diff options
Diffstat (limited to 'src/silx/gui/plot3d')
61 files changed, 24038 insertions, 0 deletions
diff --git a/src/silx/gui/plot3d/ParamTreeView.py b/src/silx/gui/plot3d/ParamTreeView.py new file mode 100644 index 0000000..2593860 --- /dev/null +++ b/src/silx/gui/plot3d/ParamTreeView.py @@ -0,0 +1,522 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2017-2021 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +""" +This module provides a :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 numbers +import sys + +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 paint(self, painter, option, index): + """See :meth:`QStyledItemDelegate.paint`""" + data = index.data(qt.Qt.DisplayRole) + + 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) + editorHint = index.data(qt.Qt.UserRole) + + if callable(editorHint): + editor = editorHint() + assert isinstance(editor, qt.QWidget) + editor.setParent(parent) + + elif isinstance(data, numbers.Number) and editorHint is not None: + # Use a slider + editor = IntSliderEditor(parent) + range_ = editorHint + editor.setRange(*range_) + editor.sliderReleased.connect(self._commit) + + elif isinstance(data, str) 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() + signature = notifySignal.methodSignature() + if qt.BINDING == 'PySide2': + signature = signature.data() + else: + signature = bytes(signature) + + 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 + header.setSectionResizeMode(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, numbers.Number) 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/src/silx/gui/plot3d/Plot3DWidget.py b/src/silx/gui/plot3d/Plot3DWidget.py new file mode 100644 index 0000000..a90d34c --- /dev/null +++ b/src/silx/gui/plot3d/Plot3DWidget.py @@ -0,0 +1,463 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2015-2021 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""This module provides a Qt widget embedding an OpenGL scene.""" + +from __future__ import absolute_import + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "24/04/2018" + + +import enum +import logging + +from silx.gui import qt +from silx.gui.colors import rgba +from . import actions + +from ...utils.enum import Enum as _Enum +from ..utils.image import convertArrayToQImage + +from .. import _glutils as glu +from .scene import interaction, primitives, transform +from . import scene + +import numpy + + +_logger = logging.getLogger(__name__) + + +class _OverviewViewport(scene.Viewport): + """A scene displaying the orientation of the data in another scene. + + :param Camera camera: The camera to track. + """ + + _SIZE = 100 + """Size in pixels of the overview square""" + + def __init__(self, camera=None): + super(_OverviewViewport, self).__init__() + self.size = self._SIZE, self._SIZE + self.background = None # Disable clear + + self.scene.transforms = [transform.Scale(2.5, 2.5, 2.5)] + + # Add a point to draw the background (in a group with depth mask) + backgroundPoint = primitives.ColorPoints( + x=0., y=0., z=0., + color=(1., 1., 1., 0.5), + size=self._SIZE) + backgroundPoint.marker = 'o' + noDepthGroup = primitives.GroupNoDepth(mask=True, notest=True) + noDepthGroup.children.append(backgroundPoint) + self.scene.children.append(noDepthGroup) + + axes = primitives.Axes() + self.scene.children.append(axes) + + if camera is not None: + camera.addListener(self._cameraChanged) + + def _cameraChanged(self, source): + """Listen to camera in other scene for transformation updates. + + Sync the overview camera to point in the same direction + but from a sphere centered on origin. + """ + position = -12. * source.extrinsic.direction + self.camera.extrinsic.position = position + + self.camera.extrinsic.setOrientation( + source.extrinsic.direction, source.extrinsic.up) + + +class Plot3DWidget(glu.OpenGLWidget): + """OpenGL widget with a 3D viewport and an overview.""" + + sigInteractiveModeChanged = qt.Signal() + """Signal emitted when the interactive mode has changed + """ + + sigStyleChanged = qt.Signal(str) + """Signal emitted when the style of the scene has changed + + It provides the updated property. + """ + + sigSceneClicked = qt.Signal(float, float) + """Signal emitted when the scene is clicked with the left mouse button. + + It provides the (x, y) clicked mouse position in logical widget pixel coordinates. + """ + + @enum.unique + class FogMode(_Enum): + """Different mode to render the scene with fog""" + + NONE = 'none' + """No fog effect""" + + LINEAR = 'linear' + """Linear fog through the whole scene""" + + def __init__(self, parent=None, f=qt.Qt.WindowFlags()): + self._firstRender = True + + super(Plot3DWidget, self).__init__( + parent, + alphaBufferSize=8, + depthBufferSize=0, + stencilBufferSize=0, + version=(2, 1), + f=f) + + self.setAutoFillBackground(False) + self.setMouseTracking(True) + + self.setFocusPolicy(qt.Qt.StrongFocus) + self._copyAction = actions.io.CopyAction(parent=self, plot3d=self) + self.addAction(self._copyAction) + + self._updating = False # True if an update is requested + + # Main viewport + self.viewport = scene.Viewport() + + self._sceneScale = transform.Scale(1., 1., 1.) + self.viewport.scene.transforms = [self._sceneScale, + transform.Translate(0., 0., 0.)] + + # Overview area + self.overview = _OverviewViewport(self.viewport.camera) + + self.setBackgroundColor((0.2, 0.2, 0.2, 1.)) + + # Window describing on screen area to render + self._window = scene.Window(mode='framebuffer') + self._window.viewports = [self.viewport, self.overview] + self._window.addListener(self._redraw) + + self.eventHandler = None + self.setInteractiveMode('rotate') + + def __clickHandler(self, *args): + """Handle interaction state machine click""" + x, y = args[0][:2] + # Convert from device pixel to logical pixel unit + devicePixelRatio = self.getDevicePixelRatio() + self.sigSceneClicked.emit(x / devicePixelRatio, y / devicePixelRatio) + + def setInteractiveMode(self, mode): + """Set the interactive mode. + + :param str mode: The interactive mode: 'rotate', 'pan' or None + """ + if mode == self.getInteractiveMode(): + return + + if mode is None: + self.eventHandler = None + + elif mode == 'rotate': + self.eventHandler = interaction.RotateCameraControl( + self.viewport, + orbitAroundCenter=False, + mode='position', + scaleTransform=self._sceneScale, + selectCB=self.__clickHandler) + + elif mode == 'pan': + self.eventHandler = interaction.PanCameraControl( + self.viewport, + orbitAroundCenter=False, + mode='position', + scaleTransform=self._sceneScale, + selectCB=self.__clickHandler) + + elif isinstance(mode, interaction.StateMachine): + self.eventHandler = mode + + else: + raise ValueError('Unsupported interactive mode %s', str(mode)) + + if (self.eventHandler is not None and + qt.QApplication.keyboardModifiers() & qt.Qt.ControlModifier): + self.eventHandler.handleEvent('keyPress', qt.Qt.Key_Control) + + self.sigInteractiveModeChanged.emit() + + def getInteractiveMode(self): + """Returns the interactive mode in use. + + :rtype: str + """ + if self.eventHandler is None: + return None + if isinstance(self.eventHandler, interaction.RotateCameraControl): + return 'rotate' + elif isinstance(self.eventHandler, interaction.PanCameraControl): + return 'pan' + else: + return None + + def setProjection(self, projection): + """Change the projection in use. + + :param str projection: In 'perspective', 'orthographic'. + """ + if projection == 'orthographic': + projection = transform.Orthographic(size=self.viewport.size) + elif projection == 'perspective': + projection = transform.Perspective(fovy=30., + size=self.viewport.size) + else: + raise RuntimeError('Unsupported projection: %s' % projection) + + self.viewport.camera.intrinsic = projection + self.viewport.resetCamera() + + def getProjection(self): + """Return the current camera projection mode as a str. + + See :meth:`setProjection` + """ + projection = self.viewport.camera.intrinsic + if isinstance(projection, transform.Orthographic): + return 'orthographic' + elif isinstance(projection, transform.Perspective): + return 'perspective' + else: + raise RuntimeError('Unknown projection in use') + + def setBackgroundColor(self, color): + """Set the background color of the OpenGL view. + + :param color: RGB color of the isosurface: name, #RRGGBB or RGB values + :type color: + QColor, str or array-like of 3 or 4 float in [0., 1.] or uint8 + """ + color = rgba(color) + if color != self.viewport.background: + self.viewport.background = color + self.sigStyleChanged.emit('backgroundColor') + + def getBackgroundColor(self): + """Returns the RGBA background color (QColor).""" + return qt.QColor.fromRgbF(*self.viewport.background) + + def setFogMode(self, mode): + """Set the kind of fog to use for the whole scene. + + :param Union[str,FogMode] mode: The mode to use + :raise ValueError: If mode is not supported + """ + mode = self.FogMode.from_value(mode) + if mode != self.getFogMode(): + self.viewport.fog.isOn = mode is self.FogMode.LINEAR + self.sigStyleChanged.emit('fogMode') + + def getFogMode(self): + """Returns the kind of fog in use + + :return: The kind of fog in use + :rtype: FogMode + """ + if self.viewport.fog.isOn: + return self.FogMode.LINEAR + else: + return self.FogMode.NONE + + def isOrientationIndicatorVisible(self): + """Returns True if the orientation indicator is displayed. + + :rtype: bool + """ + return self.overview in self._window.viewports + + def setOrientationIndicatorVisible(self, visible): + """Set the orientation indicator visibility. + + :param bool visible: True to show + """ + visible = bool(visible) + if visible != self.isOrientationIndicatorVisible(): + if visible: + self._window.viewports = [self.viewport, self.overview] + else: + self._window.viewports = [self.viewport] + self.sigStyleChanged.emit('orientationIndicatorVisible') + + def centerScene(self): + """Position the center of the scene at the center of rotation.""" + self.viewport.resetCamera() + + def resetZoom(self, face='front'): + """Reset the camera position to a default. + + :param str face: The direction the camera is looking at: + side, front, back, top, bottom, right, left. + Default: front. + """ + self.viewport.camera.extrinsic.reset(face=face) + self.centerScene() + + def _redraw(self, source=None): + """Viewport listener to require repaint""" + if not self._updating: + self._updating = True # Mark that an update is requested + self.update() # Queued repaint (i.e., asynchronous) + + def sizeHint(self): + return qt.QSize(400, 300) + + def initializeGL(self): + pass + + def paintGL(self): + # In case paintGL is called by the system and not through _redraw, + # Mark as updating. + self._updating = True + + # Update near and far planes only if viewport needs refresh + if self.viewport.dirty: + self.viewport.adjustCameraDepthExtent() + + self._window.render(self.context(), self.getDevicePixelRatio()) + + if self._firstRender: # TODO remove this ugly hack + self._firstRender = False + self.centerScene() + self._updating = False + + def resizeGL(self, width, height): + width *= self.getDevicePixelRatio() + height *= self.getDevicePixelRatio() + self._window.size = width, height + self.viewport.size = self._window.size + overviewWidth, overviewHeight = self.overview.size + self.overview.origin = width - overviewWidth, height - overviewHeight + + def grabGL(self): + """Renders the OpenGL scene into a numpy array + + :returns: OpenGL scene RGB rasterization + :rtype: QImage + """ + if not self.isValid(): + _logger.error('OpenGL 2.1 not available, cannot save OpenGL image') + height, width = self._window.shape + image = numpy.zeros((height, width, 3), dtype=numpy.uint8) + + else: + self.makeCurrent() + image = self._window.grab(self.context()) + + return convertArrayToQImage(image) + + def wheelEvent(self, event): + if qt.BINDING == "PySide6": + x, y = event.position().x(), event.position().y() + else: + x, y = event.x(), event.y() + xpixel = x * self.getDevicePixelRatio() + ypixel = y * self.getDevicePixelRatio() + angle = event.angleDelta().y() / 8. + event.accept() + + if self.eventHandler is not None and angle != 0 and self.isValid(): + self.makeCurrent() + self.eventHandler.handleEvent('wheel', xpixel, ypixel, angle) + + def keyPressEvent(self, event): + keyCode = event.key() + # No need to accept QKeyEvent + + converter = { + qt.Qt.Key_Left: 'left', + qt.Qt.Key_Right: 'right', + qt.Qt.Key_Up: 'up', + qt.Qt.Key_Down: 'down' + } + direction = converter.get(keyCode, None) + if direction is not None: + if event.modifiers() == qt.Qt.ControlModifier: + self.viewport.camera.rotate(direction) + elif event.modifiers() == qt.Qt.ShiftModifier: + self.viewport.moveCamera(direction) + else: + self.viewport.orbitCamera(direction) + + else: + if (keyCode == qt.Qt.Key_Control and + self.eventHandler is not None and + self.isValid()): + self.eventHandler.handleEvent('keyPress', keyCode) + + # Key not handled, call base class implementation + super(Plot3DWidget, self).keyPressEvent(event) + + def keyReleaseEvent(self, event): + """Catch Ctrl key release""" + keyCode = event.key() + if (keyCode == qt.Qt.Key_Control and + self.eventHandler is not None and + self.isValid()): + self.eventHandler.handleEvent('keyRelease', keyCode) + super(Plot3DWidget, self).keyReleaseEvent(event) + + # Mouse events # + _MOUSE_BTNS = {1: 'left', 2: 'right', 4: 'middle'} + + def mousePressEvent(self, event): + xpixel = event.x() * self.getDevicePixelRatio() + ypixel = event.y() * self.getDevicePixelRatio() + btn = self._MOUSE_BTNS[event.button()] + event.accept() + + if self.eventHandler is not None and self.isValid(): + self.makeCurrent() + self.eventHandler.handleEvent('press', xpixel, ypixel, btn) + + def mouseMoveEvent(self, event): + xpixel = event.x() * self.getDevicePixelRatio() + ypixel = event.y() * self.getDevicePixelRatio() + event.accept() + + if self.eventHandler is not None and self.isValid(): + self.makeCurrent() + self.eventHandler.handleEvent('move', xpixel, ypixel) + + def mouseReleaseEvent(self, event): + xpixel = event.x() * self.getDevicePixelRatio() + ypixel = event.y() * self.getDevicePixelRatio() + btn = self._MOUSE_BTNS[event.button()] + event.accept() + + if self.eventHandler is not None and self.isValid(): + self.makeCurrent() + self.eventHandler.handleEvent('release', xpixel, ypixel, btn) diff --git a/src/silx/gui/plot3d/Plot3DWindow.py b/src/silx/gui/plot3d/Plot3DWindow.py new file mode 100644 index 0000000..470b966 --- /dev/null +++ b/src/silx/gui/plot3d/Plot3DWindow.py @@ -0,0 +1,88 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2015-2019 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 scene and associated toolbar. +""" + +from __future__ import absolute_import + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "26/01/2017" + + +from silx.utils.proxy import docstring +from silx.gui import qt + +from .Plot3DWidget import Plot3DWidget +from .tools import OutputToolBar, InteractiveModeToolBar, ViewpointToolBar + + +class Plot3DWindow(qt.QMainWindow): + """OpenGL widget with a 3D viewport and an overview.""" + + def __init__(self, parent=None): + super(Plot3DWindow, self).__init__(parent) + if parent is not None: + # behave as a widget + self.setWindowFlags(qt.Qt.Widget) + + self._plot3D = Plot3DWidget() + self.setCentralWidget(self._plot3D) + + 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""" + return self._plot3D + + # Proxy to Plot3DWidget + + @docstring(Plot3DWidget) + def setProjection(self, projection): + return self._plot3D.setProjection(projection) + + @docstring(Plot3DWidget) + def getProjection(self): + return self._plot3D.getProjection() + + @docstring(Plot3DWidget) + def centerScene(self): + return self._plot3D.centerScene() + + @docstring(Plot3DWidget) + def resetZoom(self): + return self._plot3D.resetZoom() + + @docstring(Plot3DWidget) + def getBackgroundColor(self): + return self._plot3D.getBackgroundColor() + + @docstring(Plot3DWidget) + def setBackgroundColor(self, color): + return self._plot3D.setBackgroundColor(color) diff --git a/src/silx/gui/plot3d/SFViewParamTree.py b/src/silx/gui/plot3d/SFViewParamTree.py new file mode 100644 index 0000000..b269a6a --- /dev/null +++ b/src/silx/gui/plot3d/SFViewParamTree.py @@ -0,0 +1,1814 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2015-2021 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +""" +This module provides a tree widget to set/view parameters of a ScalarFieldView. +""" + +from __future__ import absolute_import + +__authors__ = ["D. N."] +__license__ = "MIT" +__date__ = "24/04/2018" + +import logging +import sys +import weakref + +import numpy + +from silx.gui import qt +from silx.gui.icons import getQIcon +from silx.gui.colors import Colormap +from silx.gui.widgets.FloatEdit import FloatEdit + +from .ScalarFieldView import Isosurface + + +_logger = logging.getLogger(__name__) + + +class ModelColumns(object): + NameColumn, ValueColumn, ColumnMax = range(3) + ColumnNames = ['Name', 'Value'] + + +class SubjectItem(qt.QStandardItem): + """ + Base class for observers items. + + Subclassing: + ------------ + The following method can/should be reimplemented: + - _init + - _pullData + - _pushData + - _setModelData + - _subjectChanged + - getEditor + - getSignals + - leftClicked + - queryRemove + - setEditorData + + Also the following attributes are available: + - editable + - persistent + + :param subject: object that this item will be observing. + """ + + editable = False + """ boolean: set to True to make the item editable. """ + + persistent = False + """ + boolean: set to True to make the editor persistent. + See : Qt.QAbstractItemView.openPersistentEditor + """ + + def __init__(self, subject, *args): + + super(SubjectItem, self).__init__(*args) + + self.setEditable(self.editable) + + self.__subject = None + self.subject = subject + + def setData(self, value, role=qt.Qt.UserRole, pushData=True): + """ + 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 + internally or from the view. + + :param value: the value ti set to data + :param role: role in the item + :param pushData: if True push value in the existing data. + """ + if role == qt.Qt.EditRole and pushData: + setValue = self._pushData(value, role) + if setValue != value: + value = setValue + super(SubjectItem, self).setData(value, role) + + @property + def subject(self): + """The subject this item is observing""" + return None if self.__subject is None else self.__subject() + + @subject.setter + def subject(self, subject): + if self.__subject is not None: + raise ValueError('Subject already set ' + ' (subject change not supported).') + if subject is None: + self.__subject = None + else: + self.__subject = weakref.ref(subject) + if subject is not None: + self._init() + self._connectSignals() + + def _connectSignals(self): + """ + Connects the signals. Called when the subject is set. + """ + + def gen_slot(_sigIdx): + def slotfn(*args, **kwargs): + self._subjectChanged(signalIdx=_sigIdx, + args=args, + kwargs=kwargs) + return slotfn + + if self.__subject is not None: + self.__slots = slots = [] + + signals = self.getSignals() + + if signals: + if not isinstance(signals, (list, tuple)): + signals = [signals] + for sigIdx, signal in enumerate(signals): + slot = gen_slot(sigIdx) + signal.connect(slot) + slots.append((signal, slot)) + + def _disconnectSignals(self): + """ + Disconnects all subject's signal + """ + if self.__slots: + for signal, slot in self.__slots: + try: + signal.disconnect(slot) + except TypeError: + pass + + def _enableRow(self, enable): + """ + Set the enabled state for this cell, or for the whole row + if this item has a parent. + + :param bool enable: True if we wan't to enable the cell + """ + parent = self.parent() + model = self.model() + if model is None or parent is None: + # no parent -> no siblings + self.setEnabled(enable) + return + + for col in range(model.columnCount()): + sibling = parent.child(self.row(), col) + sibling.setEnabled(enable) + + ################################################################# + # Overloadable methods + ################################################################# + + def getSignals(self): + """ + Returns the list of this items subject's signals that + this item will be listening to. + + :return: list. + """ + return None + + def _subjectChanged(self, signalIdx=None, args=None, kwargs=None): + """ + Called when one of the signals is triggered. Default implementation + just calls _pullData, compares the result to the current value stored + as Qt.EditRole, and stores the new value if it is different. It also + stores its str representation as Qt.DisplayRole + + :param signalIdx: index of the triggered signal. The value passed + is the same as the signal position in the list returned by + SubjectItem.getSignals. + :param args: arguments received from the signal + :param kwargs: keyword arguments received from the signal + """ + data = self._pullData() + if data == self.data(qt.Qt.EditRole): + return + self.setData(data, role=qt.Qt.DisplayRole, pushData=False) + self.setData(data, role=qt.Qt.EditRole, pushData=False) + + def _pullData(self): + """ + Pulls data from the subject. + + :return: subject data + """ + return None + + def _pushData(self, value, role=qt.Qt.UserRole): + """ + Pushes data to the subject and returns the actual value that was stored + + :return: the value that was stored + """ + return value + + def _init(self): + """ + Called when the subject is set. + :return: + """ + self._subjectChanged() + + def getEditor(self, parent, option, index): + """ + Returns the editor widget used to edit this item's data. The arguments + are the one passed to the QStyledItemDelegate.createEditor method. + + :param parent: the Qt parent of the editor + :param option: + :param index: + :return: + """ + return None + + def setEditorData(self, editor): + """ + This is called by the View's delegate just before the editor is shown, + its purpose it to setup the editors contents. Return False to use + the delegate's default behaviour. + + :param editor: + :return: + """ + return True + + def _setModelData(self, editor): + """ + This is called by the View's delegate just before the editor is closed, + its allows this item to update itself with data from the editor. + + :param editor: + :return: + """ + return False + + def queryRemove(self, view=None): + """ + This is called by the view to ask this items if it (the view) can + remove it. Return True to let the view know that the item can be + removed. + + :param view: + :return: + """ + return False + + def leftClicked(self): + """ + This method is called by the view when the item's cell if left clicked. + + :return: + """ + pass + + +# View settings ############################################################### + +class ColorItem(SubjectItem): + """color item.""" + editable = True + persistent = True + + def getEditor(self, parent, option, index): + editor = QColorEditor(parent) + editor.color = self.getColor() + + # Wrapping call in lambda is a workaround for PySide with Python 3 + editor.sigColorChanged.connect( + lambda color: self._editorSlot(color)) + return editor + + def _editorSlot(self, color): + self.setData(color, qt.Qt.EditRole) + + def _pushData(self, value, role=qt.Qt.UserRole): + self.setColor(value) + return self.getColor() + + def _pullData(self): + self.getColor() + + def setColor(self, color): + """Override to implement actual color setter""" + pass + + +class BackgroundColorItem(ColorItem): + itemName = 'Background' + + def setColor(self, color): + self.subject.setBackgroundColor(color) + + def getColor(self): + return self.subject.getBackgroundColor() + + +class ForegroundColorItem(ColorItem): + itemName = 'Foreground' + + def setColor(self, color): + self.subject.setForegroundColor(color) + + def getColor(self): + return self.subject.getForegroundColor() + + +class HighlightColorItem(ColorItem): + itemName = 'Highlight' + + def setColor(self, color): + self.subject.setHighlightColor(color) + + def getColor(self): + 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(int(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(int(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. + + Item is checkable. + """ + itemName = 'Bounding Box' + + def _init(self): + visible = self.subject.isBoundingBoxVisible() + self.setCheckable(True) + self.setCheckState(qt.Qt.Checked if visible else qt.Qt.Unchecked) + + def leftClicked(self): + checked = (self.checkState() == qt.Qt.Checked) + if checked != self.subject.isBoundingBoxVisible(): + self.subject.setBoundingBoxVisible(checked) + + +class OrientationIndicatorItem(SubjectItem): + """Orientation indicator visibility item. + + Item is checkable. + """ + itemName = 'Axes indicator' + + def _init(self): + plot3d = self.subject.getPlot3DWidget() + visible = plot3d.isOrientationIndicatorVisible() + self.setCheckable(True) + self.setCheckState(qt.Qt.Checked if visible else qt.Qt.Unchecked) + + def leftClicked(self): + plot3d = self.subject.getPlot3DWidget() + checked = (self.checkState() == qt.Qt.Checked) + if checked != plot3d.isOrientationIndicatorVisible(): + plot3d.setOrientationIndicatorVisible(checked) + + +class ViewSettingsItem(qt.QStandardItem): + """Viewport settings""" + + def __init__(self, subject, *args): + + super(ViewSettingsItem, self).__init__(*args) + + self.setEditable(False) + + classes = (BackgroundColorItem, + ForegroundColorItem, + HighlightColorItem, + 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 ############################################################ + +class DataChangedItem(SubjectItem): + """ + Base class for items listening to ScalarFieldView.sigDataChanged + """ + + def getSignals(self): + subject = self.subject + if subject: + return subject.sigDataChanged, subject.sigTransformChanged + return None + + def _init(self): + self._subjectChanged() + + +class DataTypeItem(DataChangedItem): + itemName = 'dtype' + + def _pullData(self): + data = self.subject.getData(copy=False) + return ((data is not None) and str(data.dtype)) or 'N/A' + + +class DataShapeItem(DataChangedItem): + itemName = 'size' + + def _pullData(self): + data = self.subject.getData(copy=False) + if data is None: + return 'N/A' + else: + return str(list(reversed(data.shape))) + + +class OffsetItem(DataChangedItem): + itemName = 'offset' + + def _pullData(self): + offset = self.subject.getTranslation() + return ((offset is not None) and str(offset)) or 'N/A' + + +class ScaleItem(DataChangedItem): + itemName = 'scale' + + def _pullData(self): + scale = self.subject.getScale() + 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): + + super(DataSetItem, self).__init__(*args) + + self.setEditable(False) + + 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 ################################################################## + +class IsoSurfaceRootItem(SubjectItem): + """ + Root (i.e : column index 0) Isosurface item. + """ + + def __init__(self, subject, normalization, *args): + self._isoLevelSliderNormalization = normalization + super(IsoSurfaceRootItem, self).__init__(subject, *args) + + def getSignals(self): + subject = self.subject + return [subject.sigColorChanged, + subject.sigVisibilityChanged] + + def _subjectChanged(self, signalIdx=None, args=None, kwargs=None): + if signalIdx == 0: + color = self.subject.getColor() + self.setData(color, qt.Qt.DecorationRole) + elif signalIdx == 1: + visible = args[0] + self.setCheckState((visible and qt.Qt.Checked) or qt.Qt.Unchecked) + + def _init(self): + self.setCheckable(True) + + isosurface = self.subject + color = isosurface.getColor() + visible = isosurface.isVisible() + self.setData(color, qt.Qt.DecorationRole) + self.setCheckState((visible and qt.Qt.Checked) or qt.Qt.Unchecked) + + nameItem = qt.QStandardItem('Level') + sliderItem = IsoSurfaceLevelSlider(self.subject, + self._isoLevelSliderNormalization) + self.appendRow([nameItem, sliderItem]) + + nameItem = qt.QStandardItem('Color') + nameItem.setEditable(False) + valueItem = IsoSurfaceColorItem(self.subject) + self.appendRow([nameItem, valueItem]) + + nameItem = qt.QStandardItem('Opacity') + nameItem.setTextAlignment(qt.Qt.AlignLeft | qt.Qt.AlignTop) + nameItem.setEditable(False) + valueItem = IsoSurfaceAlphaItem(self.subject) + self.appendRow([nameItem, valueItem]) + + nameItem = qt.QStandardItem() + nameItem.setEditable(False) + valueItem = IsoSurfaceAlphaLegendItem(self.subject) + valueItem.setEditable(False) + self.appendRow([nameItem, valueItem]) + + def queryRemove(self, view=None): + buttons = qt.QMessageBox.Ok | qt.QMessageBox.Cancel + ans = qt.QMessageBox.question(view, + 'Remove isosurface', + 'Remove the selected iso-surface?', + buttons=buttons) + if ans == qt.QMessageBox.Ok: + sfview = self.subject.parent() + if sfview: + sfview.removeIsosurface(self.subject) + return False + return False + + def leftClicked(self): + checked = (self.checkState() == qt.Qt.Checked) + visible = self.subject.isVisible() + if checked != visible: + self.subject.setVisible(checked) + + +class IsoSurfaceLevelItem(SubjectItem): + """ + Base class for the isosurface level items. + """ + editable = True + + def getSignals(self): + subject = self.subject + 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() + + def _pushData(self, value, role=qt.Qt.UserRole): + self.subject.setLevel(value) + return self.subject.getLevel() + + +class _IsoLevelSlider(qt.QSlider): + """QSlider used for iso-surface level with linear scale""" + + def __init__(self, parent, subject, normalization): + super(_IsoLevelSlider, self).__init__(parent=parent) + self.subject = subject + + if normalization == 'arcsinh': + self.__norm = numpy.arcsinh + self.__invNorm = numpy.sinh + elif normalization == 'linear': + self.__norm = lambda x: x + self.__invNorm = lambda x: x + else: + raise ValueError( + "Unsupported normalization %s", normalization) + + self.sliderReleased.connect(self.__sliderReleased) + + self.subject.sigLevelChanged.connect(self.setLevel) + self.subject.parent().sigDataChanged.connect(self.__dataChanged) + + def setLevel(self, level): + """Set slider from iso-surface level""" + dataRange = self.subject.parent().getDataRange() + + if dataRange is not None: + min_ = self.__norm(dataRange[0]) + max_ = self.__norm(dataRange[-1]) + + width = max_ - min_ + if width > 0: + sliderWidth = self.maximum() - self.minimum() + sliderPosition = sliderWidth * (self.__norm(level) - min_) / width + self.setValue(int(sliderPosition)) + + def __dataChanged(self): + """Handles data update to refresh slider range if needed""" + self.setLevel(self.subject.getLevel()) + + def __sliderReleased(self): + value = self.value() + dataRange = self.subject.parent().getDataRange() + if dataRange is not None: + min_ = self.__norm(dataRange[0]) + max_ = self.__norm(dataRange[-1]) + width = max_ - min_ + sliderWidth = self.maximum() - self.minimum() + level = min_ + width * value / sliderWidth + self.subject.setLevel(self.__invNorm(level)) + + +class IsoSurfaceLevelSlider(IsoSurfaceLevelItem): + """ + Isosurface level item with a slider editor. + """ + nTicks = 1000 + persistent = True + + def __init__(self, subject, normalization): + self.normalization = normalization + super(IsoSurfaceLevelSlider, self).__init__(subject) + + def getEditor(self, parent, option, index): + editor = _IsoLevelSlider(parent, self.subject, self.normalization) + editor.setOrientation(qt.Qt.Horizontal) + editor.setMinimum(0) + editor.setMaximum(self.nTicks) + + editor.setSingleStep(1) + + editor.setLevel(self.subject.getLevel()) + return editor + + def setEditorData(self, editor): + return True + + def _setModelData(self, editor): + return True + + +class IsoSurfaceColorItem(SubjectItem): + """ + Isosurface color item. + """ + editable = True + persistent = True + + def getSignals(self): + return self.subject.sigColorChanged + + def getEditor(self, parent, option, index): + editor = QColorEditor(parent) + color = self.subject.getColor() + color.setAlpha(255) + editor.color = color + # Wrapping call in lambda is a workaround for PySide with Python 3 + editor.sigColorChanged.connect( + lambda color: self.__editorChanged(color)) + return editor + + def __editorChanged(self, color): + color.setAlpha(self.subject.getColor().alpha()) + self.subject.setColor(color) + + def _pushData(self, value, role=qt.Qt.UserRole): + self.subject.setColor(value) + return self.subject.getColor() + + +class QColorEditor(qt.QWidget): + """ + QColor editor. + """ + sigColorChanged = qt.Signal(object) + + color = property(lambda self: qt.QColor(self.__color)) + + @color.setter + def color(self, color): + self._setColor(color) + self.__previousColor = color + + def __init__(self, *args, **kwargs): + super(QColorEditor, self).__init__(*args, **kwargs) + layout = qt.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + button = qt.QToolButton() + icon = qt.QIcon(qt.QPixmap(32, 32)) + button.setIcon(icon) + layout.addWidget(button) + button.clicked.connect(self.__showColorDialog) + layout.addStretch(1) + + self.__color = None + self.__previousColor = None + + def sizeHint(self): + return qt.QSize(0, 0) + + def _setColor(self, qColor): + button = self.findChild(qt.QToolButton) + pixmap = qt.QPixmap(32, 32) + pixmap.fill(qColor) + button.setIcon(qt.QIcon(pixmap)) + self.__color = qColor + + def __showColorDialog(self): + dialog = qt.QColorDialog(parent=self) + if sys.platform == 'darwin': + # Use of native color dialog on macos might cause problems + dialog.setOption(qt.QColorDialog.DontUseNativeDialog, True) + + self.__previousColor = self.__color + dialog.setAttribute(qt.Qt.WA_DeleteOnClose) + dialog.setModal(True) + dialog.currentColorChanged.connect(self.__colorChanged) + dialog.finished.connect(self.__dialogClosed) + dialog.show() + + def __colorChanged(self, color): + self.__color = color + self._setColor(color) + self.sigColorChanged.emit(color) + + def __dialogClosed(self, result): + if result == qt.QDialog.Rejected: + self.__colorChanged(self.__previousColor) + self.__previousColor = None + + +class IsoSurfaceAlphaItem(SubjectItem): + """ + Isosurface alpha item. + """ + editable = True + persistent = True + + def _init(self): + pass + + def getSignals(self): + return self.subject.sigColorChanged + + def getEditor(self, parent, option, index): + editor = qt.QSlider(parent) + editor.setOrientation(qt.Qt.Horizontal) + editor.setMinimum(0) + editor.setMaximum(255) + + color = self.subject.getColor() + editor.setValue(color.alpha()) + + # Wrapping call in lambda is a workaround for PySide with Python 3 + editor.valueChanged.connect( + lambda value: self.__editorChanged(value)) + + return editor + + def __editorChanged(self, value): + color = self.subject.getColor() + color.setAlpha(value) + self.subject.setColor(color) + + def setEditorData(self, editor): + return True + + def _setModelData(self, editor): + return True + + +class IsoSurfaceAlphaLegendItem(SubjectItem): + """Legend to place under opacity slider""" + + editable = False + persistent = True + + def getEditor(self, parent, option, index): + layout = qt.QHBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + layout.addWidget(qt.QLabel('0')) + layout.addStretch(1) + layout.addWidget(qt.QLabel('1')) + + editor = qt.QWidget(parent) + editor.setLayout(layout) + return editor + + +class IsoSurfaceCount(SubjectItem): + """ + Item displaying the number of isosurfaces. + """ + + def getSignals(self): + subject = self.subject + return [subject.sigIsosurfaceAdded, subject.sigIsosurfaceRemoved] + + def _pullData(self): + return len(self.subject.getIsosurfaces()) + + +class IsoSurfaceAddRemoveWidget(qt.QWidget): + + sigViewTask = qt.Signal(str) + """Signal for the tree view to perform some task""" + + def __init__(self, parent, item): + super(IsoSurfaceAddRemoveWidget, self).__init__(parent) + self._item = item + layout = qt.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + addBtn = qt.QToolButton(self) + addBtn.setText('+') + addBtn.setToolButtonStyle(qt.Qt.ToolButtonTextOnly) + layout.addWidget(addBtn) + addBtn.clicked.connect(self.__addClicked) + + removeBtn = qt.QToolButton(self) + removeBtn.setText('-') + removeBtn.setToolButtonStyle(qt.Qt.ToolButtonTextOnly) + layout.addWidget(removeBtn) + removeBtn.clicked.connect(self.__removeClicked) + + layout.addStretch(1) + + def __addClicked(self): + sfview = self._item.subject + if not sfview: + return + dataRange = sfview.getDataRange() + if dataRange is None: + dataRange = [0, 1] + + sfview.addIsosurface( + numpy.mean((dataRange[0], dataRange[-1])), '#0000FF') + + def __removeClicked(self): + self.sigViewTask.emit('remove_iso') + + +class IsoSurfaceAddRemoveItem(SubjectItem): + """ + Item displaying a simple QToolButton allowing to add an isosurface. + """ + persistent = True + + def getEditor(self, parent, option, index): + return IsoSurfaceAddRemoveWidget(parent, self) + + +class IsoSurfaceGroup(SubjectItem): + """ + Root item for the list of isosurface items. + """ + + def __init__(self, subject, normalization, *args): + self._isoLevelSliderNormalization = normalization + super(IsoSurfaceGroup, self).__init__(subject, *args) + + def getSignals(self): + subject = self.subject + return [subject.sigIsosurfaceAdded, subject.sigIsosurfaceRemoved] + + def _subjectChanged(self, signalIdx=None, args=None, kwargs=None): + if signalIdx == 0: + if len(args) >= 1: + isosurface = args[0] + if not isinstance(isosurface, Isosurface): + raise ValueError('Expected an isosurface instance.') + self.__addIsosurface(isosurface) + else: + raise ValueError('Expected an isosurface instance.') + elif signalIdx == 1: + if len(args) >= 1: + isosurface = args[0] + if not isinstance(isosurface, Isosurface): + raise ValueError('Expected an isosurface instance.') + self.__removeIsosurface(isosurface) + else: + raise ValueError('Expected an isosurface instance.') + + def __addIsosurface(self, isosurface): + valueItem = IsoSurfaceRootItem( + subject=isosurface, + normalization=self._isoLevelSliderNormalization) + nameItem = IsoSurfaceLevelItem(subject=isosurface) + self.insertRow(max(0, self.rowCount() - 1), [valueItem, nameItem]) + + def __removeIsosurface(self, isosurface): + for row in range(self.rowCount()): + child = self.child(row) + subject = getattr(child, 'subject', None) + if subject == isosurface: + self.takeRow(row) + break + + def _init(self): + nameItem = IsoSurfaceAddRemoveItem(self.subject) + valueItem = qt.QStandardItem() + valueItem.setEditable(False) + self.appendRow([nameItem, valueItem]) + + subject = self.subject + isosurfaces = subject.getIsosurfaces() + for isosurface in isosurfaces: + self.__addIsosurface(isosurface) + + +# Cutting Plane ############################################################### + +class ColormapBase(SubjectItem): + """ + Mixin class for colormap items. + """ + + def getSignals(self): + return [self.subject.getCutPlanes()[0].sigColormapChanged] + + +class PlaneMinRangeItem(ColormapBase): + """ + colormap minVal item. + Editor is a QLineEdit with a QDoubleValidator + """ + editable = True + + def _pullData(self): + colormap = self.subject.getCutPlanes()[0].getColormap() + auto = colormap.isAutoscale() + if auto == self.isEnabled(): + self._enableRow(not auto) + return colormap.getVMin() + + def _pushData(self, value, role=qt.Qt.UserRole): + self._setVMin(value) + + def _setVMin(self, value): + colormap = self.subject.getCutPlanes()[0].getColormap() + vMin = value + vMax = colormap.getVMax() + + if vMax is not None and value > vMax: + vMin = vMax + vMax = value + colormap.setVRange(vMin, vMax) + + def getEditor(self, parent, option, index): + return FloatEdit(parent) + + def setEditorData(self, editor): + editor.setValue(self._pullData()) + return True + + def _setModelData(self, editor): + value = editor.value() + self._setVMin(value) + return True + + +class PlaneMaxRangeItem(ColormapBase): + """ + colormap maxVal item. + Editor is a QLineEdit with a QDoubleValidator + """ + editable = True + + def _pullData(self): + colormap = self.subject.getCutPlanes()[0].getColormap() + auto = colormap.isAutoscale() + if auto == self.isEnabled(): + self._enableRow(not auto) + return self.subject.getCutPlanes()[0].getColormap().getVMax() + + def _setVMax(self, value): + colormap = self.subject.getCutPlanes()[0].getColormap() + vMin = colormap.getVMin() + vMax = value + if vMin is not None and value < vMin: + vMax = vMin + vMin = value + colormap.setVRange(vMin, vMax) + + def getEditor(self, parent, option, index): + return FloatEdit(parent) + + def setEditorData(self, editor): + editor.setText(str(self._pullData())) + return True + + def _setModelData(self, editor): + value = editor.value() + self._setVMax(value) + return True + + +class PlaneOrientationItem(SubjectItem): + """ + Plane orientation item. + Editor is a QComboBox. + """ + editable = True + + _PLANE_ACTIONS = ( + ('3d-plane-normal-x', 'Plane 0', + 'Set plane perpendicular to red axis', (1., 0., 0.)), + ('3d-plane-normal-y', 'Plane 1', + 'Set plane perpendicular to green axis', (0., 1., 0.)), + ('3d-plane-normal-z', 'Plane 2', + 'Set plane perpendicular to blue axis', (0., 0., 1.)), + ) + + def getSignals(self): + return [self.subject.getCutPlanes()[0].sigPlaneChanged] + + def _pullData(self): + currentNormal = self.subject.getCutPlanes()[0].getNormal( + coordinates='scene') + for _, text, _, normal in self._PLANE_ACTIONS: + if numpy.allclose(normal, currentNormal): + return text + return '' + + def getEditor(self, parent, option, index): + editor = qt.QComboBox(parent) + for iconName, text, tooltip, normal in self._PLANE_ACTIONS: + editor.addItem(getQIcon(iconName), text) + + # Wrapping call in lambda is a workaround for PySide with Python 3 + editor.currentIndexChanged[int].connect( + lambda index: self.__editorChanged(index)) + return editor + + def __editorChanged(self, index): + normal = self._PLANE_ACTIONS[index][3] + plane = self.subject.getCutPlanes()[0] + plane.setNormal(normal, coordinates='scene') + plane.moveToCenter() + + def setEditorData(self, editor): + currentText = self._pullData() + index = 0 + for normIdx, (_, text, _, _) in enumerate(self._PLANE_ACTIONS): + if text == currentText: + index = normIdx + break + editor.setCurrentIndex(index) + return True + + def _setModelData(self, editor): + return True + + +class PlaneInterpolationItem(SubjectItem): + """Toggle cut plane interpolation method: nearest or linear. + + Item is checkable + """ + + def _init(self): + interpolation = self.subject.getCutPlanes()[0].getInterpolation() + self.setCheckable(True) + self.setCheckState( + qt.Qt.Checked if interpolation == 'linear' else qt.Qt.Unchecked) + self.setData(self._pullData(), role=qt.Qt.DisplayRole, pushData=False) + + def getSignals(self): + return [self.subject.getCutPlanes()[0].sigInterpolationChanged] + + def leftClicked(self): + checked = self.checkState() == qt.Qt.Checked + self._setInterpolation('linear' if checked else 'nearest') + + def _pullData(self): + interpolation = self.subject.getCutPlanes()[0].getInterpolation() + self._setInterpolation(interpolation) + return interpolation[0].upper() + interpolation[1:] + + def _setInterpolation(self, interpolation): + 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. + Editor is a QComboBox + """ + editable = True + + listValues = ['gray', 'reversed gray', + 'temperature', 'red', + 'green', 'blue', + 'viridis', 'magma', 'inferno', 'plasma'] + + def getEditor(self, parent, option, index): + editor = qt.QComboBox(parent) + editor.addItems(self.listValues) + + # Wrapping call in lambda is a workaround for PySide with Python 3 + editor.currentIndexChanged[int].connect( + lambda index: self.__editorChanged(index)) + + return editor + + def __editorChanged(self, index): + colormapName = self.listValues[index] + colormap = self.subject.getCutPlanes()[0].getColormap() + colormap.setName(colormapName) + + def setEditorData(self, editor): + colormapName = self.subject.getCutPlanes()[0].getColormap().getName() + try: + index = self.listValues.index(colormapName) + except ValueError: + _logger.error('Unsupported colormap: %s', colormapName) + else: + editor.setCurrentIndex(index) + return True + + def _setModelData(self, editor): + self.__editorChanged(editor.currentIndex()) + return True + + def _pullData(self): + return self.subject.getCutPlanes()[0].getColormap().getName() + + +class PlaneAutoScaleItem(ColormapBase): + """ + colormap autoscale item. + Item is checkable. + """ + + def _init(self): + colorMap = self.subject.getCutPlanes()[0].getColormap() + self.setCheckable(True) + self.setCheckState((colorMap.isAutoscale() and qt.Qt.Checked) + or qt.Qt.Unchecked) + self.setData(self._pullData(), role=qt.Qt.DisplayRole, pushData=False) + + def leftClicked(self): + checked = (self.checkState() == qt.Qt.Checked) + self._setAutoScale(checked) + + def _setAutoScale(self, auto): + view3d = self.subject + colormap = view3d.getCutPlanes()[0].getColormap() + + if auto != colormap.isAutoscale(): + if auto: + vMin = vMax = None + else: + dataRange = view3d.getDataRange() + if dataRange is None: + vMin = vMax = None + else: + vMin, vMax = dataRange[0], dataRange[-1] + colormap.setVRange(vMin, vMax) + + def _pullData(self): + auto = self.subject.getCutPlanes()[0].getColormap().isAutoscale() + self._setAutoScale(auto) + if auto: + data = 'Auto' + else: + data = 'User' + return data + + +class NormalizationNode(ColormapBase): + """ + colormap normalization item. + Item is a QComboBox. + """ + editable = True + listValues = list(Colormap.NORMALIZATIONS) + + def getEditor(self, parent, option, index): + editor = qt.QComboBox(parent) + editor.addItems(self.listValues) + + # Wrapping call in lambda is a workaround for PySide with Python 3 + editor.currentIndexChanged[int].connect( + lambda index: self.__editorChanged(index)) + + return editor + + def __editorChanged(self, index): + colorMap = self.subject.getCutPlanes()[0].getColormap() + normalization = self.listValues[index] + self.subject.getCutPlanes()[0].setColormap(name=colorMap.getName(), + norm=normalization, + vmin=colorMap.getVMin(), + vmax=colorMap.getVMax()) + + def setEditorData(self, editor): + normalization = self.subject.getCutPlanes()[0].getColormap().getNormalization() + index = self.listValues.index(normalization) + editor.setCurrentIndex(index) + return True + + def _setModelData(self, editor): + self.__editorChanged(editor.currentIndex()) + return True + + def _pullData(self): + return self.subject.getCutPlanes()[0].getColormap().getNormalization() + + +class PlaneGroup(SubjectItem): + """ + Root Item for the plane items. + """ + def _init(self): + valueItem = qt.QStandardItem() + valueItem.setEditable(False) + nameItem = PlaneVisibleItem(self.subject, 'Visible') + self.appendRow([nameItem, valueItem]) + + nameItem = qt.QStandardItem('Colormap') + nameItem.setEditable(False) + valueItem = PlaneColormapItem(self.subject) + self.appendRow([nameItem, valueItem]) + + nameItem = qt.QStandardItem('Normalization') + nameItem.setEditable(False) + valueItem = NormalizationNode(self.subject) + self.appendRow([nameItem, valueItem]) + + nameItem = qt.QStandardItem('Orientation') + nameItem.setEditable(False) + valueItem = PlaneOrientationItem(self.subject) + self.appendRow([nameItem, valueItem]) + + nameItem = qt.QStandardItem('Interpolation') + nameItem.setEditable(False) + valueItem = PlaneInterpolationItem(self.subject) + self.appendRow([nameItem, valueItem]) + + nameItem = qt.QStandardItem('Autoscale') + nameItem.setEditable(False) + valueItem = PlaneAutoScaleItem(self.subject) + self.appendRow([nameItem, valueItem]) + + nameItem = qt.QStandardItem('Min') + nameItem.setEditable(False) + valueItem = PlaneMinRangeItem(self.subject) + self.appendRow([nameItem, valueItem]) + + nameItem = qt.QStandardItem('Max') + nameItem.setEditable(False) + 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): + """ + Plane visibility item. + Item is checkable. + """ + def _init(self): + plane = self.subject.getCutPlanes()[0] + self.setCheckable(True) + self.setCheckState((plane.isVisible() and qt.Qt.Checked) + or qt.Qt.Unchecked) + + def leftClicked(self): + plane = self.subject.getCutPlanes()[0] + checked = (self.checkState() == qt.Qt.Checked) + if checked != plane.isVisible(): + plane.setVisible(checked) + if plane.isVisible(): + plane.moveToCenter() + + +# Tree ######################################################################## + +class ItemDelegate(qt.QStyledItemDelegate): + """ + Delegate for the QTreeView filled with SubjectItems. + """ + + sigDelegateEvent = qt.Signal(str) + + def __init__(self, parent=None): + super(ItemDelegate, self).__init__(parent) + + def createEditor(self, parent, option, index): + item = index.model().itemFromIndex(index) + if item: + if isinstance(item, SubjectItem): + editor = item.getEditor(parent, option, index) + if editor: + editor.setAutoFillBackground(True) + if hasattr(editor, 'sigViewTask'): + editor.sigViewTask.connect(self.__viewTask) + return editor + + editor = super(ItemDelegate, self).createEditor(parent, + option, + index) + return editor + + def updateEditorGeometry(self, editor, option, index): + editor.setGeometry(option.rect) + + def setEditorData(self, editor, index): + item = index.model().itemFromIndex(index) + if item: + if isinstance(item, SubjectItem) and item.setEditorData(editor): + return + super(ItemDelegate, self).setEditorData(editor, index) + + def setModelData(self, editor, model, index): + item = index.model().itemFromIndex(index) + if isinstance(item, SubjectItem) and item._setModelData(editor): + return + super(ItemDelegate, self).setModelData(editor, model, index) + + def __viewTask(self, task): + self.sigDelegateEvent.emit(task) + + +class TreeView(qt.QTreeView): + """ + TreeView displaying the SubjectItems for the ScalarFieldView. + """ + + def __init__(self, parent=None): + super(TreeView, self).__init__(parent) + self.__openedIndex = None + self._isoLevelSliderNormalization = 'linear' + + self.setIconSize(qt.QSize(16, 16)) + + header = self.header() + header.setSectionResizeMode(qt.QHeaderView.ResizeToContents) + + delegate = ItemDelegate() + self.setItemDelegate(delegate) + delegate.sigDelegateEvent.connect(self.__delegateEvent) + self.setSelectionBehavior(qt.QAbstractItemView.SelectRows) + self.setSelectionMode(qt.QAbstractItemView.SingleSelection) + + self.clicked.connect(self.__clicked) + + def setSfView(self, sfView): + """ + Sets the ScalarFieldView this view is controlling. + + :param sfView: A `ScalarFieldView` + """ + model = qt.QStandardItemModel() + model.setColumnCount(ModelColumns.ColumnMax) + model.setHorizontalHeaderLabels(['Name', 'Value']) + + item = qt.QStandardItem() + item.setEditable(False) + model.appendRow([ViewSettingsItem(sfView, 'Style'), item]) + + item = qt.QStandardItem() + item.setEditable(False) + model.appendRow([DataSetItem(sfView, 'Data'), item]) + + item = IsoSurfaceCount(sfView) + item.setEditable(False) + model.appendRow([IsoSurfaceGroup(sfView, + self._isoLevelSliderNormalization, + 'Isosurfaces'), + item]) + + item = qt.QStandardItem() + item.setEditable(False) + model.appendRow([PlaneGroup(sfView, 'Cutting Plane'), item]) + + self.setModel(model) + + def setModel(self, model): + """ + Reimplementation of the QTreeView.setModel method. It connects the + rowsRemoved signal and opens the persistent editors. + + :param qt.QStandardItemModel model: the model + """ + + prevModel = self.model() + if prevModel: + self.__openPersistentEditors(qt.QModelIndex(), False) + try: + prevModel.rowsRemoved.disconnect(self.rowsRemoved) + except TypeError: + pass + + super(TreeView, self).setModel(model) + model.rowsRemoved.connect(self.rowsRemoved) + self.__openPersistentEditors(qt.QModelIndex()) + + def __openPersistentEditors(self, parent=None, openEditor=True): + """ + Opens or closes the items persistent editors. + + :param qt.QModelIndex parent: starting index, or None if the whole tree + is to be considered. + :param bool openEditor: True to open the editors, False to close them. + """ + model = self.model() + + if not model: + return + + if not parent or not parent.isValid(): + parent = self.model().invisibleRootItem().index() + + if openEditor: + meth = self.openPersistentEditor + else: + meth = self.closePersistentEditor + + curParent = parent + children = [model.index(row, 0, curParent) + for row in range(model.rowCount(curParent))] + + columnCount = model.columnCount() + + while len(children) > 0: + curParent = children.pop(-1) + + children.extend([model.index(row, 0, curParent) + for row in range(model.rowCount(curParent))]) + + for colIdx in range(columnCount): + sibling = model.sibling(curParent.row(), + colIdx, + curParent) + item = model.itemFromIndex(sibling) + if isinstance(item, SubjectItem) and item.persistent: + meth(sibling) + + def rowsAboutToBeRemoved(self, parent, start, end): + """ + Reimplementation of the QTreeView.rowsAboutToBeRemoved. Closes all + persistent editors under parent. + + :param qt.QModelIndex parent: Parent index + :param int start: Start index from parent index (inclusive) + :param int end: End index from parent index (inclusive) + """ + self.__openPersistentEditors(parent, False) + super(TreeView, self).rowsAboutToBeRemoved(parent, start, end) + + def rowsRemoved(self, parent, start, end): + """ + Called when QTreeView.rowsRemoved is emitted. Opens all persistent + editors under parent. + + :param qt.QModelIndex parent: Parent index + :param int start: Start index from parent index (inclusive) + :param int end: End index from parent index (inclusive) + """ + super(TreeView, self).rowsRemoved(parent, start, end) + self.__openPersistentEditors(parent, True) + + def rowsInserted(self, parent, start, end): + """ + Reimplementation of the QTreeView.rowsInserted. Opens all persistent + editors under parent. + + :param qt.QModelIndex parent: Parent index + :param int start: Start index from parent index + :param int end: End index from parent index + """ + self.__openPersistentEditors(parent, False) + super(TreeView, self).rowsInserted(parent, start, end) + self.__openPersistentEditors(parent) + + def keyReleaseEvent(self, event): + """ + Reimplementation of the QTreeView.keyReleaseEvent. + At the moment only Key_Delete is handled. It calls the selected item's + queryRemove method, and deleted the item if needed. + + :param qt.QKeyEvent event: A key event + """ + + # TODO : better filtering + key = event.key() + modifiers = event.modifiers() + + if key == qt.Qt.Key_Delete and modifiers == qt.Qt.NoModifier: + self.__removeIsosurfaces() + + super(TreeView, self).keyReleaseEvent(event) + + def __removeIsosurfaces(self): + model = self.model() + selected = self.selectedIndexes() + items = [] + # WARNING : the selection mode is set to single, so we re not + # supposed to have more than one item here. + # Multiple selection deletion has not been tested. + # Watch out for index invalidation + for index in selected: + leftIndex = model.sibling(index.row(), 0, index) + leftItem = model.itemFromIndex(leftIndex) + if isinstance(leftItem, SubjectItem) and leftItem not in items: + items.append(leftItem) + + isos = [item for item in items if isinstance(item, IsoSurfaceRootItem)] + if isos: + for iso in isos: + if iso.queryRemove(self): + parentItem = iso.parent() + parentItem.removeRow(iso.row()) + else: + qt.QMessageBox.information( + self, + 'Remove isosurface', + 'Select an iso-surface to remove it') + + def __clicked(self, index): + """ + Called when the QTreeView.clicked signal is emitted. Calls the item's + leftClick method. + + :param qt.QIndex index: An index + """ + item = self.model().itemFromIndex(index) + if isinstance(item, SubjectItem): + item.leftClicked() + + def __delegateEvent(self, task): + if task == 'remove_iso': + self.__removeIsosurfaces() + + def setIsoLevelSliderNormalization(self, normalization): + """Set the normalization for iso level slider + + This MUST be called *before* :meth:`setSfView` to have an effect. + + :param str normalization: Either 'linear' or 'arcsinh' + """ + assert normalization in ('linear', 'arcsinh') + self._isoLevelSliderNormalization = normalization diff --git a/src/silx/gui/plot3d/ScalarFieldView.py b/src/silx/gui/plot3d/ScalarFieldView.py new file mode 100644 index 0000000..b2bb254 --- /dev/null +++ b/src/silx/gui/plot3d/ScalarFieldView.py @@ -0,0 +1,1552 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2015-2020 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 window to view a 3D scalar field. + +It supports iso-surfaces, a cutting plane and the definition of +a region of interest. +""" + +from __future__ import absolute_import + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "14/06/2018" + +import re +import logging +import time +from collections import deque + +import numpy + +from silx.gui import qt, icons +from silx.gui.colors import rgba +from silx.gui.colors import Colormap + +from silx.math.marchingcubes import MarchingCubes +from silx.math.combo import min_max + +from .scene import axes, cutplane, interaction, primitives, transform +from . import scene +from .Plot3DWindow import Plot3DWindow +from .tools import InteractiveModeToolBar + +_logger = logging.getLogger(__name__) + + +class Isosurface(qt.QObject): + """Class representing an iso-surface + + :param parent: The View widget this iso-surface belongs to + """ + + sigLevelChanged = qt.Signal(float) + """Signal emitted when the iso-surface level has changed. + + This signal provides the new level value (might be nan). + """ + + sigColorChanged = qt.Signal() + """Signal emitted when the iso-surface color has changed""" + + sigVisibilityChanged = qt.Signal(bool) + """Signal emitted when the iso-surface visibility has changed. + + This signal provides the new visibility status. + """ + + def __init__(self, parent): + super(Isosurface, self).__init__(parent=parent) + self._level = float('nan') + self._autoLevelFunction = None + self._color = rgba('#FFD700FF') + self._data = None + self._group = scene.Group() + + def _setData(self, data, copy=True): + """Set the data set from which to build the iso-surface. + + :param numpy.ndarray data: The 3D dataset 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._update() + + def _get3DPrimitive(self): + """Return the group containing the mesh of the iso-surface if any""" + return self._group + + def isVisible(self): + """Returns True if iso-surface is visible, else False""" + return self._group.visible + + def setVisible(self, visible): + """Set the visibility of the iso-surface in the view. + + :param bool visible: True to show the iso-surface, False to hide + """ + visible = bool(visible) + if visible != self._group.visible: + self._group.visible = visible + self.sigVisibilityChanged.emit(visible) + + 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._update() + self.sigLevelChanged.emit(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._update() + + 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 + if len(self._group.children) != 0: + self._group.children[0].setAttribute('color', self._color) + self.sigColorChanged.emit() + + def _update(self): + """Update underlying mesh""" + self._group.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.sigLevelChanged.emit(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._group.children = [mesh] + + +class SelectedRegion(object): + """Selection of a 3D region aligned with the axis. + + :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, dataBBox, + translation=(0., 0., 0.), + scale=(1., 1., 1.)): + self._arrayRange = numpy.array(arrayRange, copy=True, dtype=numpy.int64) + 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,) + + def getArrayRange(self): + """Returns array ranges of the selection: 3x2 array of int + + :return: A numpy array with ((zmin, zmax), (ymin, ymax), (xmin, xmax)) + :rtype: numpy.ndarray + """ + return self._arrayRange.copy() + + def getArraySlices(self): + """Slices corresponding to the selected range in the array + + :return: A numpy array with (zslice, yslice, zslice) + :rtype: numpy.ndarray + """ + return (slice(*self._arrayRange[0]), + slice(*self._arrayRange[1]), + slice(*self._arrayRange[2])) + + 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 + """ + return self._dataRange.copy() + + def getDataScale(self): + """Scale from array to data coordinates: (sx, sy, sz) + + :return: A numpy array with (sx, sy, sz) + :rtype: numpy.ndarray + """ + return self._scale.copy() + + def getDataTranslation(self): + """Offset from array to data coordinates: (ox, oy, oz) + + :return: A numpy array with (ox, oy, oz) + :rtype: numpy.ndarray + """ + return self._translation.copy() + + +class CutPlane(qt.QObject): + """Class representing a cutting plane + + :param ~silx.gui.plot3d.ScalarFieldView.ScalarFieldView sfView: + Widget in which the cut plane is applied. + """ + + sigVisibilityChanged = qt.Signal(bool) + """Signal emitted when the cut visibility has changed. + + This signal provides the new visibility status. + """ + + sigDataChanged = qt.Signal() + """Signal emitted when the data this plane is cutting has changed.""" + + sigPlaneChanged = qt.Signal() + """Signal emitted when the cut plane has moved""" + + sigColormapChanged = qt.Signal(Colormap) + """Signal emitted when the colormap has changed + + 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 + + This signal provides the new interpolation mode. + """ + + def __init__(self, sfView): + super(CutPlane, self).__init__(parent=sfView) + + self._dataRange = None + self._visible = False + + self.__syncPlane = True + + # 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) + self.getColormap().sigChanged.connect(self._colormapChanged) + 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]) + + 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._dataPlane.setData(self.sender().getData(), copy=False) + + # Store data range info as 3-tuple of values + self._dataRange = self.sender().getDataRange() + + self.sigDataChanged.emit() + + # Update colormap range when autoscale + 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 + if source.visible != self._visible: + self._visible = source.visible + self.sigVisibilityChanged.emit(source.visible) + + def _planePositionChanged(self, source, *args, **kwargs): + """Handle update of cut plane position and normal""" + 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._planeStroke.moveToCenter() + + def isValid(self): + """Returns whether the cut plane is defined or not (bool)""" + 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, 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(coordinates).normal + + 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(coordinates).normal = normal + + 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 + """ + self._plane(coordinates).point = point + if constraint: + self._keepPlaneInBBox() + + 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 + """ + self._plane(coordinates).parameters = parameters + if constraint: + self._keepPlaneInBBox() + + # Visibility + + def isVisible(self): + """Returns True if the plane is visible, False otherwise""" + return self._planeStroke.visible + + def setVisible(self, visible): + """Set the visibility of the plane + + :param bool visible: True to make plane 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._planeStroke.color) + + def setStrokeColor(self, color): + """Set the color of the plane border. + + :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) + self._planeStroke.color = color + self._dataPlane.color = color + + # Data + + def getImageData(self): + """Returns the data and information corresponding to the cut plane. + + The returned data is not interpolated, + it is a slice of the 3D scalar field. + + Image data axes are so that plane normal is towards the point of view. + + :return: An object containing the 2D data slice and information + """ + return _CutPlaneImage(self) + + # Interpolation + + def getInterpolation(self): + """Returns the interpolation used to display to cut plane. + + :return: 'nearest' or 'linear' + :rtype: str + """ + return self._dataPlane.interpolation + + def setInterpolation(self, interpolation): + """Set the interpolation used to display to cut plane + + The default interpolation is 'linear' + + :param str interpolation: 'nearest' or 'linear' + """ + if interpolation != self.getInterpolation(): + self._dataPlane.interpolation = interpolation + self.sigInterpolationChanged.emit(interpolation) + + # Colormap + + # def getAlpha(self): + # """Returns the transparency of the plane as a float in [0., 1.]""" + # return self._plane.alpha + + # def setAlpha(self, alpha): + # """Set the plane transparency. + # + # :param float alpha: Transparency in [0., 1] + # """ + # 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: ~silx.gui.colors.Colormap + """ + return self._colormap + + def setColormap(self, + name='gray', + norm=None, + vmin=None, + vmax=None): + """Set the colormap to use. + + By either providing a :class:`Colormap` object or + its name, normalization and range. + + :param name: Name of the colormap in + 'gray', 'reversed gray', 'temperature', 'red', 'green', 'blue'. + Or Colormap object. + :type name: str or ~silx.gui.colors.Colormap + :param str norm: Colormap mapping: 'linear' or 'log'. + :param float vmin: The minimum value of the range or None for autoscale + :param float vmax: The maximum value of the range or None for autoscale + """ + _logger.debug('setColormap %s %s (%s, %s)', + name, str(norm), str(vmin), str(vmax)) + + self._colormap.sigChanged.disconnect(self._colormapChanged) + + if isinstance(name, Colormap): # Use it as it is + assert (norm, vmin, vmax) == (None, None, None) + self._colormap = name + else: + if norm is None: + norm = 'linear' + self._colormap = Colormap( + name=name, normalization=norm, vmin=vmin, vmax=vmax) + + self._colormap.sigChanged.connect(self._colormapChanged) + self._colormapChanged() + + def getColormapEffectiveRange(self): + """Returns the currently used range of the colormap. + + This range is computed from the data set if colormap is in autoscale. + Range is clipped to positive values when using log scale. + + :return: 2-tuple of float + """ + return self._dataPlane.colormap.range_ + + def _updateSceneColormap(self): + """Synchronizes scene's colormap with Colormap object""" + colormap = self.getColormap() + sceneCMap = self._dataPlane.colormap + + sceneCMap.colormap = colormap.getNColors() + + sceneCMap.norm = colormap.getNormalization() + range_ = colormap.getColormapRange(data=self._dataRange) + sceneCMap.range_ = range_ + + def _colormapChanged(self): + """Handle update of Colormap object""" + self._updateSceneColormap() + # Forward colormap changed event + self.sigColormapChanged.emit(self.getColormap()) + + +class _CutPlaneImage(object): + """Object representing the data sliced by a cut plane + + :param CutPlane cutPlane: The CutPlane from which to generate image info + """ + + def __init__(self, cutPlane): + # Init attributes with default values + self._isValid = False + self._data = numpy.zeros((0, 0), dtype=numpy.float32) + self._index = 0 + self._xLabel = '' + self._yLabel = '' + self._normalLabel = '' + 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(): + _logger.info("No plane available") + return + + data = sfView.getData(copy=False) + if data is None: + _logger.info("No data available") + return + + normal = cutPlane.getNormal(coordinates='array') + point = cutPlane.getPoint(coordinates='array') + + 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.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.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: + _logger.warning('Unsupported normal: (%f, %f, %f)', + normal[0], normal[1], normal[2]) + return + + # Store cut plane image info + + self._isValid = True + self._data = numpy.array(slice_, copy=True) + self._index = index + + # 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] + + scale = sfView.getScale() + self._scale = scale[xAxisIndex], scale[yAxisIndex] + + translation = sfView.getTranslation() + self._translation = translation[xAxisIndex], translation[yAxisIndex] + + self._position = float(index * scale[normalAxisIndex] + + translation[normalAxisIndex]) + + def isValid(self): + """Returns True if the cut plane image is defined (bool)""" + return self._isValid + + def getData(self, copy=True): + """Returns the image data sliced by the cut plane. + + :param bool copy: True to get a copy, False otherwise + :return: The 2D image data corresponding to the cut plane + :rtype: numpy.ndarray + """ + return numpy.array(self._data, copy=copy) + + def getXLabel(self): + """Returns the label associated to the X axis of the image (str)""" + return self._xLabel + + def getYLabel(self): + """Returns the label associated to the Y axis of the image (str)""" + return self._yLabel + + def getNormalLabel(self): + """Returns the label of the 3D axis of the plane normal (str)""" + return self._normalLabel + + def getScale(self): + """Returns the scales of the data as a 2-tuple of float (sx, sy)""" + return self._scale + + def getTranslation(self): + """Returns the offset of the data as a 2-tuple of float (ox, oy)""" + return self._translation + + def getIndex(self): + """Returns the index in the data array of the cut plane (int)""" + return self._index + + def getPosition(self): + """Returns the cut plane position along the normal axis (flaot)""" + return self._position + + +class ScalarFieldView(Plot3DWindow): + """Widget computing and displaying an iso-surface from a 3D scalar dataset. + + Limitation: Currently, iso-surfaces are generated with higher values + than the iso-level 'inside' the surface. + + :param parent: See :class:`QMainWindow` + """ + + 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. + + This signal provides the new selected region. + """ + + def __init__(self, parent=None): + super(ScalarFieldView, self).__init__(parent) + self._colormap = Colormap( + name='gray', normalization='linear', vmin=None, vmax=None) + self._selectedRange = None + + # Store iso-surfaces + self._isosurfaces = [] + + # 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. + + self._data = None + self._dataRange = None + + self._group = primitives.BoundedGroup() + self._group.transforms = [ + self._dataTranslate, self._dataTransform, self._dataScale] + + self._bbox = axes.LabelledAxes() + self._bbox.children = [self._group] + self._outerScale = transform.Scale(1., 1., 1.) + self._bbox.transforms = [self._outerScale] + self.getPlot3DWidget().viewport.scene.children.append(self._bbox) + + self._selectionBox = primitives.Box() + self._selectionBox.strokeSmooth = False + self._selectionBox.strokeWidth = 1. + # self._selectionBox.fillColor = 1., 1., 1., 0.3 + # self._selectionBox.fillCulling = 'back' + self._selectionBox.visible = False + self._group.children.append(self._selectionBox) + + self._cutPlane = CutPlane(sfView=self) + self._cutPlane.sigVisibilityChanged.connect( + self._planeVisibilityChanged) + planeStroke, dataPlane = self._cutPlane._get3DPrimitives() + self._bbox.children.append(planeStroke) + self._group.children.append(dataPlane) + + 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._group.children.append(self._isogroup) + + self._initPanPlaneAction() + + self._updateColors() + + self.getPlot3DWidget().viewport.light.shininess = 32 + + def saveConfig(self, ioDevice): + """ + Saves this view state. Only isosurfaces at the moment. Does not save + the isosurface's function. + + :param qt.QIODevice ioDevice: A `qt.QIODevice`. + """ + + stream = qt.QDataStream(ioDevice) + + stream.writeString('<ScalarFieldView>') + + isoSurfaces = self.getIsosurfaces() + + nIsoSurfaces = len(isoSurfaces) + + # TODO : delegate the serialization to the serialized items + # isosurfaces + if nIsoSurfaces: + tagIn = '<IsoSurfaces nIso={0}>'.format(nIsoSurfaces) + stream.writeString(tagIn) + + for surface in isoSurfaces: + color = surface.getColor() + level = surface.getLevel() + visible = surface.isVisible() + stream << color + stream.writeDouble(level) + stream.writeBool(visible) + + stream.writeString('</IsoSurfaces>') + + stream.writeString('<Style>') + background = self.getBackgroundColor() + foreground = self.getForegroundColor() + highlight = self.getHighlightColor() + stream << background << foreground << highlight + stream.writeString('</Style>') + + stream.writeString('</ScalarFieldView>') + + def loadConfig(self, ioDevice): + """ + Loads this view state. + See ScalarFieldView.saveView to know what is supported at the moment. + + :param qt.QIODevice ioDevice: A `qt.QIODevice`. + """ + + tagStack = deque() + + tagInRegex = re.compile('<(?P<itemId>[^ /]*) *' + '(?P<args>.*)>') + + tagOutRegex = re.compile('</(?P<itemId>[^ ]*)>') + + tagRootInRegex = re.compile('<ScalarFieldView>') + + isoSurfaceArgsRegex = re.compile('nIso=(?P<nIso>[0-9]*)') + + stream = qt.QDataStream(ioDevice) + + tag = stream.readString() + tagMatch = tagRootInRegex.match(tag) + + if tagMatch is None: + # TODO : explicit error + raise ValueError('Unknown data.') + + itemId = 'ScalarFieldView' + + tagStack.append(itemId) + + while True: + + tag = stream.readString() + + tagMatch = tagOutRegex.match(tag) + if tagMatch: + closeId = tagMatch.groupdict()['itemId'] + if closeId != itemId: + # TODO : explicit error + raise ValueError('Unexpected closing tag {0} ' + '(expected {1})' + ''.format(closeId, itemId)) + + if itemId == 'ScalarFieldView': + # reached end + break + else: + itemId = tagStack.pop() + # fetching next tag + continue + + tagMatch = tagInRegex.match(tag) + + if tagMatch is None: + # TODO : explicit error + raise ValueError('Unknown data.') + + tagStack.append(itemId) + + matchDict = tagMatch.groupdict() + + itemId = matchDict['itemId'] + + # TODO : delegate the deserialization to the serialized items + if itemId == 'IsoSurfaces': + argsMatch = isoSurfaceArgsRegex.match(matchDict['args']) + if not argsMatch: + # TODO : explicit error + raise ValueError('Failed to parse args "{0}".' + ''.format(matchDict['args'])) + argsDict = argsMatch.groupdict() + nIso = int(argsDict['nIso']) + if nIso: + for surface in self.getIsosurfaces(): + self.removeIsosurface(surface) + for isoIdx in range(nIso): + color = qt.QColor() + stream >> color + level = stream.readDouble() + visible = stream.readBool() + surface = self.addIsosurface(level, color=color) + surface.setVisible(visible) + elif itemId == 'Style': + background = qt.QColor() + foreground = qt.QColor() + highlight = qt.QColor() + stream >> background >> foreground >> highlight + self.setBackgroundColor(background) + self.setForegroundColor(foreground) + self.setHighlightColor(highlight) + else: + raise ValueError('Unknown entry tag {0}.' + ''.format(itemId)) + + def _initPanPlaneAction(self): + """Creates and init the pan plane action""" + self._panPlaneAction = qt.QAction(self) + self._panPlaneAction.setIcon(icons.getQIcon('3d-plane-pan')) + self._panPlaneAction.setText('Pan plane') + self._panPlaneAction.setCheckable(True) + 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) + self.getPlot3DWidget().sigInteractiveModeChanged.connect( + self._interactiveModeChanged) + + toolbar = self.findChild(InteractiveModeToolBar) + if toolbar is not None: + toolbar.addAction(self._panPlaneAction) + + def _planeActionTriggered(self, checked=False): + self._panPlaneAction.setChecked(True) + self.setInteractiveMode('plane') + + def _interactiveModeChanged(self): + self._panPlaneAction.setChecked(self.getInteractiveMode() == 'plane') + self._updateColors() + + def _planeVisibilityChanged(self, visible): + """Handle visibility events from the plane""" + if visible != self._panPlaneAction.isEnabled(): + self._panPlaneAction.setEnabled(visible) + if visible: + self.setInteractiveMode('plane') + elif self._panPlaneAction.isChecked(): + self.setInteractiveMode('rotate') + + def setInteractiveMode(self, mode): + """Choose the current interaction. + + :param str mode: Either rotate, pan or plane + """ + if mode == self.getInteractiveMode(): + return + + sceneScale = self.getPlot3DWidget().viewport.scene.transforms[0] + if mode == 'plane': + 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): + return 'plane' + else: + return self.getPlot3DWidget().getInteractiveMode() + + # Handle scalar field + + def setData(self, data, copy=True): + """Set the 3D scalar data set to use for building the iso-surface. + + Dataset order is zyx (i.e., first dimension is z). + + :param data: scalar field from which to extract the iso-surface + :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.setSelectedRegion(zrange=None, yrange=None, xrange_=None) + self._group.shape = None + self.centerScene() + + else: + data = numpy.array(data, copy=copy, dtype=numpy.float32, order='C') + assert data.ndim == 3 + assert min(data.shape) >= 2 + + wasData = self._data is not None + previousSelectedRegion = self.getSelectedRegion() + + 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 + + if previousSelectedRegion is not None: + # Update selected region to ensure it is clipped to array range + self.setSelectedRegion(*previousSelectedRegion.getArrayRange()) + + self._group.shape = self._data.shape + + if not wasData: + self.centerScene() # Reset viewpoint the first time only + + # Update iso-surfaces + for isosurface in self.getIsosurfaces(): + isosurface._setData(self._data, copy=False) + + self.sigDataChanged.emit() + + def getData(self, copy=True): + """Get the 3D scalar data currently used to build the iso-surface. + + :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 + + # Transformations + + def setOuterScale(self, sx=1., sy=1., sz=1.): + """Set the scale to apply to the whole scene including the axes. + + This is useful when axis lengths in data space are really different. + + :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 + """ + self._outerScale.setScale(sx, sy, sz) + self.centerScene() + + def getOuterScale(self): + """Returns the scales provided by :meth:`setOuterScale`. + + :rtype: numpy.ndarray + """ + return self._outerScale.scale + + def setScale(self, sx=1., sy=1., sz=1.): + """Set the scale of the 3D scalar field (i.e., size of a voxel). + + :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._dataScale.scale = scale + self.sigTransformChanged.emit() + self.centerScene() # Reset viewpoint + + def getScale(self): + """Returns the scales provided by :meth:`setScale` as a numpy.ndarray. + """ + return self._dataScale.scale + + def setTranslation(self, x=0., y=0., z=0.): + """Set the translation of the origin of the data array in data coordinates. + + :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._dataTranslate.translation = translation + self.sigTransformChanged.emit() + self.centerScene() # Reset viewpoint + + def getTranslation(self): + """Returns the offset set by :meth:`setTranslation` as a numpy.ndarray. + """ + 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): + """Returns axes labels, grid and bounding box visibility. + + :rtype: bool + """ + return self._bbox.boxVisible + + def setBoundingBoxVisible(self, visible): + """Set axes labels, grid and bounding box visibility. + + :param bool visible: True to show axes, False to hide + """ + visible = bool(visible) + self._bbox.boxVisible = visible + + 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. + """ + if xlabel is not None: + self._bbox.xlabel = xlabel + + if ylabel is not None: + self._bbox.ylabel = ylabel + + if zlabel is not None: + self._bbox.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 + + >>> widget = ScalarFieldView() + >>> widget.setAxesLabels(xlabel='X') + + You can get the labels either as a 3-tuple: + + >>> xlabel, ylabel, zlabel = widget.getAxesLabels() + + Or as an object with methods getXLabel, getYLabel and getZLabel: + + >>> labels = widget.getAxesLabels() + >>> labels.getXLabel() + ... 'X' + + :return: object describing the labels + """ + return self._Labels((self._bbox.xlabel, + self._bbox.ylabel, + self._bbox.zlabel)) + + # Colors + + def _updateColors(self): + """Update item depending on foreground/highlight color""" + self._bbox.tickColor = self._foregroundColor + self._selectionBox.strokeColor = self._foregroundColor + if self.getInteractiveMode() == 'plane': + self._cutPlane.setStrokeColor(self._highlightColor) + self._bbox.color = self._foregroundColor + else: + self._cutPlane.setStrokeColor(self._foregroundColor) + self._bbox.color = self._highlightColor + + def getForegroundColor(self): + """Return color used for text and bounding box (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 + self._updateColors() + + def getHighlightColor(self): + """Return color used for highlighted item bounding box (QColor)""" + return qt.QColor.fromRgbF(*self._highlightColor) + + def setHighlightColor(self, color): + """Set hightlighted 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 + self._updateColors() + + # Cut Plane + + def getCutPlanes(self): + """Return an iterable of all cut planes of the view. + + This includes hidden cut planes. + + For now, there is always one cut plane. + """ + return (self._cutPlane,) + + # Selection + + def setSelectedRegion(self, zrange=None, yrange=None, xrange_=None): + """Set the 3D selected region aligned with the axes. + + Provided range are array indices range. + The provided ranges are clipped to the data. + If a range is None, the range of the array on this dimension is used. + + :param zrange: (zmin, zmax) range of the selection + :param yrange: (ymin, ymax) range of the selection + :param xrange_: (xmin, xmax) range of the selection + """ + # No range given: unset selection + if zrange is None and yrange is None and xrange_ is None: + selectedRange = None + + else: + # Handle default ranges + if self._data is not None: + if zrange is None: + zrange = 0, self._data.shape[0] + if yrange is None: + yrange = 0, self._data.shape[1] + if xrange_ is None: + xrange_ = 0, self._data.shape[2] + + elif None in (xrange_, yrange, zrange): + # One of the range is None and no data available + raise RuntimeError( + 'Data is not set, cannot get default range from it.') + + # Clip selected region to data shape and make sure min <= max + selectedRange = numpy.array(( + (max(0, min(*zrange)), + min(self._data.shape[0], max(*zrange))), + (max(0, min(*yrange)), + min(self._data.shape[1], max(*yrange))), + (max(0, min(*xrange_)), + min(self._data.shape[2], max(*xrange_))), + ), dtype=numpy.int64) + + # numpy.equal supports None + if not numpy.all(numpy.equal(selectedRange, self._selectedRange)): + self._selectedRange = selectedRange + + # Update scene accordingly + if self._selectedRange is None: + self._selectionBox.visible = False + else: + self._selectionBox.visible = True + scales = self._selectedRange[:, 1] - self._selectedRange[:, 0] + self._selectionBox.size = scales[::-1] + self._selectionBox.transforms = [ + transform.Translate(*self._selectedRange[::-1, 0])] + + self.sigSelectedRegionChanged.emit(self.getSelectedRegion()) + + def getSelectedRegion(self): + """Returns the currently selected region or None.""" + if self._selectedRange is None: + return None + else: + dataBBox = self._group.transforms.transformBounds( + self._selectedRange[::-1].T).T + return SelectedRegion(self._selectedRange, dataBBox, + translation=self.getTranslation(), + scale=self.getScale()) + + # Handle iso-surfaces + + 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 iso-surface to the view. + + :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 describing this 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.sigLevelChanged.connect(self._updateIsosurfaces) + + self._isosurfaces.append(isosurface) + + self._updateIsosurfaces() + + self.sigIsosurfaceAdded.emit(isosurface) + return isosurface + + def getIsosurfaces(self): + """Return an iterable of all iso-surfaces of the view""" + return tuple(self._isosurfaces) + + def removeIsosurface(self, isosurface): + """Remove an iso-surface from the view. + + :param 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.sigLevelChanged.disconnect(self._updateIsosurfaces) + self._isosurfaces.remove(isosurface) + self._updateIsosurfaces() + self.sigIsosurfaceRemoved.emit(isosurface) + + def clearIsosurfaces(self): + """Remove all iso-surfaces from the view.""" + for isosurface in self.getIsosurfaces(): + self.removeIsosurface(isosurface) + + def _updateIsosurfaces(self, level=None): + """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 iso: - iso.getLevel()) + self._isogroup.children = [iso._get3DPrimitive() for iso in sortedIso] diff --git a/src/silx/gui/plot3d/SceneWidget.py b/src/silx/gui/plot3d/SceneWidget.py new file mode 100644 index 0000000..883f5e7 --- /dev/null +++ b/src/silx/gui/plot3d/SceneWidget.py @@ -0,0 +1,687 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2017-2019 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__ = "24/04/2018" + +import enum +import weakref + +import numpy + +from .. import qt +from ..colors import rgba + +from .Plot3DWidget import Plot3DWidget +from . import items +from .items.core import RootGroupWithAxesItem +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 item is previous: + return # Fast path, nothing to do + + 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() + 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() + + parent = self.parent() + assert isinstance(parent, SceneWidget) + + if item.root() != parent.getSceneGroup(): + self.setCurrentItem(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 = RootGroupWithAxesItem(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 + + def pickItems(self, x, y, condition=None): + """Iterator over picked items in the scene at given position. + + Each picked item yield a + :class:`~silx.gui.plot3d.items._pick.PickingResult` object + holding the picking information. + + It traverses the scene tree in a left-to-right top-down way. + + :param int x: X widget coordinate + :param int y: Y widget coordinate + :param callable condition: Optional test called for each item + checking whether to process it or not. + """ + if not self.isValid() or not self.isVisible(): + return # Empty iterator + + devicePixelRatio = self.getDevicePixelRatio() + for result in self.getSceneGroup().pickItems( + x * devicePixelRatio, y * devicePixelRatio, condition): + yield result + + # 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 addVolume(self, data, copy=True, index=None): + """Add 3D data volume of scalar or complex to :class:`SceneWidget` content. + + Dataset order is zyx (i.e., first dimension is z). + + :param data: 3D array of complex with shape at least (2, 2, 2) + :type data: numpy.ndarray[Union[numpy.complex64,numpy.float32]] + :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 3D volume item + :rtype: Union[ScalarField3D,ComplexField3D] + + """ + if data is not None: + data = numpy.array(data, copy=False) + + if numpy.iscomplexobj(data): + volume = items.ComplexField3D() + else: + volume = items.ScalarField3D() + volume.setData(data, copy=copy) + self.addItem(volume, index) + return volume + + def add3DScalarField(self, data, copy=True, index=None): + # TODO deprecate in the future + return self.addVolume(data, copy=copy, index=index) + + 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: ~silx.gui.plot3d.items.scatter.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: ~silx.gui.plot3d.items.scatter.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: ~silx.gui.plot3d.items.image.ImageData or ~silx.gui.plot3d.items.image.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().clearItems() + + # 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/src/silx/gui/plot3d/SceneWindow.py b/src/silx/gui/plot3d/SceneWindow.py new file mode 100644 index 0000000..052a4dc --- /dev/null +++ b/src/silx/gui/plot3d/SceneWindow.py @@ -0,0 +1,219 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2017-2019 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 ...gui.widgets.BoxLayoutDockWidget import BoxLayoutDockWidget + +from .actions.mode import InteractiveModeAction +from .SceneWidget import SceneWidget +from .tools import OutputToolBar, InteractiveModeToolBar, ViewpointToolBar +from .tools.GroupPropertiesWidget import GroupPropertiesWidget +from .tools.PositionInfoWidget import PositionInfoWidget + +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) + + # Add PositionInfoWidget to display picking info + self._positionInfo = PositionInfoWidget() + self._positionInfo.setSceneWidget(self._sceneWidget) + + dock = BoxLayoutDockWidget() + dock.setWindowTitle("Selection Info") + dock.setWidget(self._positionInfo) + self.addDockWidget(qt.Qt.BottomDockWidgetArea, dock) + + self._interactiveModeToolBar = InteractiveModeToolBar(parent=self) + panPlaneAction = _PanPlaneAction(self, plot3d=self._sceneWidget) + self._interactiveModeToolBar.addAction( + self._positionInfo.toggleAction()) + 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 getGroupResetWidget(self): + """Returns the :class:`GroupPropertiesWidget` of this window. + + :rtype: GroupPropertiesWidget + """ + return self._sceneGroupResetWidget + + def getParamTreeView(self): + """Returns the :class:`ParamTreeView` of this window. + + :rtype: ParamTreeView + """ + return self._paramTreeView + + def getInteractiveModeToolBar(self): + """Returns the interactive mode toolbar. + + :rtype: ~silx.gui.plot3d.tools.InteractiveModeToolBar + """ + return self._interactiveModeToolBar + + def getViewpointToolBar(self): + """Returns the viewpoint toolbar. + + :rtype: ~silx.gui.plot3d.tools.ViewpointToolBar + """ + return self._viewpointToolBar + + def getOutputToolBar(self): + """Returns the output toolbar. + + :rtype: ~silx.gui.plot3d.tools.OutputToolBar + """ + return self._outputToolBar + + def getPositionInfoWidget(self): + """Returns the widget displaying selected position information. + + :rtype: ~silx.gui.plot3d.tools.PositionInfoWidget.PositionInfoWidget + """ + return self._positionInfo diff --git a/src/silx/gui/plot3d/__init__.py b/src/silx/gui/plot3d/__init__.py new file mode 100644 index 0000000..af74613 --- /dev/null +++ b/src/silx/gui/plot3d/__init__.py @@ -0,0 +1,40 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2015-2017 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +""" +This package provides widgets displaying 3D content based on OpenGL. + +It depends on PyOpenGL and PyQtx.QtOpenGL or PyQt>=5.4. +""" +from __future__ import absolute_import + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "18/01/2017" + + +try: + import OpenGL as _OpenGL +except ImportError: + raise ImportError('PyOpenGL is not installed') diff --git a/src/silx/gui/plot3d/_model/__init__.py b/src/silx/gui/plot3d/_model/__init__.py new file mode 100644 index 0000000..4b16e32 --- /dev/null +++ b/src/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/src/silx/gui/plot3d/_model/core.py b/src/silx/gui/plot3d/_model/core.py new file mode 100644 index 0000000..e8e0820 --- /dev/null +++ b/src/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/src/silx/gui/plot3d/_model/items.py b/src/silx/gui/plot3d/_model/items.py new file mode 100644 index 0000000..492f44b --- /dev/null +++ b/src/silx/gui/plot3d/_model/items.py @@ -0,0 +1,1759 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2017-2021 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +""" +This module provides base classes to implement models for 3D scene content +""" + +from __future__ import absolute_import, division + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "24/04/2018" + + +from collections import OrderedDict +import functools +import logging +import weakref + +import numpy + +from ...utils.image import convertArrayToQImage +from ...colors import preferredColormaps +from ... import qt, icons +from .. import items +from ..items.volume import Isosurface, CutPlane, ComplexIsosurface +from ..Plot3DWidget import Plot3DWidget + + +from .core import AngleDegreeRow, BaseRow, ColorProxyRow, ProxyRow, StaticRow + + +_logger = logging.getLogger(__name__) + + +class ItemProxyRow(ProxyRow): + """Provides a node to proxy a data accessible through functions. + + It listens on sigItemChanged to trigger the update. + + Warning: Only weak reference are kept on fget and fset. + + :param Item3D item: The item to + :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 events: + An optional event kind or list of event kinds to react upon. + :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, + item, + name='', + fget=None, + fset=None, + events=None, + toModelData=None, + fromModelData=None, + editorHint=None): + super(ItemProxyRow, self).__init__( + name=name, + fget=fget, + fset=fset, + notify=None, + toModelData=toModelData, + fromModelData=fromModelData, + editorHint=editorHint) + + if isinstance(events, (items.ItemChangedType, + items.Item3DChangedType)): + events = (events,) + self.__events = events + item.sigItemChanged.connect(self._itemChanged) + + def _itemChanged(self, event): + """Handle item changed + + :param Union[ItemChangedType,Item3DChangedType] event: + """ + if self.__events is None or event in self.__events: + self._notified() + + +class ItemColorProxyRow(ColorProxyRow, ItemProxyRow): + """Combines :class:`ColorProxyRow` and :class:`ItemProxyRow`""" + + def __init__(self, *args, **kwargs): + ItemProxyRow.__init__(self, *args, **kwargs) + + +class ItemAngleDegreeRow(AngleDegreeRow, ItemProxyRow): + """Combines :class:`AngleDegreeRow` and :class:`ItemProxyRow`""" + + def __init__(self, *args, **kwargs): + ItemProxyRow.__init__(self, *args, **kwargs) + + +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: int + """ + return self._azimuth + + def getAltitudeAngle(self): + """Returns the signed vertical angle from the horizontal plane. + + Unit: degrees. + Range: [-90, +90] + + :rtype: int + """ + return self._altitude + + def setAzimuthAngle(self, angle): + """Set the horizontal angle. + + :param int angle: Angle from -z axis in zx plane in degrees. + """ + angle = int(round(angle)) + if angle != self._azimuth: + self._azimuth = angle + self._updateLight() + self.sigAzimuthAngleChanged.emit() + + def setAltitudeAngle(self, angle): + """Set the horizontal angle. + + :param int angle: Angle from -z axis in zy plane in degrees. + """ + angle = int(round(angle)) + 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 = int(round(numpy.degrees(numpy.arctan2(x, z)))) + altitude = int(round(numpy.degrees(numpy.pi/2. - numpy.arccos(y)))) + + if azimuth != self.getAzimuthAngle(): + self.setAzimuthAngle(azimuth) + + if altitude != self.getAltitudeAngle(): + 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) + if delta == 0.: # Avoids zenith position + delta = 0.0001 + 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)) + + # Fog + fog = ProxyRow( + name='Fog', + fget=sceneWidget.getFogMode, + fset=sceneWidget.setFogMode, + notify=sceneWidget.sigStyleChanged, + toModelData=lambda mode: mode is Plot3DWidget.FogMode.LINEAR, + fromModelData=lambda mode: Plot3DWidget.FogMode.LINEAR if mode else Plot3DWidget.FogMode.NONE) + + # Settings row + children = (background, foreground, text, highlight, + axesIndicator, lightDirection, fog) + super(Settings, self).__init__(('Settings', None), children=children) + + +class Item3DRow(BaseRow): + """Represents an :class:`Item3D` with checkable visibility + + :param Item3D item: The scene item to represent. + :param str name: The optional name of the item + """ + + _EVENTS = items.ItemChangedType.VISIBLE, items.Item3DChangedType.LABEL + """Events for which to update the first column in the tree""" + + def __init__(self, item, name=None): + self.__name = None if name is None else str(name) + super(Item3DRow, self).__init__() + + 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 model update upon change""" + if event in self._EVENTS: + model = self.model() + if model is not None: + index = self.index(column=0) + 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: + if 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 role == qt.Qt.DecorationRole: + return icons.getQIcon('item-3dim') + + elif role == qt.Qt.DisplayRole: + if self.__name is None: + item = self.item() + return '' if item is None else item.getLabel() + else: + return self.__name + + 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) + + def columnCount(self): + return 2 + + +class DataItem3DBoundingBoxRow(ItemProxyRow): + """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__( + item=item, + name='Bounding box', + fget=item.isBoundingBoxVisible, + fset=item.setBoundingBoxVisible, + events=items.Item3DChangedType.BOUNDING_BOX_VISIBLE) + + +class MatrixProxyRow(ItemProxyRow): + """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__( + item=item, + name='', + fget=self._getMatrixRow, + fset=self._setMatrixRow, + events=items.Item3DChangedType.TRANSFORM) + + 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 = ItemProxyRow( + item=item, + name='Translation', + fget=item.getTranslation, + fset=self._setTranslation, + events=items.Item3DChangedType.TRANSFORM, + 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=( + ItemProxyRow(item=item, + name='X axis', + fget=item.getRotationCenter, + fset=self._xSetCenter, + events=items.Item3DChangedType.TRANSFORM, + toModelData=functools.partial( + self._centerToModelData, index=0), + editorHint=self._ROTATION_CENTER_OPTIONS), + ItemProxyRow(item=item, + name='Y axis', + fget=item.getRotationCenter, + fset=self._ySetCenter, + events=items.Item3DChangedType.TRANSFORM, + toModelData=functools.partial( + self._centerToModelData, index=1), + editorHint=self._ROTATION_CENTER_OPTIONS), + ItemProxyRow(item=item, + name='Z axis', + fget=item.getRotationCenter, + fset=self._zSetCenter, + events=items.Item3DChangedType.TRANSFORM, + toModelData=functools.partial( + self._centerToModelData, index=2), + editorHint=self._ROTATION_CENTER_OPTIONS), + )) + + rotate = StaticRow( + ('Rotation', None), + children=( + ItemAngleDegreeRow( + item=item, + name='Angle', + fget=item.getRotation, + fset=self._setAngle, + events=items.Item3DChangedType.TRANSFORM, + toModelData=lambda data: data[0]), + ItemProxyRow( + item=item, + name='Axis', + fget=item.getRotation, + fset=self._setAxis, + events=items.Item3DChangedType.TRANSFORM, + toModelData=lambda data: qt.QVector3D(*data[1])), + rotateCenter + )) + self.addRow(rotate) + + scale = ItemProxyRow( + item=item, + name='Scale', + fget=item.getScale, + fset=self._setScale, + events=items.Item3DChangedType.TRANSFORM, + 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, str): + return value.title() + elif value == 0.: + return 'Origin' + else: + return str(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: + sx, sy, sz = scale.x(), scale.y(), scale.z() + if sx == 0. or sy == 0. or sz == 0.: + _logger.warning('Cannot set scale to 0: ignored') + else: + 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 isinstance(row, Item3DRow) and row.item() is item: + self.removeRow(row) + break # Got it + else: + raise RuntimeError("Model does not correspond to scene content") + + +class InterpolationRow(ItemProxyRow): + """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__( + item=item, + name='Interpolation', + fget=item.getInterpolation, + fset=item.setInterpolation, + events=items.Item3DChangedType.INTERPOLATION, + 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._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) + """ + item = self.item() + if item is not None and self._colormap is not None: + return self._colormap.getColormapRange(item) + else: + return 1, 100 # Fallback + + 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._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 _ColormapGammaRow(_ColormapBaseProxyRow): + """ProxyRow for colormap gamma normalization parameter + + :param ColormapMixIn item: The item to handle + :param str name: Name of the raw + """ + + def __init__(self, item): + _ColormapBaseProxyRow.__init__( + self, + item, + name="Gamma", + fget=self._getGammaNormalizationParameter, + fset=self._setGammaNormalizationParameter) + + self.setToolTip('Colormap gamma correction parameter:\n' + 'Only meaningful for gamma normalization.') + + def _getGammaNormalizationParameter(self): + """Proxy for :meth:`Colormap.getGammaNormalizationParameter`""" + if self._colormap is not None: + return self._colormap.getGammaNormalizationParameter() + else: + return 0.0 + + def _setGammaNormalizationParameter(self, gamma): + """Proxy for :meth:`Colormap.setGammaNormalizationParameter`""" + if self._colormap is not None: + return self._colormap.setGammaNormalizationParameter(gamma) + + def _getNormalization(self): + """Proxy for :meth:`Colormap.getNormalization`""" + if self._colormap is not None: + return self._colormap.getNormalization() + else: + return '' + + def flags(self, column): + if column in (0, 1): + if self._getNormalization() == 'gamma': + flags = qt.Qt.ItemIsEditable | qt.Qt.ItemIsEnabled + else: + flags = qt.Qt.NoItemFlags # Disabled if not gamma correction + return flags + + else: # Never event + return super(_ColormapGammaRow, self).flags(column) + + +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(_ColormapGammaRow(item)) + + modes = [mode.title() for mode in self._colormap.AUTOSCALE_MODES] + self.addRow(ProxyRow( + name='Autoscale Mode', + fget=self._getAutoscaleMode, + fset=self._setAutoscaleMode, + notify=self._sigColormapChanged, + editorHint=modes)) + + self.addRow(_ColormapBoundRow(item, name='Min.', index=0)) + self.addRow(_ColormapBoundRow(item, name='Max.', index=1)) + + self._sigColormapChanged.connect(self._updateColormapImage) + + def getColormapImage(self): + """Returns image representing the colormap or None + + :rtype: Union[QImage,None] + """ + if self._colormapImage is None and self._colormap is not 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 + + def _get(self): + """Getter for ProxyRow subclass""" + return None + + def _getName(self): + """Proxy for :meth:`Colormap.getName`""" + if self._colormap is not None and self._colormap.getName() 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 _getAutoscaleMode(self): + """Proxy for :meth:`Colormap.getAutoscaleMode`""" + if self._colormap is not None: + return self._colormap.getAutoscaleMode().title() + else: + return '' + + def _setAutoscaleMode(self, mode): + """Proxy for :meth:`Colormap.setAutoscaleMode`""" + if self._colormap is not None: + return self._colormap.setAutoscaleMode(mode.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: + return self.getColormapImage() + else: + return super(ColormapRow, self).data(column, role) + + +class SymbolRow(ItemProxyRow): + """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__( + item=item, + name='Marker', + fget=item.getSymbolName, + fset=item.setSymbol, + events=items.ItemChangedType.SYMBOL, + editorHint=names) + + +class SymbolSizeRow(ItemProxyRow): + """Represents :class:`SymbolMixIn` symbol size property. + + :param Item3D item: Scene item with symbol size property + """ + + def __init__(self, item): + super(SymbolSizeRow, self).__init__( + item=item, + name='Marker size', + fget=item.getSymbolSize, + fset=item.setSymbolSize, + events=items.ItemChangedType.SYMBOL_SIZE, + editorHint=(1, 20)) # TODO link with OpenGL max point size + + +class PlaneEquationRow(ItemProxyRow): + """Represents :class:`PlaneMixIn` as plane equation. + + :param Item3D item: Scene item with plane equation property + """ + + def __init__(self, item): + super(PlaneEquationRow, self).__init__( + item=item, + name='Equation', + fget=item.getParameters, + fset=item.setParameters, + events=items.ItemChangedType.POSITION, + 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(PlaneEquationRow, self).data(column, role) + + +class PlaneRow(ItemProxyRow): + """Represents :class:`PlaneMixIn` property. + + :param Item3D item: Scene item with plane equation property + """ + + _PLANES = OrderedDict((('Plane 0', (1., 0., 0.)), + ('Plane 1', (0., 1., 0.)), + ('Plane 2', (0., 0., 1.)), + ('-', None))) + """Mapping of plane names to normals""" + + _PLANE_ICONS = {'Plane 0': '3d-plane-normal-x', + 'Plane 1': '3d-plane-normal-y', + 'Plane 2': '3d-plane-normal-z', + '-': '3d-plane'} + """Mapping of plane names to normals""" + + def __init__(self, item): + super(PlaneRow, self).__init__( + item=item, + name='Plane', + fget=self.__getPlaneName, + fset=self.__setPlaneName, + events=items.ItemChangedType.POSITION, + editorHint=tuple(self._PLANES.keys())) + self._item = weakref.ref(item) + self._lastName = None + + self.addRow(PlaneEquationRow(item)) + + def _notified(self, *args, **kwargs): + """Handle notification of modification + + Here only send if plane name actually changed + """ + if self._lastName != self.__getPlaneName(): + super(PlaneRow, self)._notified() + + def __getPlaneName(self): + """Returns name of plane // to axes or '-' + + :rtype: str + """ + item = self._item() + planeNormal = item.getNormal() if item is not None else None + + for name, normal in self._PLANES.items(): + if numpy.array_equal(planeNormal, normal): + return name + return '-' + + def __setPlaneName(self, data): + """Set plane normal according to given plane name + + :param str data: Selected plane name + """ + item = self._item() + if item is not None: + for name, normal in self._PLANES.items(): + if data == name and normal is not None: + item.setNormal(normal) + + def data(self, column, role): + if column == 1 and role == qt.Qt.DecorationRole: + return icons.getQIcon(self._PLANE_ICONS[self.__getPlaneName()]) + data = super(PlaneRow, self).data(column, role) + if column == 1 and role == qt.Qt.DisplayRole: + self._lastName = data + return data + + +class ComplexModeRow(ItemProxyRow): + """Represents :class:`items.ComplexMixIn` symbol property. + + :param Item3D item: Scene item with symbol property + """ + + def __init__(self, item, name='Mode'): + names = [m.value.replace('_', ' ').title() + for m in item.supportedComplexModes()] + super(ComplexModeRow, self).__init__( + item=item, + name=name, + fget=item.getComplexMode, + fset=item.setComplexMode, + events=items.ItemChangedType.COMPLEX_MODE, + toModelData=lambda data: data.value.replace('_', ' ').title(), + fromModelData=lambda data: data.lower().replace(' ', '_'), + editorHint=names) + + +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: + volume = isosurface.parent() + if volume is not None: + volume.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""" + + _EVENTS = items.ItemChangedType.VISIBLE, items.ItemChangedType.COLOR + """Events for which to update the first column in the tree""" + + 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(ItemProxyRow( + item=item, + name='Level', + fget=self._getValueForLevelSlider, + fset=self._setLevelFromSliderValue, + events=items.Item3DChangedType.ISO_LEVEL, + editorHint=self._LEVEL_SLIDER_RANGE)) + + self.addRow(ItemColorProxyRow( + item=item, + name='Color', + fget=self._rgbColor, + fset=self._setRgbColor, + events=items.ItemChangedType.COLOR)) + + self.addRow(ItemProxyRow( + item=item, + name='Opacity', + fget=self._opacity, + fset=self._setOpacity, + events=items.ItemChangedType.COLOR, + 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: + volume = item.parent() + if volume is not None: + dataRange = volume.getDataRange() + if dataRange is not None: + dataMin, dataMax = dataRange[0], dataRange[-1] + if dataMax != dataMin: + offset = (item.getLevel() - dataMin) / (dataMax - dataMin) + else: + offset = 0. + + 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: + volume = item.parent() + if volume is not None: + dataRange = volume.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 ComplexIsosurfaceRow(IsosurfaceRow): + """Represents an :class:`ComplexIsosurface` item. + + :param ComplexIsosurface item: + """ + + _EVENTS = (items.ItemChangedType.VISIBLE, + items.ItemChangedType.COLOR, + items.ItemChangedType.COMPLEX_MODE) + """Events for which to update the first column in the tree""" + + def __init__(self, item): + super(ComplexIsosurfaceRow, self).__init__(item) + + self.addRow(ComplexModeRow(item, "Color Complex Mode"), index=1) + for row in self.children(): + if isinstance(row, ColorProxyRow): + self._colorRow = row + break + else: + raise RuntimeError("Cannot retrieve Color tree row") + self._colormapRow = ColormapRow(item) + + self.__updateRowsForItem(item) + item.sigItemChanged.connect(self._itemChanged) + + def _itemChanged(self, event): + """Update enabled/disabled rows""" + if event == items.ItemChangedType.COMPLEX_MODE: + item = self.sender() + self.__updateRowsForItem(item) + + def __updateRowsForItem(self, item): + """Update rows for item + + :param item: + """ + if not isinstance(item, ComplexIsosurface): + return + + if item.getComplexMode() == items.ComplexMixIn.ComplexMode.NONE: + removed = self._colormapRow + added = self._colorRow + else: + removed = self._colorRow + added = self._colormapRow + + # Remove unwanted rows + if removed in self.children(): + self.removeRow(removed) + + # Add required rows + if added not in self.children(): + self.addRow(added, index=2) + + def data(self, column, role): + if column == 0 and role == qt.Qt.DecorationRole: + item = self.item() + if (item is not None and + item.getComplexMode() != items.ComplexMixIn.ComplexMode.NONE): + return self._colormapRow.getColormapImage() + + return super(ComplexIsosurfaceRow, self).data(column, role) + + +class AddIsosurfaceRow(BaseRow): + """Class for Isosurface create button + + :param Union[ScalarField3D,ComplexField3D] volume: + The volume item to attach the button to. + """ + + def __init__(self, volume): + super(AddIsosurfaceRow, self).__init__() + self._volume = weakref.ref(volume) + + 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 volume(self): + """Returns the controlled volume item + + :rtype: Union[ScalarField3D,ComplexField3D] + """ + return self._volume() + + 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""" + volume = self.volume() + if volume is not None: + dataRange = volume.getDataRange() + if dataRange is None: + dataRange = 0., 1. + + volume.addIsosurface( + numpy.mean((dataRange[0], dataRange[-1])), + '#0000FF') + + +class VolumeIsoSurfacesRow(StaticRow): + """Represents :class:`ScalarFieldView`'s isosurfaces + + :param Union[ScalarField3D,ComplexField3D] volume: + Volume item to control + """ + + def __init__(self, volume): + super(VolumeIsoSurfacesRow, self).__init__( + ('Isosurfaces', None)) + self._volume = weakref.ref(volume) + + volume.sigIsosurfaceAdded.connect(self._isosurfaceAdded) + volume.sigIsosurfaceRemoved.connect(self._isosurfaceRemoved) + + if isinstance(volume, items.ComplexMixIn): + self.addRow(ComplexModeRow(volume, "Complex Mode")) + + for item in volume.getIsosurfaces(): + self.addRow(nodeFromItem(item)) + + self.addRow(AddIsosurfaceRow(volume)) + + def volume(self): + """Returns the controlled volume item + + :rtype: Union[ScalarField3D,ComplexField3D] + """ + return self._volume() + + def _isosurfaceAdded(self, item): + """Handle isosurface addition + + :param Isosurface item: added isosurface + """ + volume = self.volume() + if volume is None: + return + + row = volume.getIsosurfaces().index(item) + if isinstance(volume, items.ComplexMixIn): + row += 1 # Offset for the ComplexModeRow + self.addRow(nodeFromItem(item), row) + + def _isosurfaceRemoved(self, item): + """Handle isosurface removal + + :param Isosurface item: removed isosurface + """ + volume = self.volume() + if volume is None: + return + + # Find item + for row in self.children(): + if isinstance(row, IsosurfaceRow) and 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, ItemProxyRow): + """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 + ItemProxyRow.__init__(self, + item=item, + name='Line width', + fget=item.getLineWidth, + fset=item.setLineWidth, + events=items.ItemChangedType.LINE_WIDTH, + 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(ItemProxyRow( + item=item, + name='Mode', + fget=item.getVisualization, + fset=item.setVisualization, + events=items.ItemChangedType.VISUALIZATION_MODE, + editorHint=[m.value.title() for m in item.supportedVisualizations()], + toModelData=lambda data: data.value.title(), + fromModelData=lambda data: data.lower())) + + node.addRow(ItemProxyRow( + item=item, + name='Height map', + fget=item.isHeightMap, + fset=item.setHeightMap, + events=items.Item3DChangedType.HEIGHT_MAP)) + + node.addRow(ColormapRow(item)) + + node.addRow(Scatter2DSymbolRow(item)) + node.addRow(Scatter2DSymbolSizeRow(item)) + + node.addRow(Scatter2DLineWidth(item)) + + +def initVolumeNode(node, item): + """Specific node init for volume items + + :param Item3DRow node: The model node to setup + :param Union[ScalarField3D,ComplexField3D] item: + The volume item represented by the node + """ + node.addRow(nodeFromItem(item.getCutPlanes()[0])) # Add cut plane + node.addRow(VolumeIsoSurfacesRow(item)) + + +def initVolumeCutPlaneNode(node, item): + """Specific node init for volume CutPlane + + :param Item3DRow node: The model node to setup + :param CutPlane item: The CutPlane the node is representing + """ + if isinstance(item, items.ComplexMixIn): + node.addRow(ComplexModeRow(item)) + + node.addRow(PlaneRow(item)) + + node.addRow(ColormapRow(item)) + + node.addRow(ItemProxyRow( + item=item, + name='Show <=Min', + fget=item.getDisplayValuesBelowMin, + fset=item.setDisplayValuesBelowMin, + events=items.ItemChangedType.ALPHA)) + + node.addRow(InterpolationRow(item)) + + +NODE_SPECIFIC_INIT = [ # class, init(node, item) + (items.Scatter2D, initScatter2DNode), + (items.ScalarField3D, initVolumeNode), + (CutPlane, initVolumeCutPlaneNode), +] +"""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, ComplexIsosurface): + return ComplexIsosurfaceRow(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/src/silx/gui/plot3d/_model/model.py b/src/silx/gui/plot3d/_model/model.py new file mode 100644 index 0000000..186838f --- /dev/null +++ b/src/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/src/silx/gui/plot3d/actions/Plot3DAction.py b/src/silx/gui/plot3d/actions/Plot3DAction.py new file mode 100644 index 0000000..94b9572 --- /dev/null +++ b/src/silx/gui/plot3d/actions/Plot3DAction.py @@ -0,0 +1,71 @@ +# coding: utf-8 +# /*########################################################################## +# +# 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 +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""Base class for QAction attached to a Plot3DWidget.""" + +from __future__ import absolute_import, division + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "06/09/2017" + + +import logging +import weakref + +from silx.gui import qt + + +_logger = logging.getLogger(__name__) + + +class Plot3DAction(qt.QAction): + """QAction associated to a Plot3DWidget + + :param parent: See :class:`QAction` + :param ~silx.gui.plot3d.Plot3DWidget.Plot3DWidget plot3d: + Plot3DWidget the action is associated with + """ + + def __init__(self, parent, plot3d=None): + super(Plot3DAction, self).__init__(parent) + self._plot3d = None + self.setPlot3DWidget(plot3d) + + def setPlot3DWidget(self, widget): + """Set the Plot3DWidget this action is associated with + + :param ~silx.gui.plot3d.Plot3DWidget.Plot3DWidget widget: + The Plot3DWidget to use + """ + self._plot3d = None if widget is None else weakref.ref(widget) + + def getPlot3DWidget(self): + """Return the Plot3DWidget associated to this action. + + If no widget is associated, it returns None. + + :rtype: QWidget + """ + return None if self._plot3d is None else self._plot3d() diff --git a/src/silx/gui/plot3d/actions/__init__.py b/src/silx/gui/plot3d/actions/__init__.py new file mode 100644 index 0000000..26243cf --- /dev/null +++ b/src/silx/gui/plot3d/actions/__init__.py @@ -0,0 +1,34 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2017 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""This module provides QAction that can be attached to a plot3DWidget.""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "06/09/2017" + +from .Plot3DAction import Plot3DAction # noqa +from . import viewpoint # noqa +from . import io # noqa +from . import mode # noqa diff --git a/src/silx/gui/plot3d/actions/io.py b/src/silx/gui/plot3d/actions/io.py new file mode 100644 index 0000000..25f4ade --- /dev/null +++ b/src/silx/gui/plot3d/actions/io.py @@ -0,0 +1,337 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2016-2021 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""This module provides Plot3DAction related to input/output. + +It provides QAction to copy, save (snapshot and video), print a Plot3DWidget. +""" + +from __future__ import absolute_import, division + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "06/09/2017" + + +import logging +import os + +import numpy + +from silx.gui import qt, printer +from silx.gui.icons import getQIcon +from .Plot3DAction import Plot3DAction +from ..utils import mng +from ...utils.image import convertQImageToArray + + +_logger = logging.getLogger(__name__) + + +class CopyAction(Plot3DAction): + """QAction to provide copy of 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(CopyAction, self).__init__(parent, plot3d) + + self.setIcon(getQIcon('edit-copy')) + self.setText('Copy') + self.setToolTip('Copy a snapshot of the 3D scene to the clipboard') + self.setCheckable(False) + self.setShortcut(qt.QKeySequence.Copy) + self.setShortcutContext(qt.Qt.WidgetShortcut) + self.triggered[bool].connect(self._triggered) + + def _triggered(self, checked=False): + plot3d = self.getPlot3DWidget() + if plot3d is None: + _logger.error('Cannot copy widget, no associated Plot3DWidget') + else: + image = plot3d.grabGL() + qt.QApplication.clipboard().setImage(image) + + +class SaveAction(Plot3DAction): + """QAction to provide save snapshot of a Plot3DWidget + + :param parent: See :class:`QAction` + :param ~silx.gui.plot3d.Plot3DWidget.Plot3DWidget plot3d: + Plot3DWidget the action is associated with + """ + + def __init__(self, parent, plot3d=None): + super(SaveAction, self).__init__(parent, plot3d) + + self.setIcon(getQIcon('document-save')) + self.setText('Save...') + self.setToolTip('Save a snapshot of the 3D scene') + self.setCheckable(False) + self.setShortcut(qt.QKeySequence.Save) + self.setShortcutContext(qt.Qt.WidgetShortcut) + self.triggered[bool].connect(self._triggered) + + def _triggered(self, checked=False): + plot3d = self.getPlot3DWidget() + if plot3d is None: + _logger.error('Cannot save widget, no associated Plot3DWidget') + else: + dialog = qt.QFileDialog(self.parent()) + dialog.setWindowTitle('Save snapshot as') + dialog.setModal(True) + dialog.setNameFilters(('Plot3D Snapshot PNG (*.png)', + 'Plot3D Snapshot JPEG (*.jpg)')) + + dialog.setFileMode(qt.QFileDialog.AnyFile) + dialog.setAcceptMode(qt.QFileDialog.AcceptSave) + + if not dialog.exec(): + return + + nameFilter = dialog.selectedNameFilter() + filename = dialog.selectedFiles()[0] + dialog.close() + + # Forces the filename extension to match the chosen filter + extension = nameFilter.split()[-1][2:-1] + if (len(filename) <= len(extension) or + filename[-len(extension):].lower() != extension.lower()): + filename += extension + + image = plot3d.grabGL() + if not image.save(filename): + _logger.error('Failed to save image as %s', filename) + qt.QMessageBox.critical( + self.parent(), + 'Save snapshot as', + 'Failed to save snapshot') + + +class PrintAction(Plot3DAction): + """QAction to provide printing of a Plot3DWidget + + :param parent: See :class:`QAction` + :param ~silx.gui.plot3d.Plot3DWidget.Plot3DWidget plot3d: + Plot3DWidget the action is associated with + """ + + def __init__(self, parent, plot3d=None): + super(PrintAction, self).__init__(parent, plot3d) + + self.setIcon(getQIcon('document-print')) + self.setText('Print...') + self.setToolTip('Print a snapshot of the 3D scene') + self.setCheckable(False) + self.setShortcut(qt.QKeySequence.Print) + self.setShortcutContext(qt.Qt.WidgetShortcut) + self.triggered[bool].connect(self._triggered) + + def getPrinter(self): + """Return the QPrinter instance used for printing. + + :rtype: QPrinter + """ + return printer.getDefaultPrinter() + + def _triggered(self, checked=False): + plot3d = self.getPlot3DWidget() + if plot3d is None: + _logger.error('Cannot print widget, no associated Plot3DWidget') + else: + printer = self.getPrinter() + dialog = qt.QPrintDialog(printer, plot3d) + dialog.setWindowTitle('Print Plot3D snapshot') + if not dialog.exec(): + return + + image = plot3d.grabGL() + + # Draw pixmap with painter + painter = qt.QPainter() + if not painter.begin(printer): + return + + pageRect = printer.pageRect(qt.QPrinter.DevicePixel) + if (pageRect.width() < image.width() or + pageRect.height() < image.height()): + # Downscale to page + xScale = pageRect.width() / image.width() + yScale = pageRect.height() / image.height() + scale = min(xScale, yScale) + else: + scale = 1. + + rect = qt.QRectF(0, + 0, + scale * image.width(), + scale * image.height()) + painter.drawImage(rect, image) + painter.end() + + +class VideoAction(Plot3DAction): + """This action triggers the recording of a video of the scene. + + The scene is rotated 360 degrees around a vertical axis. + + :param parent: Action parent see :class:`QAction`. + :param ~silx.gui.plot3d.Plot3DWidget.Plot3DWidget plot3d: + Plot3DWidget the action is associated with + """ + + PNG_SERIE_FILTER = 'Serie of PNG files (*.png)' + MNG_FILTER = 'Multiple-image Network Graphics file (*.mng)' + + def __init__(self, parent, plot3d=None): + super(VideoAction, self).__init__(parent, plot3d) + self.setText('Record video..') + self.setIcon(getQIcon('camera')) + self.setToolTip( + 'Record a video of a 360 degrees rotation of the 3D scene.') + self.setCheckable(False) + self.triggered[bool].connect(self._triggered) + + def _triggered(self, checked=False): + """Action triggered callback""" + plot3d = self.getPlot3DWidget() + if plot3d is None: + _logger.warning( + 'Ignoring action triggered without Plot3DWidget set') + return + + dialog = qt.QFileDialog(parent=plot3d) + dialog.setWindowTitle('Save video as...') + dialog.setModal(True) + dialog.setNameFilters([self.PNG_SERIE_FILTER, + self.MNG_FILTER]) + dialog.setFileMode(dialog.AnyFile) + dialog.setAcceptMode(dialog.AcceptSave) + + if not dialog.exec(): + return + + nameFilter = dialog.selectedNameFilter() + filename = dialog.selectedFiles()[0] + + # Forces the filename extension to match the chosen filter + extension = nameFilter.split()[-1][2:-1] + if (len(filename) <= len(extension) or + filename[-len(extension):].lower() != extension.lower()): + filename += extension + + nbFrames = int(4. * 25) # 4 seconds, 25 fps + + if nameFilter == self.PNG_SERIE_FILTER: + self._saveAsPNGSerie(filename, nbFrames) + elif nameFilter == self.MNG_FILTER: + self._saveAsMNG(filename, nbFrames) + else: + _logger.error('Unsupported file filter: %s', nameFilter) + + def _saveAsPNGSerie(self, filename, nbFrames): + """Save video as serie of PNG files. + + It adds a counter to the provided filename before the extension. + + :param str filename: filename to use as template + :param int nbFrames: Number of frames to generate + """ + plot3d = self.getPlot3DWidget() + assert plot3d is not None + + # Define filename template + nbDigits = int(numpy.log10(nbFrames)) + 1 + indexFormat = '%%0%dd' % nbDigits + extensionIndex = filename.rfind('.') + filenameFormat = \ + filename[:extensionIndex] + indexFormat + filename[extensionIndex:] + + try: + for index, image in enumerate(self._video360(nbFrames)): + image.save(filenameFormat % index) + except GeneratorExit: + pass + + def _saveAsMNG(self, filename, nbFrames): + """Save video as MNG file. + + :param str filename: filename to use + :param int nbFrames: Number of frames to generate + """ + plot3d = self.getPlot3DWidget() + assert plot3d is not None + + frames = (convertQImageToArray(im) for im in self._video360(nbFrames)) + try: + with open(filename, 'wb') as file_: + for chunk in mng.convert(frames, nb_images=nbFrames): + file_.write(chunk) + except GeneratorExit: + os.remove(filename) # Saving aborted, delete file + + def _video360(self, nbFrames): + """Run the video and provides the images + + :param int nbFrames: The number of frames to generate for + :return: Iterator of QImage of the video sequence + """ + plot3d = self.getPlot3DWidget() + assert plot3d is not None + + angleStep = 360. / nbFrames + + # Create progress bar dialog + dialog = qt.QDialog(plot3d) + dialog.setWindowTitle('Record Video') + layout = qt.QVBoxLayout(dialog) + progress = qt.QProgressBar() + progress.setRange(0, nbFrames) + layout.addWidget(progress) + + btnBox = qt.QDialogButtonBox(qt.QDialogButtonBox.Abort) + btnBox.rejected.connect(dialog.reject) + layout.addWidget(btnBox) + + dialog.setModal(True) + dialog.show() + + qapp = qt.QApplication.instance() + + for frame in range(nbFrames): + progress.setValue(frame) + image = plot3d.grabGL() + yield image + plot3d.viewport.orbitCamera('left', angleStep) + qapp.processEvents() + if not dialog.isVisible(): + break # It as been rejected by the abort button + else: + dialog.accept() + + if dialog.result() == qt.QDialog.Rejected: + raise GeneratorExit('Aborted') diff --git a/src/silx/gui/plot3d/actions/mode.py b/src/silx/gui/plot3d/actions/mode.py new file mode 100644 index 0000000..b9cd7c8 --- /dev/null +++ b/src/silx/gui/plot3d/actions/mode.py @@ -0,0 +1,178 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2017-2021 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""This module provides Plot3DAction related to interaction modes. + +It provides QAction to rotate or pan a Plot3DWidget +as well as toggle a picking mode. +""" + +from __future__ import absolute_import, division + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "06/09/2017" + + +import logging + +from ....utils.proxy import docstring +from ... import qt +from ...icons import getQIcon +from .Plot3DAction import Plot3DAction + + +_logger = logging.getLogger(__name__) + + +class InteractiveModeAction(Plot3DAction): + """Base class for QAction changing interactive mode of a Plot3DWidget + + :param parent: See :class:`QAction` + :param str interaction: The interactive mode this action controls + :param ~silx.gui.plot3d.Plot3DWidget.Plot3DWidget plot3d: + Plot3DWidget the action is associated with + """ + + def __init__(self, parent, interaction, plot3d=None): + self._interaction = interaction + + super(InteractiveModeAction, self).__init__(parent, plot3d) + self.setCheckable(True) + self.triggered[bool].connect(self._triggered) + + def _triggered(self, checked=False): + plot3d = self.getPlot3DWidget() + if plot3d is None: + _logger.error( + 'Cannot set %s interaction, no associated Plot3DWidget' % + self._interaction) + else: + plot3d.setInteractiveMode(self._interaction) + self.setChecked(True) + + @docstring(Plot3DAction) + def setPlot3DWidget(self, widget): + # Disconnect from previous Plot3DWidget + plot3d = self.getPlot3DWidget() + if plot3d is not None: + plot3d.sigInteractiveModeChanged.disconnect( + self._interactiveModeChanged) + + super(InteractiveModeAction, self).setPlot3DWidget(widget) + + # Connect to new Plot3DWidget + if widget is None: + self.setChecked(False) + else: + self.setChecked(widget.getInteractiveMode() == self._interaction) + widget.sigInteractiveModeChanged.connect( + self._interactiveModeChanged) + + def _interactiveModeChanged(self): + plot3d = self.getPlot3DWidget() + if plot3d is None: + _logger.error('Received a signal while there is no widget') + else: + self.setChecked(plot3d.getInteractiveMode() == self._interaction) + + +class RotateArcballAction(InteractiveModeAction): + """QAction to set arcball rotation interaction on a Plot3DWidget + + :param parent: See :class:`QAction` + :param ~silx.gui.plot3d.Plot3DWidget.Plot3DWidget plot3d: + Plot3DWidget the action is associated with + """ + + def __init__(self, parent, plot3d=None): + super(RotateArcballAction, self).__init__(parent, 'rotate', plot3d) + + self.setIcon(getQIcon('rotate-3d')) + self.setText('Rotate') + self.setToolTip('Rotate the view. Press <b>Ctrl</b> to pan.') + + +class PanAction(InteractiveModeAction): + """QAction to set 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(PanAction, self).__init__(parent, 'pan', plot3d) + + self.setIcon(getQIcon('pan')) + self.setText('Pan') + self.setToolTip('Pan the view. Press <b>Ctrl</b> to rotate.') + + +class PickingModeAction(Plot3DAction): + """QAction to toggle picking moe on a Plot3DWidget + + :param parent: See :class:`QAction` + :param ~silx.gui.plot3d.Plot3DWidget.Plot3DWidget plot3d: + Plot3DWidget the action is associated with + """ + + sigSceneClicked = qt.Signal(float, float) + """Signal emitted when the scene is clicked with the left mouse button. + + This signal is only emitted when the action is checked. + + It provides the (x, y) clicked mouse position in logical widget pixel coordinates + """ + + def __init__(self, parent, plot3d=None): + super(PickingModeAction, self).__init__(parent, plot3d) + self.setIcon(getQIcon('pointing-hand')) + self.setText('Picking') + self.setToolTip('Toggle picking with left button click') + self.setCheckable(True) + self.triggered[bool].connect(self._triggered) + + def _triggered(self, checked=False): + plot3d = self.getPlot3DWidget() + if plot3d is not None: + if checked: + plot3d.sigSceneClicked.connect(self.sigSceneClicked) + else: + plot3d.sigSceneClicked.disconnect(self.sigSceneClicked) + + @docstring(Plot3DAction) + def setPlot3DWidget(self, widget): + # Disconnect from previous Plot3DWidget + plot3d = self.getPlot3DWidget() + if plot3d is not None and self.isChecked(): + plot3d.sigSceneClicked.disconnect(self.sigSceneClicked) + + super(PickingModeAction, self).setPlot3DWidget(widget) + + # Connect to new Plot3DWidget + if widget is None: + self.setChecked(False) + elif self.isChecked(): + widget.sigSceneClicked.connect(self.sigSceneClicked) diff --git a/src/silx/gui/plot3d/actions/viewpoint.py b/src/silx/gui/plot3d/actions/viewpoint.py new file mode 100644 index 0000000..d764c40 --- /dev/null +++ b/src/silx/gui/plot3d/actions/viewpoint.py @@ -0,0 +1,231 @@ +# 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 Plot3DAction controlling the viewpoint. + +It provides QAction to rotate or pan a Plot3DWidget. +""" + +from __future__ import absolute_import, division + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "03/10/2017" + + +import time +import logging + +from silx.gui import qt +from silx.gui.icons import getQIcon +from .Plot3DAction import Plot3DAction + + +_logger = logging.getLogger(__name__) + + +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 ~silx.gui.plot3d.Plot3DWidget.Plot3DWidget plot3d: + Plot3DWidget the action is associated with + """ + + _TIMEOUT_MS = 50 + """Time interval between to frames (in milliseconds)""" + + _DEGREE_PER_SECONDS = 360. / 5. + """Rotation speed of the animation""" + + def __init__(self, parent, plot3d=None): + super(RotateViewpoint, self).__init__(parent, plot3d) + + self._previousTime = None + + self._timer = qt.QTimer(self) + self._timer.setInterval(self._TIMEOUT_MS) # 20fps + self._timer.timeout.connect(self._rotate) + + self.setIcon(getQIcon('cube-rotate')) + self.setText('Rotate scene') + self.setToolTip('Rotate the 3D scene around the vertical axis') + self.setCheckable(True) + 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') + elif checked: + self._previousTime = time.time() + self._timer.start() + else: + self._timer.stop() + self._previousTime = None + + def _rotate(self): + """Perform a step of the rotation""" + if self._previousTime is None: + _logger.error('Previous time not set!') + angleStep = 0. + else: + angleStep = self._DEGREE_PER_SECONDS * (time.time() - self._previousTime) + + self.getPlot3DWidget().viewport.orbitCamera('left', angleStep) + self._previousTime = time.time() diff --git a/src/silx/gui/plot3d/conftest.py b/src/silx/gui/plot3d/conftest.py new file mode 100644 index 0000000..da02238 --- /dev/null +++ b/src/silx/gui/plot3d/conftest.py @@ -0,0 +1,5 @@ +import pytest + +@pytest.mark.usefixtures("use_opengl") +def setup_module(module): + pass diff --git a/src/silx/gui/plot3d/items/__init__.py b/src/silx/gui/plot3d/items/__init__.py new file mode 100644 index 0000000..e7c4af1 --- /dev/null +++ b/src/silx/gui/plot3d/items/__init__.py @@ -0,0 +1,43 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2017-2021 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""This 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, ComplexMixIn, InterpolationMixIn, # noqa + PlaneMixIn, SymbolMixIn) # noqa +from .clipplane import ClipPlane # noqa +from .image import ImageData, ImageRgba, HeightMapData, HeightMapRGBA # noqa +from .mesh import Mesh, ColormapMesh, Box, Cylinder, Hexagon # noqa +from .scatter import Scatter2D, Scatter3D # noqa +from .volume import ComplexField3D, ScalarField3D # noqa diff --git a/src/silx/gui/plot3d/items/_pick.py b/src/silx/gui/plot3d/items/_pick.py new file mode 100644 index 0000000..0d6a495 --- /dev/null +++ b/src/silx/gui/plot3d/items/_pick.py @@ -0,0 +1,265 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2018-2020 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 classes supporting item picking. +""" + +from __future__ import absolute_import + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "24/09/2018" + +import logging +import numpy + +from ...plot.items._pick import PickingResult as _PickingResult +from ..scene import Viewport, Base + + +_logger = logging.getLogger(__name__) + + +class PickContext(object): + """Store information related to current picking + + :param int x: Widget coordinate + :param int y: Widget coordinate + :param ~silx.gui.plot3d.scene.Viewport viewport: + Viewport where picking occurs + :param Union[None,callable] condition: + Test whether each item needs to be picked or not. + """ + + def __init__(self, x, y, viewport, condition): + self._widgetPosition = x, y + assert isinstance(viewport, Viewport) + self._viewport = viewport + self._ndcZRange = -1., 1. + self._enabled = True + self._condition = condition + + def copy(self): + """Returns a copy + + :rtype: PickContent + """ + x, y = self.getWidgetPosition() + context = PickContext(x, y, self.getViewport(), self._condition) + context.setNDCZRange(*self._ndcZRange) + context.setEnabled(self.isEnabled()) + return context + + def isItemPickable(self, item): + """Check condition for the given item. + + :param Item3D item: + :return: Whether to process the item (True) or to skip it (False) + :rtype: bool + """ + return self._condition is None or self._condition(item) + + def getViewport(self): + """Returns viewport where picking occurs + + :rtype: ~silx.gui.plot3d.scene.Viewport + """ + return self._viewport + + def getWidgetPosition(self): + """Returns (x, y) position in pixel in the widget + + Origin is at the top-left corner of the widget, + X from left to right, Y goes downward. + + :rtype: List[int] + """ + return self._widgetPosition + + def setEnabled(self, enabled): + """Set whether picking is enabled or not + + :param bool enabled: True to enable picking, False otherwise + """ + self._enabled = bool(enabled) + + def isEnabled(self): + """Returns True if picking is currently enabled, False otherwise. + + :rtype: bool + """ + return self._enabled + + def setNDCZRange(self, near=-1., far=1.): + """Set near and far Z value in normalized device coordinates + + This allows to clip the ray to a subset of the NDC range + + :param float near: Near segment end point Z coordinate + :param float far: Far segment end point Z coordinate + """ + self._ndcZRange = near, far + + def getNDCPosition(self): + """Return Normalized device coordinates of picked point. + + :return: (x, y) in NDC coordinates or None if outside viewport. + :rtype: Union[None,List[float]] + """ + if not self.isEnabled(): + return None + + # Convert x, y from window to NDC + x, y = self.getWidgetPosition() + return self.getViewport().windowToNdc(x, y, checkInside=True) + + def getPickingSegment(self, frame): + """Returns picking segment in requested coordinate frame. + + :param Union[str,Base] frame: + The frame in which to get the picking segment, + either a keyword: 'ndc', 'camera', 'scene' or a scene + :class:`~silx.gui.plot3d.scene.Base` object. + :return: Near and far points of the segment as (x, y, z, w) + or None if picked point is outside viewport + :rtype: Union[None,numpy.ndarray] + """ + assert frame in ('ndc', 'camera', 'scene') or isinstance(frame, Base) + + positionNdc = self.getNDCPosition() + if positionNdc is None: + return None + + near, far = self._ndcZRange + rayNdc = numpy.array((positionNdc + (near, 1.), + positionNdc + (far, 1.)), + dtype=numpy.float64) + if frame == 'ndc': + return rayNdc + + viewport = self.getViewport() + + rayCamera = viewport.camera.intrinsic.transformPoints( + rayNdc, + direct=False, + perspectiveDivide=True) + if frame == 'camera': + return rayCamera + + rayScene = viewport.camera.extrinsic.transformPoints( + rayCamera, direct=False) + if frame == 'scene': + return rayScene + + # frame is a scene Base object + rayObject = frame.objectToSceneTransform.transformPoints( + rayScene, direct=False) + return rayObject + + +class PickingResult(_PickingResult): + """Class to access picking information in a 3D scene.""" + + def __init__(self, item, positions, indices=None, fetchdata=None): + """Init + + :param ~silx.gui.plot3d.items.Item3D item: The picked item + :param numpy.ndarray positions: + Nx3 array-like of picked positions (x, y, z) in item coordinates. + :param numpy.ndarray indices: Array-like of indices of picked data. + Either 1D or 2D with dim0: data dimension and dim1: indices. + No copy is made. + :param callable fetchdata: Optional function with a bool copy argument + to provide an alternative function to access item data. + Default is to use `item.getData`. + """ + super(PickingResult, self).__init__(item, indices) + + self._objectPositions = numpy.array( + positions, copy=False, dtype=numpy.float64) + + # Store matrices to generate positions on demand + primitive = item._getScenePrimitive() + self._objectToSceneTransform = primitive.objectToSceneTransform + self._objectToNDCTransform = primitive.objectToNDCTransform + self._scenePositions = None + self._ndcPositions = None + + self._fetchdata = fetchdata + + def getData(self, copy=True): + """Returns picked data values + + :param bool copy: True (default) to get a copy, + False to return internal arrays + :rtype: Union[None,numpy.ndarray] + """ + + indices = self.getIndices(copy=False) + if indices is None or len(indices) == 0: + return None + + item = self.getItem() + if self._fetchdata is None: + if hasattr(item, 'getData'): + data = item.getData(copy=False) + else: + return None + else: + data = self._fetchdata(copy=False) + + return numpy.array(data[indices], copy=copy) + + def getPositions(self, frame='scene', copy=True): + """Returns picking positions in item coordinates. + + :param str frame: The frame in which the positions are returned + Either 'scene' for world space, + 'ndc' for normalized device coordinates or 'object' for item frame. + :param bool copy: True (default) to get a copy, + False to return internal arrays + :return: Nx3 array of (x, y, z) coordinates + :rtype: numpy.ndarray + """ + if frame == 'ndc': + if self._ndcPositions is None: # Lazy-loading + self._ndcPositions = self._objectToNDCTransform.transformPoints( + self._objectPositions, perspectiveDivide=True) + + positions = self._ndcPositions + + elif frame == 'scene': + if self._scenePositions is None: # Lazy-loading + self._scenePositions = self._objectToSceneTransform.transformPoints( + self._objectPositions) + + positions = self._scenePositions + + elif frame == 'object': + positions = self._objectPositions + + else: + raise ValueError('Unsupported frame argument: %s' % str(frame)) + + return numpy.array(positions, copy=copy) diff --git a/src/silx/gui/plot3d/items/clipplane.py b/src/silx/gui/plot3d/items/clipplane.py new file mode 100644 index 0000000..3e819d0 --- /dev/null +++ b/src/silx/gui/plot3d/items/clipplane.py @@ -0,0 +1,136 @@ +# 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 scene clip plane class. +""" + +from __future__ import absolute_import + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "15/11/2017" + + +import numpy + +from ..scene import primitives, utils + +from ._pick import PickingResult +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) + + def __pickPreProcessing(self, context): + """Common processing for :meth:`_pickPostProcess` and :meth:`_pickFull` + + :param PickContext context: Current picking context + :return None or (bounds, intersection points, rayObject) + """ + plane = self._getPlane() + planeParent = plane.parent + if planeParent is None: + return None + + rayObject = context.getPickingSegment(frame=plane) + if rayObject is None: + return None + + bounds = planeParent.bounds(dataBounds=True) + rayClip = utils.clipSegmentToBounds(rayObject[:, :3], bounds) + if rayClip is None: + return None # Ray is outside parent's bounding box + + points = utils.segmentPlaneIntersect( + rayObject[0, :3], + rayObject[1, :3], + planeNorm=self.getNormal(), + planePt=self.getPoint()) + + # A single intersection inside bounding box + picked = (len(points) == 1 and + numpy.all(bounds[0] <= points[0]) and + numpy.all(points[0] <= bounds[1])) + + return picked, points, rayObject + + def _pick(self, context): + # Perform picking before modifying context + result = super(ClipPlane, self)._pick(context) + + # Modify context if needed + if self.isVisible() and context.isEnabled(): + info = self.__pickPreProcessing(context) + if info is not None: + picked, points, rayObject = info + plane = self._getPlane() + + if picked: # A single intersection inside bounding box + # Clip NDC z range for following brother items + ndcIntersect = plane.objectToNDCTransform.transformPoint( + points[0], perspectiveDivide=True) + ndcNormal = plane.objectToNDCTransform.transformNormal( + self.getNormal()) + if ndcNormal[2] < 0: + context.setNDCZRange(-1., ndcIntersect[2]) + else: + context.setNDCZRange(ndcIntersect[2], 1.) + + else: + # TODO check this might not be correct + rayObject[:, 3] = 1. # Make sure 4h coordinate is one + if numpy.sum(rayObject[0] * self.getParameters()) < 0.: + # Disable picking for remaining brothers + context.setEnabled(False) + + return result + + def _pickFastCheck(self, context): + return True + + def _pickFull(self, context): + """Perform picking in this item at given widget position. + + :param PickContext context: Current picking context + :return: Object holding the results or None + :rtype: Union[None,PickingResult] + """ + info = self.__pickPreProcessing(context) + if info is not None: + picked, points, _ = info + + if picked: + return PickingResult(self, positions=[points[0]]) + + return None diff --git a/src/silx/gui/plot3d/items/core.py b/src/silx/gui/plot3d/items/core.py new file mode 100644 index 0000000..0388ce7 --- /dev/null +++ b/src/silx/gui/plot3d/items/core.py @@ -0,0 +1,778 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2017-2021 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""This module provides 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 enum + +import numpy + +from ... import qt +from ...plot.items import ItemChangedType +from .. import scene +from ..scene import axes, primitives, transform +from ._pick import PickContext + + +@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 = str(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 = str(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()) + + # picking + + def _pick(self, context): + """Implement picking on this item. + + :param PickContext context: Current picking context + :return: Data indices at picked position or None + :rtype: Union[None,PickingResult] + """ + if (self.isVisible() and + context.isEnabled() and + context.isItemPickable(self) and + self._pickFastCheck(context)): + return self._pickFull(context) + return None + + def _pickFastCheck(self, context): + """Approximate item pick test (e.g., bounding box-based picking). + + :param PickContext context: Current picking context + :return: True if item might be picked + :rtype: bool + """ + primitive = self._getScenePrimitive() + + positionNdc = context.getNDCPosition() + if positionNdc is None: # No picking outside viewport + return False + + bounds = primitive.bounds(transformed=False, dataBounds=False) + if bounds is None: # primitive has no bounds + return False + + bounds = primitive.objectToNDCTransform.transformBounds(bounds) + + return (bounds[0, 0] <= positionNdc[0] <= bounds[1, 0] and + bounds[0, 1] <= positionNdc[1] <= bounds[1, 1]) + + def _pickFull(self, context): + """Perform precise picking in this item at given widget position. + + :param PickContext context: Current picking context + :return: Object holding the results or None + :rtype: Union[None,PickingResult] + """ + return None + + +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.__transforms = transform.TransformList([ + self._translate, + self._rotateForwardTranslation, + self._rotate, + self._rotateBackwardTranslation, + self._transformObjectToRotate]) + + self._getScenePrimitive().transforms = self.__transforms + + 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 _getSceneTransforms(self): + """Return TransformList corresponding to current transforms + + :rtype: TransformList + """ + return self.__transforms + + 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, str): + 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 BaseNodeItem(DataItem3D): + """Base class for data item having children (e.g., group, 3d volume).""" + + 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) + + def getItems(self): + """Returns the list of items currently present in the group. + + :rtype: tuple + """ + raise NotImplementedError('getItems must be implemented in subclass') + + 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 + + def pickItems(self, x, y, condition=None): + """Iterator over picked items in the group at given position. + + Each picked item yield a :class:`PickingResult` object + holding the picking information. + + It traverses the group sub-tree in a left-to-right top-down way. + + :param int x: X widget device pixel coordinate + :param int y: Y widget device pixel coordinate + :param callable condition: Optional test called for each item + checking whether to process it or not. + """ + viewport = self._getScenePrimitive().viewport + if viewport is None: + raise RuntimeError( + 'Cannot perform picking: Item not attached to a widget') + + context = PickContext(x, y, viewport, condition) + for result in self._pickItems(context): + yield result + + def _pickItems(self, context): + """Implement :meth:`pickItems` + + :param PickContext context: Current picking context + """ + if not self.isVisible() or not context.isEnabled(): + return # empty iterator + + # Use a copy to discard context changes once this returns + context = context.copy() + + if not self._pickFastCheck(context): + return # empty iterator + + result = self._pick(context) + if result is not None: + yield result + + for child in self.getItems(): + if isinstance(child, BaseNodeItem): + for result in child._pickItems(context): + yield result # Flatten result + + else: + result = child._pick(context) + if result is not None: + yield result + + +class _BaseGroupItem(BaseNodeItem): + """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 + """ + BaseNodeItem.__init__(self, parent=parent, group=group) + self._items = [] + + def _getGroupPrimitive(self): + """Returns the group for which to handle children. + + This allows this group to be different from the primitive. + """ + return self._getScenePrimitive() + + 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._getGroupPrimitive().children.append( + item._getScenePrimitive()) + self._items.append(item) + else: + self._getGroupPrimitive().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._getGroupPrimitive().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) + + +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)) + + +class RootGroupWithAxesItem(GroupWithAxesItem): + """Special group with axes item for root of the scene. + + Uses 2 groups so that axes take transforms into account. + """ + + def __init__(self, parent=None): + super(RootGroupWithAxesItem, self).__init__(parent) + self.__group = scene.Group() + self.__group.transforms = self._getSceneTransforms() + + groupWithAxes = self._getScenePrimitive() + groupWithAxes.transforms = [] # Do not apply transforms here + groupWithAxes.children.append(self.__group) + + def _getGroupPrimitive(self): + """Returns the group for which to handle children. + + This allows this group to be different from the primitive. + """ + return self.__group diff --git a/src/silx/gui/plot3d/items/image.py b/src/silx/gui/plot3d/items/image.py new file mode 100644 index 0000000..5a50459 --- /dev/null +++ b/src/silx/gui/plot3d/items/image.py @@ -0,0 +1,425 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2017-2021 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""This module provides 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, utils +from .core import DataItem3D, ItemChangedType +from .mixins import ColormapMixIn, InterpolationMixIn +from ._pick import PickingResult + + +class _Image(DataItem3D, InterpolationMixIn): + """Base class for images + + :param parent: The View widget this item belongs to. + """ + + def __init__(self, parent=None): + DataItem3D.__init__(self, parent=parent) + InterpolationMixIn.__init__(self) + + def _setPrimitive(self, primitive): + InterpolationMixIn._setPrimitive(self, primitive) + + def getData(self, copy=True): + raise NotImplementedError() + + def _pickFull(self, context): + """Perform picking in this item at given widget position. + + :param PickContext context: Current picking context + :return: Object holding the results or None + :rtype: Union[None,PickingResult] + """ + rayObject = context.getPickingSegment(frame=self._getScenePrimitive()) + if rayObject is None: + return None + + points = utils.segmentPlaneIntersect( + rayObject[0, :3], + rayObject[1, :3], + planeNorm=numpy.array((0., 0., 1.), dtype=numpy.float64), + planePt=numpy.array((0., 0., 0.), dtype=numpy.float64)) + + if len(points) == 1: # Single intersection + if points[0][0] < 0. or points[0][1] < 0.: + return None # Outside image + row, column = int(points[0][1]), int(points[0][0]) + data = self.getData(copy=False) + height, width = data.shape[:2] + if row < height and column < width: + return PickingResult( + self, + positions=[(points[0][0], points[0][1], 0.)], + indices=([row], [column])) + else: + return None # Outside image + else: # Either no intersection or segment and image are coplanar + return None + + +class ImageData(_Image, ColormapMixIn): + """Description of a 2D image data. + + :param parent: The View widget this item belongs to. + """ + + def __init__(self, parent=None): + _Image.__init__(self, parent=parent) + ColormapMixIn.__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) + _Image._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) + self._setColormappedData(self.getData(copy=False), 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(_Image, InterpolationMixIn): + """Description of a 2D data RGB(A) image. + + :param parent: The View widget this item belongs to. + """ + + def __init__(self, parent=None): + _Image.__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 + _Image._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) + + +class _HeightMap(DataItem3D): + """Base class for 2D data array displayed as a height field. + + :param parent: The View widget this item belongs to. + """ + + def __init__(self, parent=None): + DataItem3D.__init__(self, parent=parent) + self.__data = numpy.zeros((0, 0), dtype=numpy.float32) + + def _pickFull(self, context, threshold=0., sort='depth'): + """Perform picking in this item at given widget position. + + :param PickContext context: Current picking context + :param float threshold: Picking threshold in pixel. + Perform picking in a square of size threshold x threshold. + :param str sort: How returned indices are sorted: + + - 'index' (default): sort by the value of the indices + - 'depth': Sort by the depth of the points from the current + camera point of view. + :return: Object holding the results or None + :rtype: Union[None,PickingResult] + """ + assert sort in ('index', 'depth') + + rayNdc = context.getPickingSegment(frame='ndc') + if rayNdc is None: # No picking outside viewport + return None + + # TODO no colormapped or color data + # Project data to NDC + heightData = self.getData(copy=False) + if heightData.size == 0: + return # Nothing displayed + + height, width = heightData.shape + z = numpy.ravel(heightData) + y, x = numpy.mgrid[0:height, 0:width] + dataPoints = numpy.transpose((numpy.ravel(x), + numpy.ravel(y), + z, + numpy.ones_like(z))) + + primitive = self._getScenePrimitive() + + pointsNdc = primitive.objectToNDCTransform.transformPoints( + dataPoints, perspectiveDivide=True) + + # Perform picking + distancesNdc = numpy.abs(pointsNdc[:, :2] - rayNdc[0, :2]) + # TODO issue with symbol size: using pixel instead of points + threshold += 1. # symbol size + thresholdNdc = 2. * threshold / numpy.array(primitive.viewport.size) + picked = numpy.where(numpy.logical_and( + numpy.all(distancesNdc < thresholdNdc, axis=1), + numpy.logical_and(rayNdc[0, 2] <= pointsNdc[:, 2], + pointsNdc[:, 2] <= rayNdc[1, 2])))[0] + + if sort == 'depth': + # Sort picked points from front to back + picked = picked[numpy.argsort(pointsNdc[picked, 2])] + + if picked.size > 0: + # Convert indices from 1D to 2D + return PickingResult(self, + positions=dataPoints[picked, :3], + indices=(picked // width, picked % width), + fetchdata=self.getData) + else: + return None + + def setData(self, data, copy: bool=True): + """Set the height field data. + + :param data: + :param copy: True (default) to copy the data, + False to use as is (do not modify!). + """ + data = numpy.array(data, copy=copy) + assert data.ndim == 2 + + self.__data = data + self._updated(ItemChangedType.DATA) + + def getData(self, copy: bool=True) -> numpy.ndarray: + """Get the height field 2D data. + + :param bool copy: + True (default) to get a copy, + False to get internal representation (do not modify!). + """ + return numpy.array(self.__data, copy=copy) + + +class HeightMapData(_HeightMap, ColormapMixIn): + """Description of a 2D height field associated to a colormapped dataset. + + :param parent: The View widget this item belongs to. + """ + + def __init__(self, parent=None): + _HeightMap.__init__(self, parent=parent) + ColormapMixIn.__init__(self) + + self.__data = numpy.zeros((0, 0), dtype=numpy.float32) + + def _updated(self, event=None): + if event == ItemChangedType.DATA: + self.__updateScene() + super()._updated(event=event) + + def __updateScene(self): + """Update display primitive to use""" + self._getScenePrimitive().children = [] # Remove previous primitives + ColormapMixIn._setSceneColormap(self, None) + + if not self.isVisible(): + return # Update when visible + + data = self.getColormappedData(copy=False) + heightData = self.getData(copy=False) + + if data.size == 0 or heightData.size == 0: + return # Nothing to display + + # Display as a set of points + height, width = heightData.shape + # Generates coordinates + y, x = numpy.mgrid[0:height, 0:width] + + if data.shape != heightData.shape: # data and height size miss-match + # Colormapped data is interpolated (nearest-neighbour) to match the height field + data = data[numpy.floor(y * data.shape[0] / height).astype(numpy.int32), + numpy.floor(x * data.shape[1] / height).astype(numpy.int32)] + + x = numpy.ravel(x) + y = numpy.ravel(y) + + primitive = primitives.Points( + x=x, + y=y, + z=numpy.ravel(heightData), + value=numpy.ravel(data), + size=1) + primitive.marker = 's' + ColormapMixIn._setSceneColormap(self, primitive.colormap) + self._getScenePrimitive().children = [primitive] + + def setColormappedData(self, data, copy: bool=True): + """Set the 2D data used to compute colors. + + :param data: 2D array of data + :param copy: True (default) to copy the data, + False to use as is (do not modify!). + """ + data = numpy.array(data, copy=copy) + assert data.ndim == 2 + + self.__data = data + self._updated(ItemChangedType.DATA) + + def getColormappedData(self, copy: bool=True) -> numpy.ndarray: + """Returns the 2D data used to compute colors. + + :param copy: + True (default) to get a copy, + False to get internal representation (do not modify!). + """ + return numpy.array(self.__data, copy=copy) + + +class HeightMapRGBA(_HeightMap): + """Description of a 2D height field associated to a RGB(A) image. + + :param parent: The View widget this item belongs to. + """ + + def __init__(self, parent=None): + _HeightMap.__init__(self, parent=parent) + + self.__rgba = numpy.zeros((0, 0, 3), dtype=numpy.float32) + + def _updated(self, event=None): + if event == ItemChangedType.DATA: + self.__updateScene() + super()._updated(event=event) + + def __updateScene(self): + """Update display primitive to use""" + self._getScenePrimitive().children = [] # Remove previous primitives + + if not self.isVisible(): + return # Update when visible + + rgba = self.getColorData(copy=False) + heightData = self.getData(copy=False) + if rgba.size == 0 or heightData.size == 0: + return # Nothing to display + + # Display as a set of points + height, width = heightData.shape + # Generates coordinates + y, x = numpy.mgrid[0:height, 0:width] + + if rgba.shape[:2] != heightData.shape: # image and height size miss-match + # RGBA data is interpolated (nearest-neighbour) to match the height field + rgba = rgba[numpy.floor(y * rgba.shape[0] / height).astype(numpy.int32), + numpy.floor(x * rgba.shape[1] / height).astype(numpy.int32)] + + x = numpy.ravel(x) + y = numpy.ravel(y) + + primitive = primitives.ColorPoints( + x=x, + y=y, + z=numpy.ravel(heightData), + color=rgba.reshape(-1, rgba.shape[-1]), + size=1) + primitive.marker = 's' + self._getScenePrimitive().children = [primitive] + + def setColorData(self, data, copy: bool=True): + """Set the RGB(A) image to use. + + Supported array format: float32 in [0, 1], uint8. + + :param data: + The RGBA image data as an array of shape (H, W, Channels) + :param copy: True (default) to copy the data, + False to use as is (do not modify!). + """ + data = numpy.array(data, copy=copy) + assert data.ndim == 3 + assert data.shape[-1] in (3, 4) + # TODO check type + + self.__rgba = data + self._updated(ItemChangedType.DATA) + + def getColorData(self, copy: bool=True) -> numpy.ndarray: + """Get the RGB(A) image data. + + :param copy: True (default) to get a copy, + False to get internal representation (do not modify!). + """ + return numpy.array(self.__rgba, copy=copy) diff --git a/src/silx/gui/plot3d/items/mesh.py b/src/silx/gui/plot3d/items/mesh.py new file mode 100644 index 0000000..4e19939 --- /dev/null +++ b/src/silx/gui/plot3d/items/mesh.py @@ -0,0 +1,792 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2017-2020 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__ = "17/07/2018" + + +import logging +import numpy + +from ... import _glutils as glu +from ..scene import primitives, utils, function +from ..scene.transform import Rotate +from .core import DataItem3D, ItemChangedType +from .mixins import ColormapMixIn +from ._pick import PickingResult + + +_logger = logging.getLogger(__name__) + + +class _MeshBase(DataItem3D): + """Base class for :class:`Mesh' and :class:`ColormapMesh`. + + :param parent: The View widget this item belongs to. + """ + + def __init__(self, parent=None): + DataItem3D.__init__(self, parent=parent) + self._mesh = None + + def _setMesh(self, mesh): + """Set mesh primitive + + :param Union[None,Geometry] mesh: The scene primitive + """ + self._getScenePrimitive().children = [] # Remove any previous mesh + + self._mesh = mesh + if self._mesh is not None: + self._getScenePrimitive().children.append(self._mesh) + + self._updated(ItemChangedType.DATA) + + def _getMesh(self): + """Returns the underlying Mesh scene primitive""" + return self._mesh + + 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._getMesh() is None: + return numpy.empty((0, 3), dtype=numpy.float32) + else: + return self._getMesh().getAttribute('position', 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: Union[numpy.ndarray,None] + """ + if self._getMesh() is None: + return None + else: + return self._getMesh().getAttribute('normal', copy=copy) + + def getIndices(self, copy=True): + """Get the vertex indices. + + :param bool copy: + True (default) to get a copy, + False to get internal representation (do not modify!). + :return: The vertex indices as an array or None. + :rtype: Union[numpy.ndarray,None] + """ + if self._getMesh() is None: + return None + else: + return self._getMesh().getIndices(copy=copy) + + def getDrawMode(self): + """Get mesh rendering mode. + + :return: The drawing mode of this primitive + :rtype: str + """ + return self._getMesh().drawMode + + def _pickFull(self, context): + """Perform precise picking in this item at given widget position. + + :param PickContext context: Current picking context + :return: Object holding the results or None + :rtype: Union[None,PickingResult] + """ + rayObject = context.getPickingSegment(frame=self._getScenePrimitive()) + if rayObject is None: # No picking outside viewport + return None + rayObject = rayObject[:, :3] + + positions = self.getPositionData(copy=False) + if positions.size == 0: + return None + + mode = self.getDrawMode() + + vertexIndices = self.getIndices(copy=False) + if vertexIndices is not None: # Expand indices + positions = utils.unindexArrays(mode, vertexIndices, positions)[0] + triangles = positions.reshape(-1, 3, 3) + else: + if mode == 'triangles': + triangles = positions.reshape(-1, 3, 3) + + elif mode == 'triangle_strip': + # Expand strip + triangles = numpy.empty((len(positions) - 2, 3, 3), + dtype=positions.dtype) + triangles[:, 0] = positions[:-2] + triangles[:, 1] = positions[1:-1] + triangles[:, 2] = positions[2:] + + elif mode == 'fan': + # Expand fan + triangles = numpy.empty((len(positions) - 2, 3, 3), + dtype=positions.dtype) + triangles[:, 0] = positions[0] + triangles[:, 1] = positions[1:-1] + triangles[:, 2] = positions[2:] + + else: + _logger.warning("Unsupported draw mode: %s" % mode) + return None + + trianglesIndices, t, barycentric = glu.segmentTrianglesIntersection( + rayObject, triangles) + + if len(trianglesIndices) == 0: + return None + + points = t.reshape(-1, 1) * (rayObject[1] - rayObject[0]) + rayObject[0] + + # Get vertex index from triangle index and closest point in triangle + closest = numpy.argmax(barycentric, axis=1) + + if mode == 'triangles': + indices = trianglesIndices * 3 + closest + + elif mode == 'triangle_strip': + indices = trianglesIndices + closest + + elif mode == 'fan': + indices = trianglesIndices + closest # For corners 1 and 2 + indices[closest == 0] = 0 # For first corner (common) + + if vertexIndices is not None: + # Convert from indices in expanded triangles to input vertices + indices = vertexIndices[indices] + + return PickingResult(self, + positions=points, + indices=indices, + fetchdata=self.getPositionData) + + +class Mesh(_MeshBase): + """Description of mesh. + + :param parent: The View widget this item belongs to. + """ + + def __init__(self, parent=None): + _MeshBase.__init__(self, parent=parent) + + def setData(self, + position, + color, + normal=None, + mode='triangles', + indices=None, + copy=True): + """Set mesh geometry data. + + Supported drawing modes are: '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 Union[numpy.ndarray,None] normal: Normals for each point or None (default) + :param str mode: The drawing mode. + :param Union[List[int],None] indices: + Array of vertex indices or None to use arrays directly. + :param bool copy: True (default) to copy the data, + False to use as is (do not modify!). + """ + assert mode in ('triangles', 'triangle_strip', 'fan') + if position is None or len(position) == 0: + mesh = None + else: + mesh = primitives.Mesh3D( + position, color, normal, mode=mode, indices=indices, copy=copy) + self._setMesh(mesh) + + 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 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._getMesh() is None: + return numpy.empty((0, 4), dtype=numpy.float32) + else: + return self._getMesh().getAttribute('color', copy=copy) + + +class ColormapMesh(_MeshBase, ColormapMixIn): + """Description of mesh which color is defined by scalar and a colormap. + + :param parent: The View widget this item belongs to. + """ + + def __init__(self, parent=None): + _MeshBase.__init__(self, parent=parent) + ColormapMixIn.__init__(self, function.Colormap()) + + def setData(self, + position, + value, + normal=None, + mode='triangles', + indices=None, + copy=True): + """Set mesh geometry data. + + Supported drawing modes are: 'triangles', 'triangle_strip', 'fan' + + :param numpy.ndarray position: + Position (x, y, z) of each vertex as a (N, 3) array + :param numpy.ndarray value: Data value for each vertex. + :param Union[numpy.ndarray,None] normal: Normals for each point or None (default) + :param str mode: The drawing mode. + :param Union[List[int],None] indices: + Array of vertex indices or None to use arrays directly. + :param bool copy: True (default) to copy the data, + False to use as is (do not modify!). + """ + assert mode in ('triangles', 'triangle_strip', 'fan') + if position is None or len(position) == 0: + mesh = None + else: + mesh = primitives.ColormapMesh3D( + position=position, + value=numpy.array(value, copy=False).reshape(-1, 1), # Make it a 2D array + colormap=self._getSceneColormap(), + normal=normal, + mode=mode, + indices=indices, + copy=copy) + self._setMesh(mesh) + + self._setColormappedData(self.getValueData(copy=False), copy=False) + + 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, values, normals and mode + :rtype: tuple of numpy.ndarray + """ + return (self.getPositionData(copy=copy), + self.getValueData(copy=copy), + self.getNormalData(copy=copy), + self.getDrawMode()) + + def getValueData(self, copy=True): + """Get the mesh vertex values. + + :param bool copy: + True (default) to get a copy, + False to get internal representation (do not modify!). + :return: Array of data values + :rtype: numpy.ndarray + """ + if self._getMesh() is None: + return numpy.empty((0,), dtype=numpy.float32) + else: + return self._getMesh().getAttribute('value', copy=copy) + + +class _CylindricalVolume(DataItem3D): + """Class that represents a volume with a rotational symmetry along z + + :param parent: The View widget this item belongs to. + """ + + def __init__(self, parent=None): + DataItem3D.__init__(self, parent=parent) + self._mesh = None + self._nbFaces = 0 + + def getPosition(self, copy=True): + """Get primitive positions. + + :param bool copy: + True (default) to get a copy, + False to get internal representation (do not modify!). + :return: Position of the primitives as a (N, 3) array. + :rtype: numpy.ndarray + """ + raise NotImplementedError("Must be implemented in subclass") + + def _setData(self, position, radius, height, angles, color, flatFaces, + rotation): + """Set volume geometry data. + + :param numpy.ndarray position: + Center position (x, y, z) of each volume as (N, 3) array. + :param float radius: External radius ot the volume. + :param float height: Height of the volume(s). + :param numpy.ndarray angles: Angles of the edges. + :param numpy.array color: RGB color of the volume(s). + :param bool flatFaces: + If the volume as flat faces or not. Used for normals calculation. + """ + + self._getScenePrimitive().children = [] # Remove any previous mesh + + if position is None or len(position) == 0: + self._mesh = None + self._nbFaces = 0 + else: + self._nbFaces = len(angles) - 1 + + volume = numpy.empty(shape=(len(angles) - 1, 12, 3), + dtype=numpy.float32) + normal = numpy.empty(shape=(len(angles) - 1, 12, 3), + dtype=numpy.float32) + + for i in range(0, len(angles) - 1): + # c6 + # /\ + # / \ + # / \ + # c4|------|c5 + # | \ | + # | \ | + # | \ | + # | \ | + # c2|------|c3 + # \ / + # \ / + # \/ + # c1 + c1 = numpy.array([0, 0, -height/2]) + c1 = rotation.transformPoint(c1) + c2 = numpy.array([radius * numpy.cos(angles[i]), + radius * numpy.sin(angles[i]), + -height/2]) + c2 = rotation.transformPoint(c2) + c3 = numpy.array([radius * numpy.cos(angles[i+1]), + radius * numpy.sin(angles[i+1]), + -height/2]) + c3 = rotation.transformPoint(c3) + c4 = numpy.array([radius * numpy.cos(angles[i]), + radius * numpy.sin(angles[i]), + height/2]) + c4 = rotation.transformPoint(c4) + c5 = numpy.array([radius * numpy.cos(angles[i+1]), + radius * numpy.sin(angles[i+1]), + height/2]) + c5 = rotation.transformPoint(c5) + c6 = numpy.array([0, 0, height/2]) + c6 = rotation.transformPoint(c6) + + volume[i] = numpy.array([c1, c3, c2, + c2, c3, c4, + c3, c5, c4, + c4, c5, c6]) + if flatFaces: + normal[i] = numpy.array([numpy.cross(c3-c1, c2-c1), # c1 + numpy.cross(c2-c3, c1-c3), # c3 + numpy.cross(c1-c2, c3-c2), # c2 + numpy.cross(c3-c2, c4-c2), # c2 + numpy.cross(c4-c3, c2-c3), # c3 + numpy.cross(c2-c4, c3-c4), # c4 + numpy.cross(c5-c3, c4-c3), # c3 + numpy.cross(c4-c5, c3-c5), # c5 + numpy.cross(c3-c4, c5-c4), # c4 + numpy.cross(c5-c4, c6-c4), # c4 + numpy.cross(c6-c5, c5-c5), # c5 + numpy.cross(c4-c6, c5-c6)]) # c6 + else: + normal[i] = numpy.array([numpy.cross(c3-c1, c2-c1), + numpy.cross(c2-c3, c1-c3), + numpy.cross(c1-c2, c3-c2), + c2-c1, c3-c1, c4-c6, # c2 c2 c4 + c3-c1, c5-c6, c4-c6, # c3 c5 c4 + numpy.cross(c5-c4, c6-c4), + numpy.cross(c6-c5, c5-c5), + numpy.cross(c4-c6, c5-c6)]) + + # Multiplication according to the number of positions + vertices = numpy.tile(volume.reshape(-1, 3), (len(position), 1))\ + .reshape((-1, 3)) + normals = numpy.tile(normal.reshape(-1, 3), (len(position), 1))\ + .reshape((-1, 3)) + + # Translations + numpy.add(vertices, numpy.tile(position, (1, (len(angles)-1) * 12)) + .reshape((-1, 3)), out=vertices) + + # Colors + if numpy.ndim(color) == 2: + color = numpy.tile(color, (1, 12 * (len(angles) - 1)))\ + .reshape(-1, 3) + + self._mesh = primitives.Mesh3D( + vertices, color, normals, mode='triangles', copy=False) + self._getScenePrimitive().children.append(self._mesh) + + self._updated(ItemChangedType.DATA) + + def _pickFull(self, context): + """Perform precise picking in this item at given widget position. + + :param PickContext context: Current picking context + :return: Object holding the results or None + :rtype: Union[None,PickingResult] + """ + if self._mesh is None or self._nbFaces == 0: + return None + + rayObject = context.getPickingSegment(frame=self._getScenePrimitive()) + if rayObject is None: # No picking outside viewport + return None + rayObject = rayObject[:, :3] + + positions = self._mesh.getAttribute('position', copy=False) + triangles = positions.reshape(-1, 3, 3) # 'triangle' draw mode + + trianglesIndices, t = glu.segmentTrianglesIntersection( + rayObject, triangles)[:2] + + if len(trianglesIndices) == 0: + return None + + # Get object index from triangle index + indices = trianglesIndices // (4 * self._nbFaces) + + # Select closest intersection point for each primitive + indices, firstIndices = numpy.unique(indices, return_index=True) + t = t[firstIndices] + + # Resort along t as result of numpy.unique is not sorted by t + sortedIndices = numpy.argsort(t) + t = t[sortedIndices] + indices = indices[sortedIndices] + + points = t.reshape(-1, 1) * (rayObject[1] - rayObject[0]) + rayObject[0] + + return PickingResult(self, + positions=points, + indices=indices, + fetchdata=self.getPosition) + + +class Box(_CylindricalVolume): + """Description of a box. + + Can be used to draw one box or many similar boxes. + + :param parent: The View widget this item belongs to. + """ + + def __init__(self, parent=None): + super(Box, self).__init__(parent) + self.position = None + self.size = None + self.color = None + self.rotation = None + self.setData() + + def setData(self, size=(1, 1, 1), color=(1, 1, 1), + position=(0, 0, 0), rotation=(0, (0, 0, 0))): + """ + Set Box geometry data. + + :param numpy.array size: Size (dx, dy, dz) of the box(es). + :param numpy.array color: RGB color of the box(es). + :param numpy.ndarray position: + Center position (x, y, z) of each box as a (N, 3) array. + :param tuple(float, array) rotation: + Angle (in degrees) and axis of rotation. + If (0, (0, 0, 0)) (default), the hexagonal faces are on + xy plane and a side face is aligned with x axis. + """ + self.position = numpy.atleast_2d(numpy.array(position, copy=True)) + self.size = numpy.array(size, copy=True) + self.color = numpy.array(color, copy=True) + self.rotation = Rotate(rotation[0], + rotation[1][0], rotation[1][1], rotation[1][2]) + + assert (numpy.ndim(self.color) == 1 or + len(self.color) == len(self.position)) + + diagonal = numpy.sqrt(self.size[0]**2 + self.size[1]**2) + alpha = 2 * numpy.arcsin(self.size[1] / diagonal) + beta = 2 * numpy.arcsin(self.size[0] / diagonal) + angles = numpy.array([0, + alpha, + alpha + beta, + alpha + beta + alpha, + 2 * numpy.pi]) + numpy.subtract(angles, 0.5 * alpha, out=angles) + self._setData(self.position, + numpy.sqrt(self.size[0]**2 + self.size[1]**2)/2, + self.size[2], + angles, + self.color, + True, + self.rotation) + + def getPosition(self, copy=True): + """Get box(es) position(s). + + :param bool copy: + True (default) to get a copy, + False to get internal representation (do not modify!). + :return: Position of the box(es) as a (N, 3) array. + :rtype: numpy.ndarray + """ + return numpy.array(self.position, copy=copy) + + def getSize(self): + """Get box(es) size. + + :return: Size (dx, dy, dz) of the box(es). + :rtype: numpy.ndarray + """ + return numpy.array(self.size, copy=True) + + def getColor(self, copy=True): + """Get box(es) color. + + :param bool copy: + True (default) to get a copy, + False to get internal representation (do not modify!). + :return: RGB color of the box(es). + :rtype: numpy.ndarray + """ + return numpy.array(self.color, copy=copy) + + +class Cylinder(_CylindricalVolume): + """Description of a cylinder. + + Can be used to draw one cylinder or many similar cylinders. + + :param parent: The View widget this item belongs to. + """ + + def __init__(self, parent=None): + super(Cylinder, self).__init__(parent) + self.position = None + self.radius = None + self.height = None + self.color = None + self.nbFaces = 0 + self.rotation = None + self.setData() + + def setData(self, radius=1, height=1, color=(1, 1, 1), nbFaces=20, + position=(0, 0, 0), rotation=(0, (0, 0, 0))): + """ + Set the cylinder geometry data + + :param float radius: Radius of the cylinder(s). + :param float height: Height of the cylinder(s). + :param numpy.array color: RGB color of the cylinder(s). + :param int nbFaces: + Number of faces for cylinder approximation (default 20). + :param numpy.ndarray position: + Center position (x, y, z) of each cylinder as a (N, 3) array. + :param tuple(float, array) rotation: + Angle (in degrees) and axis of rotation. + If (0, (0, 0, 0)) (default), the hexagonal faces are on + xy plane and a side face is aligned with x axis. + """ + self.position = numpy.atleast_2d(numpy.array(position, copy=True)) + self.radius = float(radius) + self.height = float(height) + self.color = numpy.array(color, copy=True) + self.nbFaces = int(nbFaces) + self.rotation = Rotate(rotation[0], + rotation[1][0], rotation[1][1], rotation[1][2]) + + assert (numpy.ndim(self.color) == 1 or + len(self.color) == len(self.position)) + + angles = numpy.linspace(0, 2*numpy.pi, self.nbFaces + 1) + self._setData(self.position, + self.radius, + self.height, + angles, + self.color, + False, + self.rotation) + + def getPosition(self, copy=True): + """Get cylinder(s) position(s). + + :param bool copy: + True (default) to get a copy, + False to get internal representation (do not modify!). + :return: Position(s) of the cylinder(s) as a (N, 3) array. + :rtype: numpy.ndarray + """ + return numpy.array(self.position, copy=copy) + + def getRadius(self): + """Get cylinder(s) radius. + + :return: Radius of the cylinder(s). + :rtype: float + """ + return self.radius + + def getHeight(self): + """Get cylinder(s) height. + + :return: Height of the cylinder(s). + :rtype: float + """ + return self.height + + def getColor(self, copy=True): + """Get cylinder(s) color. + + :param bool copy: + True (default) to get a copy, + False to get internal representation (do not modify!). + :return: RGB color of the cylinder(s). + :rtype: numpy.ndarray + """ + return numpy.array(self.color, copy=copy) + + +class Hexagon(_CylindricalVolume): + """Description of a uniform hexagonal prism. + + Can be used to draw one hexagonal prim or many similar hexagonal + prisms. + + :param parent: The View widget this item belongs to. + """ + + def __init__(self, parent=None): + super(Hexagon, self).__init__(parent) + self.position = None + self.radius = 0 + self.height = 0 + self.color = None + self.rotation = None + self.setData() + + def setData(self, radius=1, height=1, color=(1, 1, 1), + position=(0, 0, 0), rotation=(0, (0, 0, 0))): + """ + Set the uniform hexagonal prism geometry data + + :param float radius: External radius of the hexagonal prism + :param float height: Height of the hexagonal prism + :param numpy.array color: RGB color of the prism(s) + :param numpy.ndarray position: + Center position (x, y, z) of each prism as a (N, 3) array + :param tuple(float, array) rotation: + Angle (in degrees) and axis of rotation. + If (0, (0, 0, 0)) (default), the hexagonal faces are on + xy plane and a side face is aligned with x axis. + """ + self.position = numpy.atleast_2d(numpy.array(position, copy=True)) + self.radius = float(radius) + self.height = float(height) + self.color = numpy.array(color, copy=True) + self.rotation = Rotate(rotation[0], rotation[1][0], rotation[1][1], + rotation[1][2]) + + assert (numpy.ndim(self.color) == 1 or + len(self.color) == len(self.position)) + + angles = numpy.linspace(0, 2*numpy.pi, 7) + self._setData(self.position, + self.radius, + self.height, + angles, + self.color, + True, + self.rotation) + + def getPosition(self, copy=True): + """Get hexagonal prim(s) position(s). + + :param bool copy: + True (default) to get a copy, + False to get internal representation (do not modify!). + :return: Position(s) of hexagonal prism(s) as a (N, 3) array. + :rtype: numpy.ndarray + """ + return numpy.array(self.position, copy=copy) + + def getRadius(self): + """Get hexagonal prism(s) radius. + + :return: Radius of hexagon(s). + :rtype: float + """ + return self.radius + + def getHeight(self): + """Get hexagonal prism(s) height. + + :return: Height of hexagonal prism(s). + :rtype: float + """ + return self.height + + def getColor(self, copy=True): + """Get hexagonal prism(s) color. + + :param bool copy: + True (default) to get a copy, + False to get internal representation (do not modify!). + :return: RGB color of the hexagonal prism(s). + :rtype: numpy.ndarray + """ + return numpy.array(self.color, copy=copy) diff --git a/src/silx/gui/plot3d/items/mixins.py b/src/silx/gui/plot3d/items/mixins.py new file mode 100644 index 0000000..f512365 --- /dev/null +++ b/src/silx/gui/plot3d/items/mixins.py @@ -0,0 +1,288 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2017-2020 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__ = "24/04/2018" + + +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.items.core import ComplexMixIn as _ComplexMixIn +from ...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.__sceneColormap = sceneColormap + self._syncSceneColormap() + + def _colormapChanged(self): + """Handle colormap updates""" + self._syncSceneColormap() + super(ColormapMixIn, self)._colormapChanged() + + 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() + self.__sceneColormap.gamma = colormap.getGammaNormalizationParameter() + self.__sceneColormap.range_ = colormap.getColormapRange(self) + self.__sceneColormap.nancolor = rgba(colormap.getNaNColor()) + + +class ComplexMixIn(_ComplexMixIn): + __doc__ = _ComplexMixIn.__doc__ # Reuse docstring + + _SUPPORTED_COMPLEX_MODES = ( + _ComplexMixIn.ComplexMode.REAL, + _ComplexMixIn.ComplexMode.IMAGINARY, + _ComplexMixIn.ComplexMode.ABSOLUTE, + _ComplexMixIn.ComplexMode.PHASE, + _ComplexMixIn.ComplexMode.SQUARE_AMPLITUDE) + """Overrides supported ComplexMode""" + + +class SymbolMixIn(_SymbolMixIn): + """Mix-in class for symbol and symbolSize properties for Item3D""" + + _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/src/silx/gui/plot3d/items/scatter.py b/src/silx/gui/plot3d/items/scatter.py new file mode 100644 index 0000000..24abaa5 --- /dev/null +++ b/src/silx/gui/plot3d/items/scatter.py @@ -0,0 +1,617 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2017-2020 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" + +try: + from collections import abc +except ImportError: # Python2 support + import collections as abc +import logging +import numpy + +from ....utils.deprecation import deprecated +from ... import _glutils as glu +from ...plot._utils.delaunay import delaunay +from ..scene import function, primitives, utils + +from ...plot.items import ScatterVisualizationMixIn +from .core import DataItem3D, Item3DChangedType, ItemChangedType +from .mixins import ColormapMixIn, SymbolMixIn +from ._pick import PickingResult + + +_logger = logging.getLogger(__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) + + self._setColormappedData(self.getValueData(copy=False), 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.getValueData(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).reshape(-1) + + 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).reshape(-1) + + 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).reshape(-1) + + def getValueData(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).reshape(-1) + + @deprecated(reason="Consistency with PlotWidget items", + replacement="getValueData", since_version="0.10.0") + def getValues(self, copy=True): + return self.getValueData(copy) + + def _pickFull(self, context, threshold=0., sort='depth'): + """Perform picking in this item at given widget position. + + :param PickContext context: Current picking context + :param float threshold: Picking threshold in pixel. + Perform picking in a square of size threshold x threshold. + :param str sort: How returned indices are sorted: + + - 'index' (default): sort by the value of the indices + - 'depth': Sort by the depth of the points from the current + camera point of view. + :return: Object holding the results or None + :rtype: Union[None,PickingResult] + """ + assert sort in ('index', 'depth') + + rayNdc = context.getPickingSegment(frame='ndc') + if rayNdc is None: # No picking outside viewport + return None + + # Project data to NDC + xData = self.getXData(copy=False) + if len(xData) == 0: # No data in the scatter + return None + + primitive = self._getScenePrimitive() + + dataPoints = numpy.transpose((xData, + self.getYData(copy=False), + self.getZData(copy=False), + numpy.ones_like(xData))) + + pointsNdc = primitive.objectToNDCTransform.transformPoints( + dataPoints, perspectiveDivide=True) + + # Perform picking + distancesNdc = numpy.abs(pointsNdc[:, :2] - rayNdc[0, :2]) + # TODO issue with symbol size: using pixel instead of points + threshold += self.getSymbolSize() + thresholdNdc = 2. * threshold / numpy.array(primitive.viewport.size) + picked = numpy.where(numpy.logical_and( + numpy.all(distancesNdc < thresholdNdc, axis=1), + numpy.logical_and(rayNdc[0, 2] <= pointsNdc[:, 2], + pointsNdc[:, 2] <= rayNdc[1, 2])))[0] + + if sort == 'depth': + # Sort picked points from front to back + picked = picked[numpy.argsort(pointsNdc[picked, 2])] + + if picked.size > 0: + return PickingResult(self, + positions=dataPoints[picked, :3], + indices=picked, + fetchdata=self.getValueData) + else: + return None + + +class Scatter2D(DataItem3D, ColormapMixIn, SymbolMixIn, + ScatterVisualizationMixIn): + """2D scatter data with settable visualization mode. + + :param parent: The View widget this item belongs to. + """ + + _VISUALIZATION_PROPERTIES = { + ScatterVisualizationMixIn.Visualization.POINTS: + ('symbol', 'symbolSize'), + ScatterVisualizationMixIn.Visualization.LINES: + ('lineWidth',), + ScatterVisualizationMixIn.Visualization.SOLID: (), + } + """Dict {visualization mode: property names used in this mode}""" + + _SUPPORTED_SCATTER_VISUALIZATION = tuple(_VISUALIZATION_PROPERTIES.keys()) + """Overrides supported Visualizations""" + + def __init__(self, parent=None): + DataItem3D.__init__(self, parent=parent) + ColormapMixIn.__init__(self) + SymbolMixIn.__init__(self) + ScatterVisualizationMixIn.__init__(self) + + 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 is ItemChangedType.VISIBLE: + # TODO smart update?, need dirty flags + self._updateScene() + + elif event is ItemChangedType.VISUALIZATION_MODE: + self._updateScene() + + super(Scatter2D, self)._updated(event) + + 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, abc.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 + + self._setColormappedData(self.getValueData(copy=False), 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.getValueData(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 getValueData(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) + + @deprecated(reason="Consistency with PlotWidget items", + replacement="getValueData", since_version="0.10.0") + def getValues(self, copy=True): + return self.getValueData(copy) + + def _pickPoints(self, context, points, threshold=1., sort='depth'): + """Perform picking while in 'points' visualization mode + + :param PickContext context: Current picking context + :param float threshold: Picking threshold in pixel. + Perform picking in a square of size threshold x threshold. + :param str sort: How returned indices are sorted: + + - 'index' (default): sort by the value of the indices + - 'depth': Sort by the depth of the points from the current + camera point of view. + :return: Object holding the results or None + :rtype: Union[None,PickingResult] + """ + assert sort in ('index', 'depth') + + rayNdc = context.getPickingSegment(frame='ndc') + if rayNdc is None: # No picking outside viewport + return None + + # Project data to NDC + primitive = self._getScenePrimitive() + pointsNdc = primitive.objectToNDCTransform.transformPoints( + points, perspectiveDivide=True) + + # Perform picking + distancesNdc = numpy.abs(pointsNdc[:, :2] - rayNdc[0, :2]) + thresholdNdc = threshold / numpy.array(primitive.viewport.size) + picked = numpy.where(numpy.logical_and( + numpy.all(distancesNdc < thresholdNdc, axis=1), + numpy.logical_and(rayNdc[0, 2] <= pointsNdc[:, 2], + pointsNdc[:, 2] <= rayNdc[1, 2])))[0] + + if sort == 'depth': + # Sort picked points from front to back + picked = picked[numpy.argsort(pointsNdc[picked, 2])] + + if picked.size > 0: + return PickingResult(self, + positions=points[picked, :3], + indices=picked, + fetchdata=self.getValueData) + else: + return None + + def _pickSolid(self, context, points): + """Perform picking while in 'solid' visualization mode + + :param PickContext context: Current picking context + """ + if self._cachedTrianglesIndices is None: + _logger.info("Picking on Scatter2D before rendering") + return None + + rayObject = context.getPickingSegment(frame=self._getScenePrimitive()) + if rayObject is None: # No picking outside viewport + return None + rayObject = rayObject[:, :3] + + trianglesIndices = self._cachedTrianglesIndices.reshape(-1, 3) + triangles = points[trianglesIndices, :3] + selectedIndices, t, barycentric = glu.segmentTrianglesIntersection( + rayObject, triangles) + closest = numpy.argmax(barycentric, axis=1) + + indices = trianglesIndices.reshape(-1, 3)[selectedIndices, closest] + + if len(indices) == 0: # No point is picked + return None + + # Compute intersection points and get closest data point + positions = t.reshape(-1, 1) * (rayObject[1] - rayObject[0]) + rayObject[0] + + return PickingResult(self, + positions=positions, + indices=indices, + fetchdata=self.getValueData) + + def _pickFull(self, context): + """Perform picking in this item at given widget position. + + :param PickContext context: Current picking context + :return: Object holding the results or None + :rtype: Union[None,PickingResult] + """ + xData = self.getXData(copy=False) + if len(xData) == 0: # No data in the scatter + return None + + if self.isHeightMap(): + zData = self.getValueData(copy=False) + else: + zData = numpy.zeros_like(xData) + + points = numpy.transpose((xData, + self.getYData(copy=False), + zData, + numpy.ones_like(xData))) + + mode = self.getVisualization() + if mode is self.Visualization.POINTS: + # TODO issue with symbol size: using pixel instead of points + # Get "corrected" symbol size + _, threshold = self._getSceneSymbol() + return self._pickPoints( + context, points, threshold=max(3., threshold)) + + elif mode is self.Visualization.LINES: + # Picking only at point + return self._pickPoints(context, points, threshold=5.) + + else: # mode == 'solid' + return self._pickSolid(context, points) + + 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 is self.Visualization.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: + triangulation = delaunay(x, y) + if triangulation is None: + return None + self._cachedTrianglesIndices = numpy.ravel( + triangulation.simplices.astype(numpy.uint32)) + + if (mode is self.Visualization.LINES and + self._cachedLinesIndices is None): + # Compute line indices + self._cachedLinesIndices = utils.triangleToLineIndices( + self._cachedTrianglesIndices, unicity=True) + + if mode is self.Visualization.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 is self.Visualization.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/src/silx/gui/plot3d/items/volume.py b/src/silx/gui/plot3d/items/volume.py new file mode 100644 index 0000000..f80fea2 --- /dev/null +++ b/src/silx/gui/plot3d/items/volume.py @@ -0,0 +1,886 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2017-2020 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__ = "24/04/2018" + +import logging +import time +import numpy + +from silx.math.combo import min_max +from silx.math.marchingcubes import MarchingCubes +from silx.math.interpolate import interp3d + +from ....utils.proxy import docstring +from ... import _glutils as glu +from ... import qt +from ...colors import rgba + +from ..scene import cutplane, function, primitives, transform, utils + +from .core import BaseNodeItem, Item3D, ItemChangedType, Item3DChangedType +from .mixins import ColormapMixIn, ComplexMixIn, InterpolationMixIn, PlaneMixIn +from ._pick import PickingResult + + +_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=None) + ColormapMixIn.__init__(self) + InterpolationMixIn.__init__(self) + PlaneMixIn.__init__(self, plane=plane) + + self._dataRange = None + self._data = None + + self._getScenePrimitive().children = [plane] + + # Connect scene primitive to mix-in class + ColormapMixIn._setSceneColormap(self, plane.colormap) + InterpolationMixIn._setPrimitive(self, plane) + + self.setParent(parent) + + def _updateData(self, data, range_): + """Update used dataset + + No copy is made. + + :param Union[numpy.ndarray[float],None] data: The dataset + :param Union[List[float],None] range_: + (min, min positive, max) values + """ + self._data = None if data is None else numpy.array(data, copy=False) + self._getPlane().setData(self._data, copy=False) + + # Store data range info as 3-tuple of values + self._dataRange = range_ + if range_ is None: + range_ = None, None, None + self._setColormappedData(self._data, copy=False, + min_=range_[0], + minPositive=range_[1], + max_=range_[2]) + + self._updated(ItemChangedType.DATA) + + def _syncDataWithParent(self): + """Synchronize this instance data with that of its parent""" + parent = self.parent() + if parent is None: + data, range_ = None, None + else: + data = parent.getData(copy=False) + range_ = parent.getDataRange() + self._updateData(data, range_) + + def _parentChanged(self, event): + """Handle data change in the parent this plane belongs to""" + if event == ItemChangedType.DATA: + self._syncDataWithParent() + + def setParent(self, parent): + oldParent = self.parent() + if isinstance(oldParent, Item3D): + oldParent.sigItemChanged.disconnect(self._parentChanged) + + super(CutPlane, self).setParent(parent) + + if isinstance(parent, Item3D): + parent.sigItemChanged.connect(self._parentChanged) + + self._syncDataWithParent() + + # 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._updated(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. + :rtype: Union[List[float],None] + """ + return None if self._dataRange is None else tuple(self._dataRange) + + 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 _pickFull(self, context): + """Perform picking in this item at given widget position. + + :param PickContext context: Current picking context + :return: Object holding the results or None + :rtype: Union[None,PickingResult] + """ + rayObject = context.getPickingSegment(frame=self._getScenePrimitive()) + if rayObject is None: + return None + + points = utils.segmentPlaneIntersect( + rayObject[0, :3], + rayObject[1, :3], + planeNorm=self.getNormal(), + planePt=self.getPoint()) + + if len(points) == 1: # Single intersection + if numpy.any(points[0] < 0.): + return None # Outside volume + z, y, x = int(points[0][2]), int(points[0][1]), int(points[0][0]) + + data = self.getData(copy=False) + if data is None: + return None # No dataset + + depth, height, width = data.shape + if z < depth and y < height and x < width: + return PickingResult(self, + positions=[points[0]], + indices=([z], [y], [x])) + else: + return None # Outside image + else: # Either no intersection or segment and image are coplanar + return None + + +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=None) + self._data = None + self._level = float('nan') + self._autoLevelFunction = None + self._color = rgba('#FFD700FF') + self.setParent(parent) + + def _syncDataWithParent(self): + """Synchronize this instance data with that of its parent""" + parent = self.parent() + if parent is None: + self._data = None + else: + self._data = parent.getData(copy=False) + self._updateScenePrimitive() + + def _parentChanged(self, event): + """Handle data change in the parent this isosurface belongs to""" + if event == ItemChangedType.DATA: + self._syncDataWithParent() + + def setParent(self, parent): + oldParent = self.parent() + if isinstance(oldParent, Item3D): + oldParent.sigItemChanged.disconnect(self._parentChanged) + + super(Isosurface, self).setParent(parent) + + if isinstance(parent, Item3D): + parent.sigItemChanged.connect(self._parentChanged) + + self._syncDataWithParent() + + 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 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 _updateColor(self, color): + """Handle update of color + + :param List[float] color: RGBA channels in [0, 1] + """ + primitive = self._getScenePrimitive() + if len(primitive.children) != 0: + primitive.children[0].setAttribute('color', 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 + self._updateColor(self._color) + self._updated(ItemChangedType.COLOR) + + def _computeIsosurface(self): + """Compute isosurface for current state. + + :return: (vertices, normals, indices) arrays + :rtype: List[Union[None,numpy.ndarray]] + """ + data = self.getData(copy=False) + + if data is None: + if self.isAutoLevel(): + self._level = float('nan') + + else: + if self.isAutoLevel(): + st = time.time() + try: + level = float(self.getAutoLevelFunction()(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 numpy.isfinite(self._level): + st = time.time() + vertices, normals, indices = MarchingCubes( + data, + isolevel=self._level) + _logger.info('Computed iso-surface in %f s.', time.time() - st) + + if len(vertices) != 0: + return vertices, normals, indices + + return None, None, None + + def _updateScenePrimitive(self): + """Update underlying mesh""" + self._getScenePrimitive().children = [] + + vertices, normals, indices = self._computeIsosurface() + if vertices is not None: + mesh = primitives.Mesh3D(vertices, + colors=self._color, + normals=normals, + mode='triangles', + indices=indices, + copy=False) + self._getScenePrimitive().children = [mesh] + + def _pickFull(self, context): + """Perform picking in this item at given widget position. + + :param PickContext context: Current picking context + :return: Object holding the results or None + :rtype: Union[None,PickingResult] + """ + rayObject = context.getPickingSegment(frame=self._getScenePrimitive()) + if rayObject is None: + return None + rayObject = rayObject[:, :3] + + data = self.getData(copy=False) + bins = utils.segmentVolumeIntersect( + rayObject, numpy.array(data.shape) - 1) + if bins is None: + return None + + # gather bin data + offsets = [(i, j, k) for i in (0, 1) for j in (0, 1) for k in (0, 1)] + indices = bins[:, numpy.newaxis, :] + offsets + binsData = data[indices[:, :, 0], indices[:, :, 1], indices[:, :, 2]] + # binsData.shape = nbins, 8 + # TODO up-to this point everything can be done once for all isosurfaces + + # check bin candidates + level = self.getLevel() + mask = numpy.logical_and(numpy.nanmin(binsData, axis=1) <= level, + level <= numpy.nanmax(binsData, axis=1)) + bins = bins[mask] + binsData = binsData[mask] + + if len(bins) == 0: + return None # No bin candidate + + # do picking on candidates + intersections = [] + depths = [] + for currentBin, data in zip(bins, binsData): + mc = MarchingCubes(data.reshape(2, 2, 2), isolevel=level) + points = mc.get_vertices() + currentBin + triangles = points[mc.get_indices()] + t = glu.segmentTrianglesIntersection(rayObject, triangles)[1] + t = numpy.unique(t) # Duplicates happen on triangle edges + if len(t) != 0: + # Compute intersection points and get closest data point + points = t.reshape(-1, 1) * (rayObject[1] - rayObject[0]) + rayObject[0] + # Get closest data points by rounding to int + intersections.extend(points) + depths.extend(t) + + if len(intersections) == 0: + return None # No intersected triangles + + intersections = numpy.array(intersections)[numpy.argsort(depths)] + indices = numpy.transpose(numpy.round(intersections).astype(numpy.int64)) + return PickingResult(self, positions=intersections, indices=indices) + + +class ScalarField3D(BaseNodeItem): + """3D scalar field on a regular grid. + + :param parent: The View widget this item belongs to. + """ + + _CutPlane = CutPlane + """CutPlane class associated to this class""" + + _Isosurface = Isosurface + """Isosurface classe associated to this class""" + + def __init__(self, parent=None): + BaseNodeItem.__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 = self._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] + + @staticmethod + def _computeRangeFromData(data): + """Compute range info (min, min positive, max) from data + + :param Union[numpy.ndarray,None] data: + :return: Union[List[float],None] + """ + if data is None: + return None + + dataRange = min_max(data, min_positive=True, finite=True) + if dataRange.minimum is None: # Only non-finite data + return None + + if dataRange is not None: + min_positive = dataRange.min_positive + if min_positive is None: + min_positive = float('nan') + return dataRange.minimum, min_positive, dataRange.maximum + + 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._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 + self._boundedGroup.shape = self._data.shape + + self._dataRange = self._computeRangeFromData(self._data) + 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 = self._Isosurface(parent=self) + isosurface.setColor(color) + if callable(level): + isosurface.setAutoLevelFunction(level) + else: + isosurface.setLevel(level) + 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] + + # BaseNodeItem + + def getItems(self): + """Returns the list of items currently present in this item. + + :rtype: tuple + """ + return self.getCutPlanes() + self.getIsosurfaces() + + +################## +# ComplexField3D # +################## + +class ComplexCutPlane(CutPlane, ComplexMixIn): + """Class representing a cutting plane in a :class:`ComplexField3D` item. + + :param parent: 3D Data set in which the cut plane is applied. + """ + + def __init__(self, parent): + ComplexMixIn.__init__(self) + CutPlane.__init__(self, parent=parent) + + def _syncDataWithParent(self): + """Synchronize this instance data with that of its parent""" + parent = self.parent() + if parent is None: + data, range_ = None, None + else: + mode = self.getComplexMode() + data = parent.getData(mode=mode, copy=False) + range_ = parent.getDataRange(mode=mode) + self._updateData(data, range_) + + def _updated(self, event=None): + """Handle update of the cut plane (and take care of mode change + + :param Union[None,ItemChangedType] event: The kind of update + """ + if event == ItemChangedType.COMPLEX_MODE: + self._syncDataWithParent() + super(ComplexCutPlane, self)._updated(event) + + +class ComplexIsosurface(Isosurface, ComplexMixIn, ColormapMixIn): + """Class representing an iso-surface in a :class:`ComplexField3D` item. + + :param parent: The DataItem3D this iso-surface belongs to + """ + + _SUPPORTED_COMPLEX_MODES = \ + (ComplexMixIn.ComplexMode.NONE,) + ComplexMixIn._SUPPORTED_COMPLEX_MODES + """Overrides supported ComplexMode""" + + def __init__(self, parent): + ComplexMixIn.__init__(self) + ColormapMixIn.__init__(self, function.Colormap()) + Isosurface.__init__(self, parent=parent) + self.setComplexMode(self.ComplexMode.NONE) + + def _updateColor(self, color): + """Handle update of color + + :param List[float] color: RGBA channels in [0, 1] + """ + primitive = self._getScenePrimitive() + if (len(primitive.children) != 0 and + isinstance(primitive.children[0], primitives.ColormapMesh3D)): + primitive.children[0].alpha = self._color[3] + else: + super(ComplexIsosurface, self)._updateColor(color) + + def _syncDataWithParent(self): + """Synchronize this instance data with that of its parent""" + parent = self.parent() + if parent is None: + self._data = None + else: + self._data = parent.getData( + mode=parent.getComplexMode(), copy=False) + + if parent is None or self.getComplexMode() == self.ComplexMode.NONE: + self._setColormappedData(None, copy=False) + else: + self._setColormappedData( + parent.getData(mode=self.getComplexMode(), copy=False), + copy=False) + + self._updateScenePrimitive() + + def _parentChanged(self, event): + """Handle data change in the parent this isosurface belongs to""" + if event == ItemChangedType.COMPLEX_MODE: + self._syncDataWithParent() + super(ComplexIsosurface, self)._parentChanged(event) + + def _updated(self, event=None): + """Handle update of the isosurface (and take care of mode change) + + :param ItemChangedType event: The kind of update + """ + if event == ItemChangedType.COMPLEX_MODE: + self._syncDataWithParent() + + elif event in (ItemChangedType.COLORMAP, + Item3DChangedType.INTERPOLATION): + self._updateScenePrimitive() + super(ComplexIsosurface, self)._updated(event) + + def _updateScenePrimitive(self): + """Update underlying mesh""" + if self.getComplexMode() == self.ComplexMode.NONE: + super(ComplexIsosurface, self)._updateScenePrimitive() + + else: # Specific display for colormapped isosurface + self._getScenePrimitive().children = [] + + values = self.getColormappedData(copy=False) + if values is not None: + vertices, normals, indices = self._computeIsosurface() + if vertices is not None: + values = interp3d(values, vertices, method='linear_omp') + # TODO reuse isosurface when only color changes... + + mesh = primitives.ColormapMesh3D( + vertices, + value=values.reshape(-1, 1), + colormap=self._getSceneColormap(), + normal=normals, + mode='triangles', + indices=indices, + copy=False) + mesh.alpha = self._color[3] + self._getScenePrimitive().children = [mesh] + + +class ComplexField3D(ScalarField3D, ComplexMixIn): + """3D complex field on a regular grid. + + :param parent: The View widget this item belongs to. + """ + + _CutPlane = ComplexCutPlane + _Isosurface = ComplexIsosurface + + def __init__(self, parent=None): + self._dataRangeCache = None + + ComplexMixIn.__init__(self) + ScalarField3D.__init__(self, parent=parent) + + @docstring(ComplexMixIn) + def setComplexMode(self, mode): + mode = ComplexMixIn.ComplexMode.from_value(mode) + if mode != self.getComplexMode(): + self.clearIsosurfaces() # Reset isosurfaces + ComplexMixIn.setComplexMode(self, mode) + + def setData(self, data, copy=True): + """Set the 3D complex 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._dataRangeCache = None + self._boundedGroup.shape = None + + else: + data = numpy.array(data, copy=copy, dtype=numpy.complex64, order='C') + assert data.ndim == 3 + assert min(data.shape) >= 2 + + self._data = data + self._dataRangeCache = {} + self._boundedGroup.shape = self._data.shape + + self._updated(ItemChangedType.DATA) + + def getData(self, copy=True, mode=None): + """Return 3D dataset. + + This method does not cache data converted to a specific mode, + it computes it for each request. + + :param bool copy: + True (default) to get a copy, + False to get the internal data (DO NOT modify!) + :param Union[None,Mode] mode: + The kind of data to retrieve. + If None (the default), it returns the complex data, + else it computes the requested scalar data. + :return: The data set (or None if not set) + :rtype: Union[numpy.ndarray,None] + """ + if mode is None: + return super(ComplexField3D, self).getData(copy=copy) + else: + return self._convertComplexData(self._data, mode) + + def getDataRange(self, mode=None): + """Return the range of the requested data as a 3-tuple of values. + + Positive min is NaN if no data is positive. + + :param Union[None,Mode] mode: + The kind of data for which to get the range information. + If None (the default), it returns the data range for the current mode, + else it returns the data range for the requested mode. + :return: (min, positive min, max) or None. + :rtype: Union[None,List[float]] + """ + if self._dataRangeCache is None: + return None + + if mode is None: + mode = self.getComplexMode() + + if mode not in self._dataRangeCache: + # Compute it and store it in cache + data = self.getData(copy=False, mode=mode) + self._dataRangeCache[mode] = self._computeRangeFromData(data) + + return self._dataRangeCache[mode] diff --git a/src/silx/gui/plot3d/scene/__init__.py b/src/silx/gui/plot3d/scene/__init__.py new file mode 100644 index 0000000..9671725 --- /dev/null +++ b/src/silx/gui/plot3d/scene/__init__.py @@ -0,0 +1,34 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2015-2017 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""This module provides a 3D graphics scene graph structure.""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "08/11/2016" + + +from .core import Base, Elem, Group, PrivateGroup # noqa +from .viewport import Viewport # noqa +from .window import Window # noqa diff --git a/src/silx/gui/plot3d/scene/axes.py b/src/silx/gui/plot3d/scene/axes.py new file mode 100644 index 0000000..e35e5e1 --- /dev/null +++ b/src/silx/gui/plot3d/scene/axes.py @@ -0,0 +1,258 @@ +# coding: utf-8 +# /*########################################################################## +# +# 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 +# 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. +# +# ###########################################################################*/ +"""Primitive displaying a text field in the scene.""" + +from __future__ import absolute_import, division, unicode_literals + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "17/10/2016" + + +import logging +import numpy + +from ...plot._utils import ticklayout + +from . import core, primitives, text, transform + + +_logger = logging.getLogger(__name__) + + +class LabelledAxes(primitives.GroupBBox): + """A group displaying a bounding box with axes labels around its children. + """ + + def __init__(self): + super(LabelledAxes, self).__init__() + self._ticksForBounds = None + + self._font = text.Font() + + self._boxVisibility = True + + # TODO offset labels from anchor in pixels + + self._xlabel = text.Text2D(font=self._font) + self._xlabel.align = 'center' + self._xlabel.transforms = [self._boxTransforms, + transform.Translate(tx=0.5)] + self._children.insert(-1, self._xlabel) + + self._ylabel = text.Text2D(font=self._font) + self._ylabel.align = 'center' + self._ylabel.transforms = [self._boxTransforms, + transform.Translate(ty=0.5)] + self._children.insert(-1, self._ylabel) + + self._zlabel = text.Text2D(font=self._font) + self._zlabel.align = 'center' + self._zlabel.transforms = [self._boxTransforms, + transform.Translate(tz=0.5)] + self._children.insert(-1, self._zlabel) + + # Init tick lines with dummy pos + self._tickLines = primitives.DashedLines( + positions=((0., 0., 0.), (0., 0., 0.))) + self._tickLines.dash = 5, 10 + self._tickLines.visible = False + self._children.insert(-1, self._tickLines) + + self._tickLabels = core.Group() + self._children.insert(-1, self._tickLabels) + + # 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. + + This does NOT set bounding box color. + Use :attr:`color` for the bounding box. + """ + return self._xlabel.foreground + + @tickColor.setter + def tickColor(self, color): + self._xlabel.foreground = color + self._ylabel.foreground = color + self._zlabel.foreground = color + transparentColor = color[0], color[1], color[2], color[3] * 0.6 + self._tickLines.setAttribute('color', transparentColor) + for label in self._tickLabels.children: + label.foreground = color + + @property + def font(self): + """Font of axes text labels (Font)""" + return self._font + + @font.setter + def font(self, font): + self._font = font + self._xlabel.font = font + self._ylabel.font = font + self._zlabel.font = font + for label in self._tickLabels.children: + label.font = font + + @property + def xlabel(self): + """Text label of the X axis (str)""" + return self._xlabel.text + + @xlabel.setter + def xlabel(self, text): + self._xlabel.text = text + + @property + def ylabel(self): + """Text label of the Y axis (str)""" + return self._ylabel.text + + @ylabel.setter + def ylabel(self, text): + self._ylabel.text = text + + @property + def zlabel(self): + """Text label of the Z axis (str)""" + return self._zlabel.text + + @zlabel.setter + def zlabel(self, text): + self._zlabel.text = text + + @property + def boxVisible(self): + """Returns bounding box, axes labels and grid visibility.""" + return self._boxVisibility + + @boxVisible.setter + def boxVisible(self, visible): + self._boxVisibility = bool(visible) + for child in self._children: + if child == self._tickLines: + if self._ticksForBounds is not None: + child.visible = self._boxVisibility + elif child != self._group: + child.visible = self._boxVisibility + + def _updateTicks(self): + """Check if ticks need update and update them if needed.""" + bounds = self._group.bounds(transformed=False, dataBounds=True) + if bounds is None: # No content + if self._ticksForBounds is not None: + self._ticksForBounds = None + self._tickLines.visible = False + self._tickLabels.children = [] # Reset previous labels + + elif (self._ticksForBounds is None or + not numpy.all(numpy.equal(bounds, self._ticksForBounds))): + self._ticksForBounds = bounds + + # Update ticks + ticklength = numpy.abs(bounds[1] - bounds[0]) + + xticks, xlabels = ticklayout.ticks(*bounds[:, 0]) + yticks, ylabels = ticklayout.ticks(*bounds[:, 1]) + zticks, zlabels = ticklayout.ticks(*bounds[:, 2]) + + # Update tick lines + coords = numpy.empty( + ((len(xticks) + len(yticks) + len(zticks)), 4, 3), + dtype=numpy.float32) + coords[:, :, :] = bounds[0, :] # account for offset from origin + + xcoords = coords[:len(xticks)] + xcoords[:, :, 0] = numpy.asarray(xticks)[:, numpy.newaxis] + xcoords[:, 1, 1] += ticklength[1] # X ticks on XY plane + xcoords[:, 3, 2] += ticklength[2] # X ticks on XZ plane + + ycoords = coords[len(xticks):len(xticks) + len(yticks)] + ycoords[:, :, 1] = numpy.asarray(yticks)[:, numpy.newaxis] + ycoords[:, 1, 0] += ticklength[0] # Y ticks on XY plane + ycoords[:, 3, 2] += ticklength[2] # Y ticks on YZ plane + + zcoords = coords[len(xticks) + len(yticks):] + zcoords[:, :, 2] = numpy.asarray(zticks)[:, numpy.newaxis] + zcoords[:, 1, 0] += ticklength[0] # Z ticks on XZ plane + zcoords[:, 3, 1] += ticklength[1] # Z ticks on YZ plane + + self._tickLines.setPositions(coords.reshape(-1, 3)) + self._tickLines.visible = self._boxVisibility + + # Update labels + color = self.tickColor + offsets = bounds[0] - ticklength / 20. + labels = [] + for tick, label in zip(xticks, xlabels): + text2d = text.Text2D(text=label, font=self.font) + text2d.align = 'center' + text2d.foreground = color + text2d.transforms = [transform.Translate( + tx=tick, ty=offsets[1], tz=offsets[2])] + labels.append(text2d) + + for tick, label in zip(yticks, ylabels): + text2d = text.Text2D(text=label, font=self.font) + text2d.align = 'center' + text2d.foreground = color + text2d.transforms = [transform.Translate( + tx=offsets[0], ty=tick, tz=offsets[2])] + labels.append(text2d) + + for tick, label in zip(zticks, zlabels): + text2d = text.Text2D(text=label, font=self.font) + text2d.align = 'center' + text2d.foreground = color + text2d.transforms = [transform.Translate( + tx=offsets[0], ty=offsets[1], tz=tick)] + labels.append(text2d) + + self._tickLabels.children = labels # Reset previous labels + + def prepareGL2(self, context): + self._updateTicks() + super(LabelledAxes, self).prepareGL2(context) diff --git a/src/silx/gui/plot3d/scene/camera.py b/src/silx/gui/plot3d/scene/camera.py new file mode 100644 index 0000000..90de7ed --- /dev/null +++ b/src/silx/gui/plot3d/scene/camera.py @@ -0,0 +1,353 @@ +# coding: utf-8 +# /*########################################################################## +# +# 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 +# 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 classes to handle a perspective projection in 3D.""" + +from __future__ import absolute_import, division, unicode_literals + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "25/07/2016" + + +import numpy + +from . import transform + + +# CameraExtrinsic ############################################################# + +class CameraExtrinsic(transform.Transform): + """Transform matrix to handle camera position and orientation. + + :param position: Coordinates of the point of view. + :type position: numpy.ndarray-like of 3 float32. + :param direction: Sight direction vector. + :type direction: numpy.ndarray-like of 3 float32. + :param up: Vector pointing upward in the image plane. + :type up: numpy.ndarray-like of 3 float32. + """ + + def __init__(self, position=(0., 0., 0.), + direction=(0., 0., -1.), + up=(0., 1., 0.)): + + super(CameraExtrinsic, self).__init__() + self._position = None + self.position = position # set _position + self._side = 1., 0., 0. + self._up = 0., 1., 0. + self._direction = 0., 0., -1. + self.setOrientation(direction=direction, up=up) # set _direction, _up + + def _makeMatrix(self): + return transform.mat4LookAtDir(self._position, + self._direction, self._up) + + def copy(self): + """Return an independent copy""" + return CameraExtrinsic(self.position, self.direction, self.up) + + def setOrientation(self, direction=None, up=None): + """Set the rotation of the point of view. + + :param direction: Sight direction vector or + None to keep the current one. + :type direction: numpy.ndarray-like of 3 float32 or None. + :param up: Vector pointing upward in the image plane or + None to keep the current one. + :type up: numpy.ndarray-like of 3 float32 or None. + :raises RuntimeError: if the direction and up are parallel. + """ + if direction is None: # Use current direction + direction = self.direction + else: + assert len(direction) == 3 + direction = numpy.array(direction, copy=True, dtype=numpy.float32) + direction /= numpy.linalg.norm(direction) + + if up is None: # Use current up + up = self.up + else: + assert len(up) == 3 + up = numpy.array(up, copy=True, dtype=numpy.float32) + + # Update side and up to make sure they are perpendicular and normalized + side = numpy.cross(direction, up) + sidenormal = numpy.linalg.norm(side) + if sidenormal == 0.: + raise RuntimeError('direction and up vectors are parallel.') + # Alternative: when one of the input parameter is None, it is + # possible to guess correct vectors using previous direction and up + side /= sidenormal + up = numpy.cross(side, direction) + up /= numpy.linalg.norm(up) + + self._side = side + self._up = up + self._direction = direction + self.notify() + + @property + def position(self): + """Coordinates of the point of view as a numpy.ndarray of 3 float32.""" + return self._position.copy() + + @position.setter + def position(self, position): + assert len(position) == 3 + self._position = numpy.array(position, copy=True, dtype=numpy.float32) + self.notify() + + @property + def direction(self): + """Sight direction (ndarray of 3 float32).""" + return self._direction.copy() + + @direction.setter + def direction(self, direction): + self.setOrientation(direction=direction) + + @property + def up(self): + """Vector pointing upward in the image plane (ndarray of 3 float32). + """ + return self._up.copy() + + @up.setter + def up(self, up): + self.setOrientation(up=up) + + @property + def side(self): + """Vector pointing towards the side of the image plane. + + ndarray of 3 float32""" + return self._side.copy() + + def move(self, direction, step=1.): + """Move the camera relative to the image plane. + + :param str direction: Direction relative to image plane. + One of: 'up', 'down', 'left', 'right', + 'forward', 'backward'. + :param float step: The step of the pan to perform in the coordinate + in which the camera position is defined. + """ + if direction in ('up', 'down'): + vector = self.up * (1. if direction == 'up' else -1.) + elif direction in ('left', 'right'): + vector = self.side * (1. if direction == 'right' else -1.) + elif direction in ('forward', 'backward'): + vector = self.direction * (1. if direction == 'forward' else -1.) + else: + raise ValueError('Unsupported direction: %s' % direction) + + self.position += step * vector + + def rotate(self, direction, angle=1.): + """First-person rotation of the camera towards the direction. + + :param str direction: Direction of movement relative to image plane. + In: 'up', 'down', 'left', 'right'. + :param float angle: The angle in degrees of the rotation. + """ + if direction in ('up', 'down'): + axis = self.side * (1. if direction == 'up' else -1.) + elif direction in ('left', 'right'): + axis = self.up * (1. if direction == 'left' else -1.) + else: + raise ValueError('Unsupported direction: %s' % direction) + + matrix = transform.mat4RotateFromAngleAxis(numpy.radians(angle), *axis) + newdir = numpy.dot(matrix[:3, :3], self.direction) + + if direction in ('up', 'down'): + # Rotate up to avoid up and new direction to be (almost) co-linear + newup = numpy.dot(matrix[:3, :3], self.up) + self.setOrientation(newdir, newup) + else: + # No need to rotate up here as it is the rotation axis + self.direction = newdir + + def orbit(self, direction, center=(0., 0., 0.), angle=1.): + """Rotate the camera around a point. + + :param str direction: Direction of movement relative to image plane. + In: 'up', 'down', 'left', 'right'. + :param center: Position around which to rotate the point of view. + :type center: numpy.ndarray-like of 3 float32. + :param float angle: he angle in degrees of the rotation. + """ + if direction in ('up', 'down'): + axis = self.side * (1. if direction == 'down' else -1.) + elif direction in ('left', 'right'): + axis = self.up * (1. if direction == 'right' else -1.) + else: + raise ValueError('Unsupported direction: %s' % direction) + + # Rotate viewing direction + rotmatrix = transform.mat4RotateFromAngleAxis( + numpy.radians(angle), *axis) + self.direction = numpy.dot(rotmatrix[:3, :3], self.direction) + + # Rotate position around center + center = numpy.array(center, copy=False, dtype=numpy.float32) + matrix = numpy.dot(transform.mat4Translate(*center), rotmatrix) + matrix = numpy.dot(matrix, transform.mat4Translate(*(-center))) + position = numpy.append(self.position, 1.) + self.position = numpy.dot(matrix, position)[:3] + + _RESET_CAMERA_ORIENTATIONS = { + 'side': ((-1., -1., -1.), (0., 1., 0.)), + 'front': ((0., 0., -1.), (0., 1., 0.)), + 'back': ((0., 0., 1.), (0., 1., 0.)), + 'top': ((0., -1., 0.), (0., 0., -1.)), + 'bottom': ((0., 1., 0.), (0., 0., 1.)), + 'right': ((-1., 0., 0.), (0., 1., 0.)), + 'left': ((1., 0., 0.), (0., 1., 0.)) + } + + def reset(self, face=None): + """Reset the camera position to pre-defined orientations. + + :param str face: The direction of the camera in: + side, front, back, top, bottom, right, left. + """ + if face not in self._RESET_CAMERA_ORIENTATIONS: + raise ValueError('Unsupported face: %s' % face) + + distance = numpy.linalg.norm(self.position) + direction, up = self._RESET_CAMERA_ORIENTATIONS[face] + self.setOrientation(direction, up) + self.position = - self.direction * distance + + +class Camera(transform.Transform): + """Combination of camera projection and position. + + See :class:`Perspective` and :class:`CameraExtrinsic`. + + :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 (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. + :type direction: numpy.ndarray-like of 3 float32. + :param up: Vector pointing upward in the image plane. + :type up: numpy.ndarray-like of 3 float32. + """ + + def __init__(self, fovy=30., near=0.1, far=1., size=(1., 1.), + position=(0., 0., 0.), + direction=(0., 0., -1.), up=(0., 1., 0.)): + super(Camera, self).__init__() + self._intrinsic = transform.Perspective(fovy, near, far, size) + self._intrinsic.addListener(self._transformChanged) + self._extrinsic = CameraExtrinsic(position, direction, up) + self._extrinsic.addListener(self._transformChanged) + + def _makeMatrix(self): + return numpy.dot(self.intrinsic.matrix, self.extrinsic.matrix) + + def _transformChanged(self, source): + """Listener of intrinsic and extrinsic camera parameters instances.""" + if source is not self: + self.notify() + + def resetCamera(self, bounds): + """Change camera to have the bounds in the viewing frustum. + + It updates the camera position and depth extent. + Camera sight direction and up are not affected. + + :param bounds: The axes-aligned bounds to include. + :type bounds: numpy.ndarray: ((xMin, yMin, zMin), (xMax, yMax, zMax)) + """ + + center = 0.5 * (bounds[0] + bounds[1]) + radius = numpy.linalg.norm(0.5 * (bounds[1] - bounds[0])) + if radius == 0.: # bounds are all collapsed + radius = 1. + + if isinstance(self.intrinsic, transform.Perspective): + # Get the viewpoint distance from the bounds center + minfov = numpy.radians(self.intrinsic.fovy) + width, height = self.intrinsic.size + if width < height: + minfov *= width / height + + offset = radius / numpy.sin(0.5 * minfov) + + # Update camera + self.extrinsic.position = \ + center - offset * self.extrinsic.direction + self.intrinsic.setDepthExtent(offset - radius, offset + radius) + + elif isinstance(self.intrinsic, transform.Orthographic): + # Y goes up + self.intrinsic.setClipping( + left=center[0] - radius, + right=center[0] + radius, + bottom=center[1] - radius, + top=center[1] + radius) + + # Update camera + self.extrinsic.position = 0, 0, 0 + self.intrinsic.setDepthExtent(center[2] - radius, + center[2] + radius) + else: + raise RuntimeError('Unsupported camera: %s' % self.intrinsic) + + @property + def intrinsic(self): + """Intrinsic camera parameters, i.e., projection matrix.""" + return self._intrinsic + + @intrinsic.setter + def intrinsic(self, intrinsic): + self._intrinsic.removeListener(self._transformChanged) + self._intrinsic = intrinsic + self._intrinsic.addListener(self._transformChanged) + + @property + def extrinsic(self): + """Extrinsic camera parameters, i.e., position and orientation.""" + return self._extrinsic + + def move(self, *args, **kwargs): + """See :meth:`CameraExtrinsic.move`.""" + self.extrinsic.move(*args, **kwargs) + + def rotate(self, *args, **kwargs): + """See :meth:`CameraExtrinsic.rotate`.""" + self.extrinsic.rotate(*args, **kwargs) + + def orbit(self, *args, **kwargs): + """See :meth:`CameraExtrinsic.orbit`.""" + self.extrinsic.orbit(*args, **kwargs) diff --git a/src/silx/gui/plot3d/scene/core.py b/src/silx/gui/plot3d/scene/core.py new file mode 100644 index 0000000..43838fe --- /dev/null +++ b/src/silx/gui/plot3d/scene/core.py @@ -0,0 +1,343 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2015-2019 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 scene structure. + +This module provides the classes for describing a tree structure with +rendering and picking API. +All nodes inherit from :class:`Base`. +Nodes with children are provided with :class:`PrivateGroup` and +:class:`Group` classes. +Leaf rendering nodes should inherit from :class:`Elem`. +""" + +from __future__ import absolute_import, division, unicode_literals + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "25/07/2016" + + +import itertools +import weakref + +import numpy + +from . import event +from . import transform + +from .viewport import Viewport + + +# Nodes ####################################################################### + +class Base(event.Notifier): + """A scene node with common features.""" + + def __init__(self): + super(Base, self).__init__() + self._visible = True + self._pickable = False + + self._parentRef = None + + self._transforms = transform.TransformList() + self._transforms.addListener(self._transformChanged) + + # notifying properties + + visible = event.notifyProperty('_visible', + doc="Visibility flag of the node") + pickable = event.notifyProperty('_pickable', + doc="True to make node pickable") + + # Access to tree path + + @property + def parent(self): + """Parent or None if no parent""" + return None if self._parentRef is None else self._parentRef() + + def _setParent(self, parent): + """Set the parent of this node. + + For internal use. + + :param Base parent: The parent. + """ + if parent is not None and self._parentRef is not None: + raise RuntimeError('Trying to add a node at two places.') + # Alternative: remove it from previous children list + self._parentRef = None if parent is None else weakref.ref(parent) + + @property + def path(self): + """Tuple of scene nodes, from the tip of the tree down to this node. + + If this tree is attached to a :class:`Viewport`, + then the :class:`Viewport` is the first element of path. + """ + if self.parent is None: + return self, + elif isinstance(self.parent, Viewport): + return self.parent, self + else: + return self.parent.path + (self, ) + + @property + def viewport(self): + """The viewport this node is attached to or None.""" + root = self.path[0] + return root if isinstance(root, Viewport) else None + + @property + def root(self): + """The root node of the scene. + + If attached to a :class:`Viewport`, this is the item right under it + """ + path = self.path + return path[1] if isinstance(path[0], Viewport) else path[0] + + @property + def objectToNDCTransform(self): + """Transform from object to normalized device coordinates. + + Do not forget perspective divide. + """ + # Using the Viewport's transforms property to proxy the camera + path = self.path + assert isinstance(path[0], Viewport) + return transform.StaticTransformList(elem.transforms for elem in path) + + @property + def objectToSceneTransform(self): + """Transform from object to scene. + + Combine transforms up to the Viewport (not including it). + """ + path = self.path + if isinstance(path[0], Viewport): + path = path[1:] # Remove viewport to remove camera transforms + return transform.StaticTransformList(elem.transforms for elem in path) + + # transform + + @property + def transforms(self): + """List of transforms defining the frame of this node relative + to its parent.""" + return self._transforms + + @transforms.setter + def transforms(self, iterable): + self._transforms.removeListener(self._transformChanged) + if isinstance(iterable, transform.TransformList): + # If it is a TransformList, do not create one to enable sharing. + self._transforms = iterable + else: + assert hasattr(iterable, '__iter__') + self._transforms = transform.TransformList(iterable) + self._transforms.addListener(self._transformChanged) + + def _transformChanged(self, source): + self.notify() # Broadcast transform notification + + # Bounds + + _CUBE_CORNERS = numpy.array(list(itertools.product((0., 1.), repeat=3)), + dtype=numpy.float32) + """Unit cube corners used to transform bounds""" + + def _bounds(self, dataBounds=False): + """Override in subclass to provide bounds in object coordinates""" + return None + + def bounds(self, transformed=False, dataBounds=False): + """Returns the bounds of this node aligned with the axis, + with or without transform applied. + + :param bool transformed: False to give bounds in object coordinates + (the default), True to apply this object's + transforms. + :param bool dataBounds: False to give bounds of vertices (the default), + True to give bounds of the represented data. + :return: The bounds: ((xMin, yMin, zMin), (xMax, yMax, zMax)) or None + if no bounds. + :rtype: numpy.ndarray of float + """ + bounds = self._bounds(dataBounds) + + if transformed and bounds is not None: + bounds = self.transforms.transformBounds(bounds) + + return bounds + + # Rendering + + def prepareGL2(self, ctx): + """Called before the rendering to prepare OpenGL resources. + + Override in subclass. + """ + pass + + def renderGL2(self, ctx): + """Called to perform the OpenGL rendering. + + Override in subclass. + """ + pass + + def render(self, ctx): + """Called internally to perform rendering.""" + if self.visible: + ctx.pushTransform(self.transforms) + self.prepareGL2(ctx) + self.renderGL2(ctx) + ctx.popTransform() + + def postRender(self, ctx): + """Hook called when parent's node render is finished. + + Called in the reverse of rendering order (i.e., last child first). + + Meant for nodes that modify the :class:`RenderContext` ctx to + reset their modifications. + """ + pass + + def pick(self, ctx, x, y, depth=None): + """True/False picking, should be fast""" + if self.pickable: + pass + + def pickRay(self, ctx, ray): + """Picking returning list of ray intersections.""" + if self.pickable: + pass + + +class Elem(Base): + """A scene node that does some rendering.""" + + def __init__(self): + super(Elem, self).__init__() + # self.showBBox = False # Here or outside scene graph? + # self.clipPlane = None # This needs to be handled in the shader + + +class PrivateGroup(Base): + """A scene node that renders its (private) childern. + + :param iterable children: :class:`Base` nodes to add as children + """ + + class ChildrenList(event.NotifierList): + """List of children with notification and children's parent update.""" + + def _listWillChangeHook(self, methodName, *args, **kwargs): + super(PrivateGroup.ChildrenList, self)._listWillChangeHook( + methodName, *args, **kwargs) + for item in self: + item._setParent(None) + + def _listWasChangedHook(self, methodName, *args, **kwargs): + for item in self: + item._setParent(self._parentRef()) + super(PrivateGroup.ChildrenList, self)._listWasChangedHook( + methodName, *args, **kwargs) + + def __init__(self, parent, children): + self._parentRef = weakref.ref(parent) + super(PrivateGroup.ChildrenList, self).__init__(children) + + def __init__(self, children=()): + super(PrivateGroup, self).__init__() + self.__children = PrivateGroup.ChildrenList(self, children) + self.__children.addListener(self._updated) + + @property + def _children(self): + """List of children to be rendered. + + This private attribute is meant to be used by subclass. + """ + return self.__children + + @_children.setter + def _children(self, iterable): + self.__children.removeListener(self._updated) + for item in self.__children: + item._setParent(None) + del self.__children # This is needed + self.__children = PrivateGroup.ChildrenList(self, iterable) + self.__children.addListener(self._updated) + self.notify() + + def _updated(self, source, *args, **kwargs): + """Listen for updates""" + if source is not self: # Avoid infinite recursion + self.notify(*args, **kwargs) + + def _bounds(self, dataBounds=False): + """Compute the bounds from transformed children bounds""" + bounds = [] + for child in self._children: + if child.visible: + childBounds = child.bounds( + transformed=True, dataBounds=dataBounds) + if childBounds is not None: + bounds.append(childBounds) + + if len(bounds) == 0: + return None + else: + bounds = numpy.array(bounds, dtype=numpy.float32) + return numpy.array((bounds[:, 0, :].min(axis=0), + bounds[:, 1, :].max(axis=0)), + dtype=numpy.float32) + + def prepareGL2(self, ctx): + pass + + def renderGL2(self, ctx): + """Render all children""" + for child in self._children: + child.render(ctx) + for child in reversed(self._children): + child.postRender(ctx) + + +class Group(PrivateGroup): + """A scene node that renders its (public) children.""" + + @property + def children(self): + """List of children to be rendered.""" + return self._children + + @children.setter + def children(self, iterable): + self._children = iterable diff --git a/src/silx/gui/plot3d/scene/cutplane.py b/src/silx/gui/plot3d/scene/cutplane.py new file mode 100644 index 0000000..88147df --- /dev/null +++ b/src/silx/gui/plot3d/scene/cutplane.py @@ -0,0 +1,390 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2016-2020 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. +# +# ###########################################################################*/ +"""A cut plane in a 3D texture: hackish implementation... +""" + +from __future__ import absolute_import, division, unicode_literals + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "11/01/2018" + +import string +import numpy + +from ... import _glutils +from ..._glutils import gl + +from .function import Colormap +from .primitives import Box, Geometry, PlaneInGroup +from . import transform, utils + + +class ColormapMesh3D(Geometry): + """A 3D mesh with color from a 3D texture.""" + + _shaders = (""" + attribute vec3 position; + attribute vec3 normal; + + uniform mat4 matrix; + uniform mat4 transformMat; + //uniform mat3 matrixInvTranspose; + uniform vec3 dataScale; + uniform vec3 texCoordsOffset; + + varying vec4 vCameraPosition; + varying vec3 vPosition; + varying vec3 vNormal; + varying vec3 vTexCoords; + + void main(void) + { + vCameraPosition = transformMat * vec4(position, 1.0); + //vNormal = matrixInvTranspose * normalize(normal); + vPosition = position; + vTexCoords = dataScale * position + texCoordsOffset; + vNormal = normal; + gl_Position = matrix * vec4(position, 1.0); + } + """, + string.Template(""" + varying vec4 vCameraPosition; + varying vec3 vPosition; + varying vec3 vNormal; + varying vec3 vTexCoords; + uniform sampler3D data; + uniform float alpha; + + $colormapDecl + $sceneDecl + $lightingFunction + + void main(void) + { + $scenePreCall(vCameraPosition); + + float value = texture3D(data, vTexCoords).r; + vec4 color = $colormapCall(value); + color.a *= alpha; + + gl_FragColor = $lightingCall(color, vPosition, vNormal); + + $scenePostCall(vCameraPosition); + } + """)) + + def __init__(self, position, normal, data, copy=True, + mode='triangles', indices=None, colormap=None): + assert mode in self._TRIANGLE_MODES + data = numpy.array(data, copy=copy, order='C') + assert data.ndim == 3 + self._data = data + self._texture = None + self._update_texture = True + self._update_texture_filter = False + self._alpha = 1. + self._colormap = colormap or Colormap() # Default colormap + self._colormap.addListener(self._cmapChanged) + self._interpolation = 'linear' + super(ColormapMesh3D, self).__init__(mode, + indices, + position=position, + 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') + assert data.ndim == 3 + self._data = data + self._update_texture = True + + 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 plane, float in [0, 1]""" + return self._alpha + + @alpha.setter + def alpha(self, alpha): + self._alpha = float(alpha) + + @property + def colormap(self): + """The colormap used by this primitive""" + return self._colormap + + def _cmapChanged(self, source, *args, **kwargs): + """Broadcast colormap changes""" + self.notify(*args, **kwargs) + + 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 + self._texture = _glutils.Texture( + gl.GL_R32F, self._data, gl.GL_RED, + minFilter=filter_, + magFilter=filter_, + wrap=gl.GL_CLAMP_TO_EDGE) + + if self._update_texture_filter: + 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(ColormapMesh3D, self).prepareGL2(ctx) + + def renderGL2(self, ctx): + fragment = self._shaders[1].substitute( + sceneDecl=ctx.fragDecl, + scenePreCall=ctx.fragCallPre, + scenePostCall=ctx.fragCallPost, + 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) + self.colormap.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 + 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) + + ctx.setupProgram(program) + + self._texture.bind() + self._draw(program) + + if not self.isBackfaceVisible: + gl.glDisable(gl.GL_CULL_FACE) + + +class CutPlane(PlaneInGroup): + """A cutting plane in a 3D texture""" + + def __init__(self, point=(0., 0., 0.), normal=(0., 0., 1.)): + self._data = None + self._mesh = None + self._alpha = 1. + self._interpolation = 'linear' + self._colormap = Colormap() + super(CutPlane, self).__init__(point, normal) + + def setData(self, data, copy=True): + if data is None: + self._data = None + if self._mesh is not None: + self._children.remove(self._mesh) + self._mesh = None + + else: + data = numpy.array(data, copy=copy, order='C') + assert data.ndim == 3 + self._data = data + if self._mesh is not None: + self._mesh.setData(data, copy=False) + + def getData(self, copy=True): + return None if self._mesh is None else self._mesh.getData(copy=copy) + + @property + def alpha(self): + return self._alpha + + @alpha.setter + def alpha(self, alpha): + self._alpha = float(alpha) + if self._mesh is not None: + self._mesh.alpha = alpha + + @property + def colormap(self): + return self._colormap + + @property + def interpolation(self): + """The texture interpolation mode: 'linear' (default) or 'nearest'""" + return self._interpolation + + @interpolation.setter + def interpolation(self, interpolation): + assert interpolation in ('nearest', 'linear') + if interpolation != self.interpolation: + 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._mesh is None and self._data is not None: + self._mesh = ColormapMesh3D(contourVertices, + normal=self.plane.normal, + data=self._data, + copy=False, + mode='fan', + colormap=self.colormap) + self._mesh.alpha = self._alpha + self._mesh.interpolation = self.interpolation + self._children.insert(0, self._mesh) + + if self._mesh is not None: + if (contourVertices is None or + len(contourVertices) == 0): + self._mesh.visible = False + else: + self._mesh.visible = True + 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): + with self.viewport.light.turnOff(): + super(CutPlane, self).renderGL2(ctx) + + def _bounds(self, dataBounds=False): + if not dataBounds: + vertices = self.contourVertices + if vertices is not None: + return numpy.array( + (vertices.min(axis=0), vertices.max(axis=0)), + dtype=numpy.float32) + else: + return None # Plane in not slicing the data volume + else: + if self._data is None: + return None + else: + depth, height, width = self._data.shape + return numpy.array(((0., 0., 0.), + (width, height, depth)), + dtype=numpy.float32) + + @property + def contourVertices(self): + """The vertices of the contour of the plane/bounds intersection.""" + # TODO copy from PlaneInGroup, refactor all that! + bounds = self.bounds(dataBounds=True) + if bounds is None: + return None # No bounds: no vertices + + # Check if cache is valid and return it + cachebounds, cachevertices = self._cache + if numpy.all(numpy.equal(bounds, cachebounds)): + return cachevertices + + # Cache is not OK, rebuild it + 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) + + self._cache = bounds, vertices if len(vertices) != 0 else None + + return self._cache[1] + + # Render transforms RW, TODO refactor this! + @property + def transforms(self): + return self._transforms + + @transforms.setter + def transforms(self, iterable): + self._transforms.removeListener(self._transformChanged) + if isinstance(iterable, transform.TransformList): + # If it is a TransformList, do not create one to enable sharing. + self._transforms = iterable + else: + assert hasattr(iterable, '__iter__') + self._transforms = transform.TransformList(iterable) + self._transforms.addListener(self._transformChanged) diff --git a/src/silx/gui/plot3d/scene/event.py b/src/silx/gui/plot3d/scene/event.py new file mode 100644 index 0000000..98f8f8b --- /dev/null +++ b/src/silx/gui/plot3d/scene/event.py @@ -0,0 +1,225 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2015-2017 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""This module provides a simple generic notification system.""" + +from __future__ import absolute_import, division, unicode_literals + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "17/07/2018" + + +import logging + +from silx.utils.weakref import WeakList + +_logger = logging.getLogger(__name__) + + +# Notifier #################################################################### + +class Notifier(object): + """Base class for object with notification mechanism.""" + + def __init__(self): + self._listeners = WeakList() + + def addListener(self, listener): + """Register a listener. + + Adding an already registered listener has no effect. + + :param callable listener: The function or method to register. + """ + if listener not in self._listeners: + self._listeners.append(listener) + else: + _logger.warning('Ignoring addition of an already registered listener') + + def removeListener(self, listener): + """Remove a previously registered listener. + + :param callable listener: The function or method to unregister. + """ + try: + self._listeners.remove(listener) + except ValueError: + _logger.warning('Trying to remove a listener that is not registered') + + def notify(self, *args, **kwargs): + """Notify all registered listeners with the given parameters. + + Listeners are called directly in this method. + Listeners are called in the order they were registered. + """ + for listener in self._listeners: + listener(self, *args, **kwargs) + + +def notifyProperty(attrName, copy=False, converter=None, doc=None): + """Create a property that adds notification to an attribute. + + :param str attrName: The name of the attribute to wrap. + :param bool copy: Whether to return a copy of the attribute + or not (the default). + :param converter: Function converting input value to appropriate type + This function takes a single argument and return the + converted value. + It can be used to perform some asserts. + :param str doc: The docstring of the property + :return: A property with getter and setter + """ + if copy: + def getter(self): + return getattr(self, attrName).copy() + else: + def getter(self): + return getattr(self, attrName) + + if converter is None: + def setter(self, value): + if getattr(self, attrName) != value: + setattr(self, attrName, value) + self.notify() + + else: + def setter(self, value): + value = converter(value) + if getattr(self, attrName) != value: + setattr(self, attrName, value) + self.notify() + + return property(getter, setter, doc=doc) + + +class HookList(list): + """List with hooks before and after modification.""" + + def __init__(self, iterable): + super(HookList, self).__init__(iterable) + + self._listWasChangedHook('__init__', iterable) + + def _listWillChangeHook(self, methodName, *args, **kwargs): + """To override. Called before modifying the list. + + This method is called with the name of the method called to + modify the list and its parameters. + """ + pass + + def _listWasChangedHook(self, methodName, *args, **kwargs): + """To override. Called after modifying the list. + + This method is called with the name of the method called to + modify the list and its parameters. + """ + pass + + # Wrapping methods that modify the list + + def _wrapper(self, methodName, *args, **kwargs): + """Generic wrapper of list methods calling the hooks.""" + self._listWillChangeHook(methodName, *args, **kwargs) + result = getattr(super(HookList, self), + methodName)(*args, **kwargs) + self._listWasChangedHook(methodName, *args, **kwargs) + return result + + # Add methods + + def __iadd__(self, *args, **kwargs): + return self._wrapper('__iadd__', *args, **kwargs) + + def __imul__(self, *args, **kwargs): + return self._wrapper('__imul__', *args, **kwargs) + + def append(self, *args, **kwargs): + return self._wrapper('append', *args, **kwargs) + + def extend(self, *args, **kwargs): + return self._wrapper('extend', *args, **kwargs) + + def insert(self, *args, **kwargs): + return self._wrapper('insert', *args, **kwargs) + + # Remove methods + + def __delitem__(self, *args, **kwargs): + return self._wrapper('__delitem__', *args, **kwargs) + + def __delslice__(self, *args, **kwargs): + return self._wrapper('__delslice__', *args, **kwargs) + + def remove(self, *args, **kwargs): + return self._wrapper('remove', *args, **kwargs) + + def pop(self, *args, **kwargs): + return self._wrapper('pop', *args, **kwargs) + + # Set methods + + def __setitem__(self, *args, **kwargs): + return self._wrapper('__setitem__', *args, **kwargs) + + def __setslice__(self, *args, **kwargs): + return self._wrapper('__setslice__', *args, **kwargs) + + # In place methods + + def sort(self, *args, **kwargs): + return self._wrapper('sort', *args, **kwargs) + + def reverse(self, *args, **kwargs): + return self._wrapper('reverse', *args, **kwargs) + + +class NotifierList(HookList, Notifier): + """List of Notifiers with notification mechanism. + + This class registers itself as a listener of the list items. + + The default listener method forward notification from list items + to the listeners of the list. + """ + + def __init__(self, iterable=()): + Notifier.__init__(self) + HookList.__init__(self, iterable) + + def _listWillChangeHook(self, methodName, *args, **kwargs): + for item in self: + item.removeListener(self._notified) + + def _listWasChangedHook(self, methodName, *args, **kwargs): + for item in self: + item.addListener(self._notified) + self.notify() + + def _notified(self, source, *args, **kwargs): + """Default listener forwarding list item changes to its listeners.""" + # Avoid infinite recursion if the list is listening itself + if source is not self: + self.notify(*args, **kwargs) diff --git a/src/silx/gui/plot3d/scene/function.py b/src/silx/gui/plot3d/scene/function.py new file mode 100644 index 0000000..2deb785 --- /dev/null +++ b/src/silx/gui/plot3d/scene/function.py @@ -0,0 +1,654 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2015-2020 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 functions to add to shaders.""" + +from __future__ import absolute_import, division, unicode_literals + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "17/07/2018" + + +import contextlib +import logging +import string +import numpy + +from ... import _glutils +from ..._glutils import gl + +from . import event +from . import utils + + +_logger = logging.getLogger(__name__) + + +class ProgramFunction(object): + """Class providing a function to add to a GLProgram shaders. + """ + + def setupProgram(self, context, program): + """Sets-up uniforms of a program using this shader function. + + :param RenderContext context: The current rendering context + :param GLProgram program: The program to set-up. + It MUST be in use and using this function. + """ + pass + + +class Fog(event.Notifier, ProgramFunction): + """Linear fog over the whole scene content. + + The background of the viewport is used as fog color, + otherwise it defaults to white. + """ + # TODO: add more controls (set fog range), add more fog modes + + _fragDecl = """ + /* (1/(far - near) or 0, near) z in [0 (camera), -inf[ */ + uniform vec2 fogExtentInfo; + + /* Color to use as fog color */ + uniform vec3 fogColor; + + vec4 fog(vec4 color, vec4 cameraPosition) { + /* d = (pos - near) / (far - near) */ + float distance = fogExtentInfo.x * (cameraPosition.z/cameraPosition.w - fogExtentInfo.y); + float fogFactor = clamp(distance, 0.0, 1.0); + vec3 rgb = mix(color.rgb, fogColor, fogFactor); + return vec4(rgb.r, rgb.g, rgb.b, color.a); + } + """ + + _fragDeclNoop = """ + vec4 fog(vec4 color, vec4 cameraPosition) { + return color; + } + """ + + def __init__(self): + super(Fog, self).__init__() + self._isOn = True + + @property + def isOn(self): + """True to enable fog, False to disable (bool)""" + return self._isOn + + @isOn.setter + def isOn(self, isOn): + isOn = bool(isOn) + if self._isOn != isOn: + self._isOn = bool(isOn) + self.notify() + + @property + def fragDecl(self): + return self._fragDecl if self.isOn else self._fragDeclNoop + + @property + def fragCall(self): + return "fog" + + @staticmethod + def _zExtentCamera(viewport): + """Return (far, near) planes Z in camera coordinates. + + :param Viewport viewport: + :return: (far, near) position in camera coords (from 0 to -inf) + """ + # Provide scene z extent in camera coords + bounds = viewport.camera.extrinsic.transformBounds( + viewport.scene.bounds(transformed=True, dataBounds=True)) + return bounds[:, 2] + + def setupProgram(self, context, program): + if not self.isOn: + return + + far, near = context.cache(key='zExtentCamera', + factory=self._zExtentCamera, + viewport=context.viewport) + extent = far - near + gl.glUniform2f(program.uniforms['fogExtentInfo'], + 0.9/extent if extent != 0. else 0., + near) + + # Use background color as fog color + bgColor = context.viewport.background + if bgColor is None: + bgColor = 1., 1., 1. + gl.glUniform3f(program.uniforms['fogColor'], *bgColor[:3]) + + +class ClippingPlane(ProgramFunction): + """Description of a clipping plane and rendering. + + Convention: Clipping is performed in camera/eye space. + + :param point: Local coordinates of a point on the plane. + :type point: numpy.ndarray-like of 3 float32 + :param normal: Local coordinates of the plane normal. + :type normal: numpy.ndarray-like of 3 float32 + """ + + _fragDecl = """ + /* Clipping plane */ + /* as rx + gy + bz + a > 0, clipping all positive */ + uniform vec4 planeEq; + + /* Position is in camera/eye coordinates */ + + bool isClipped(vec4 position) { + vec4 tmp = planeEq * position; + float value = tmp.x + tmp.y + tmp.z + planeEq.a; + return (value < 0.0001); + } + + void clipping(vec4 position) { + if (isClipped(position)) { + discard; + } + } + /* End of clipping */ + """ + + _fragDeclNoop = """ + bool isClipped(vec4 position) + { + return false; + } + + void clipping(vec4 position) {} + """ + + def __init__(self, point=(0., 0., 0.), normal=(0., 0., 0.)): + self._plane = utils.Plane(point, normal) + + @property + def plane(self): + """Plane parameters in camera space.""" + return self._plane + + # GL2 + + @property + def fragDecl(self): + return self._fragDecl if self.plane.isPlane else self._fragDeclNoop + + @property + def fragCall(self): + return "clipping" + + def setupProgram(self, context, program): + """Sets-up uniforms of a program using this shader function. + + :param RenderContext context: The current rendering context + :param GLProgram program: The program to set-up. + It MUST be in use and using this function. + """ + if self.plane.isPlane: + gl.glUniform4f(program.uniforms['planeEq'], *self.plane.parameters) + + +class DirectionalLight(event.Notifier, ProgramFunction): + """Description of a directional Phong light. + + :param direction: The direction of the light or None to disable light + :type direction: ndarray of 3 floats or None + :param ambient: RGB ambient light + :type ambient: ndarray of 3 floats in [0, 1], default: (1., 1., 1.) + :param diffuse: RGB diffuse light parameter + :type diffuse: ndarray of 3 floats in [0, 1], default: (0., 0., 0.) + :param specular: RGB specular light parameter + :type specular: ndarray of 3 floats in [0, 1], default: (1., 1., 1.) + :param int shininess: The shininess of the material for specular term, + default: 0 which disables specular component. + """ + + fragmentShaderFunction = """ + /* Lighting */ + struct DLight { + vec3 lightDir; // Direction of light in object space + vec3 ambient; + vec3 diffuse; + vec3 specular; + float shininess; + vec3 viewPos; // Camera position in object space + }; + + uniform DLight dLight; + + vec4 lighting(vec4 color, vec3 position, vec3 normal) + { + normal = normalize(normal); + // 1-sided + float nDotL = max(0.0, dot(normal, - dLight.lightDir)); + + // 2-sided + //float nDotL = dot(normal, - dLight.lightDir); + //if (nDotL < 0.) { + // nDotL = - nDotL; + // normal = - normal; + //} + + float specFactor = 0.; + if (dLight.shininess > 0. && nDotL > 0.) { + vec3 reflection = reflect(dLight.lightDir, normal); + vec3 viewDir = normalize(dLight.viewPos - position); + specFactor = max(0.0, dot(reflection, viewDir)); + if (specFactor > 0.) { + specFactor = pow(specFactor, dLight.shininess); + } + } + + vec3 enlightedColor = color.rgb * (dLight.ambient + + dLight.diffuse * nDotL) + + dLight.specular * specFactor; + + return vec4(enlightedColor.rgb, color.a); + } + /* End of Lighting */ + """ + + fragmentShaderFunctionNoop = """ + vec4 lighting(vec4 color, vec3 position, vec3 normal) + { + return color; + } + """ + + def __init__(self, direction=None, + ambient=(1., 1., 1.), diffuse=(0., 0., 0.), + specular=(1., 1., 1.), shininess=0): + super(DirectionalLight, self).__init__() + self._direction = None + self.direction = direction # Set _direction + self._isOn = True + self._ambient = ambient + self._diffuse = diffuse + self._specular = specular + self._shininess = shininess + + ambient = event.notifyProperty('_ambient') + diffuse = event.notifyProperty('_diffuse') + specular = event.notifyProperty('_specular') + shininess = event.notifyProperty('_shininess') + + @property + def isOn(self): + """True if light is on, False otherwise.""" + return self._isOn and self._direction is not None + + @isOn.setter + def isOn(self, isOn): + self._isOn = bool(isOn) + + @contextlib.contextmanager + def turnOff(self): + """Context manager to temporary turn off lighting during rendering. + + >>> with light.turnOff(): + ... # Do some rendering without lighting + """ + wason = self._isOn + self._isOn = False + yield + self._isOn = wason + + @property + def direction(self): + """The direction of the light, or None if light is not on.""" + return self._direction + + @direction.setter + def direction(self, direction): + if direction is None: + self._direction = None + else: + assert len(direction) == 3 + direction = numpy.array(direction, dtype=numpy.float32, copy=True) + norm = numpy.linalg.norm(direction) + assert norm != 0 + self._direction = direction / norm + self.notify() + + # GL2 + + @property + def fragmentDef(self): + """Definition to add to fragment shader""" + if self.isOn: + return self.fragmentShaderFunction + else: + return self.fragmentShaderFunctionNoop + + @property + def fragmentCall(self): + """Function name to call in fragment shader""" + return "lighting" + + def setupProgram(self, context, program): + """Sets-up uniforms of a program using this shader function. + + :param RenderContext context: The current rendering context + :param GLProgram program: The program to set-up. + It MUST be in use and using this function. + """ + if self.isOn and self._direction is not None: + # Transform light direction from camera space to object coords + lightdir = context.objectToCamera.transformDir( + self._direction, direct=False) + lightdir /= numpy.linalg.norm(lightdir) + + gl.glUniform3f(program.uniforms['dLight.lightDir'], *lightdir) + + # Convert view position to object coords + viewpos = context.objectToCamera.transformPoint( + numpy.array((0., 0., 0., 1.), dtype=numpy.float32), + direct=False, + perspectiveDivide=True)[:3] + gl.glUniform3f(program.uniforms['dLight.viewPos'], *viewpos) + + gl.glUniform3f(program.uniforms['dLight.ambient'], *self.ambient) + gl.glUniform3f(program.uniforms['dLight.diffuse'], *self.diffuse) + gl.glUniform3f(program.uniforms['dLight.specular'], *self.specular) + gl.glUniform1f(program.uniforms['dLight.shininess'], + self.shininess) + + +class Colormap(event.Notifier, ProgramFunction): + + _declTemplate = string.Template(""" + uniform sampler2D cmap_texture; + uniform int cmap_normalization; + uniform float cmap_parameter; + uniform float cmap_min; + uniform float cmap_oneOverRange; + uniform vec4 nancolor; + + const float oneOverLog10 = 0.43429448190325176; + + vec4 colormap(float value) { + float data = value; /* Keep original input value for isnan test */ + + if (cmap_normalization == 1) { /* Log10 mapping */ + if (value > 0.0) { + value = clamp(cmap_oneOverRange * + (oneOverLog10 * log(value) - cmap_min), + 0.0, 1.0); + } else { + value = 0.0; + } + } else if (cmap_normalization == 2) { /* Sqrt mapping */ + if (value > 0.0) { + value = clamp(cmap_oneOverRange * (sqrt(value) - cmap_min), + 0.0, 1.0); + } else { + value = 0.0; + } + } else if (cmap_normalization == 3) { /*Gamma correction mapping*/ + value = pow( + clamp(cmap_oneOverRange * (value - cmap_min), 0.0, 1.0), + cmap_parameter); + } else if (cmap_normalization == 4) { /* arcsinh mapping */ + /* asinh = log(x + sqrt(x*x + 1) for compatibility with GLSL 1.20 */ + value = clamp(cmap_oneOverRange * (log(value + sqrt(value*value + 1.0)) - cmap_min), 0.0, 1.0); + } else { /* Linear mapping */ + value = clamp(cmap_oneOverRange * (value - cmap_min), 0.0, 1.0); + } + + $discard + + vec4 color; + if (data != data) { /* isnan alternative for compatibility with GLSL 1.20 */ + color = nancolor; + } else { + color = texture2D(cmap_texture, vec2(value, 0.5)); + } + return color; + } + """) + + _discardCode = """ + if (value == 0.) { + discard; + } + """ + + call = "colormap" + + NORMS = 'linear', 'log', 'sqrt', 'gamma', 'arcsinh' + """Tuple of supported normalizations.""" + + _COLORMAP_TEXTURE_UNIT = 1 + """Texture unit to use for storing the colormap""" + + def __init__(self, colormap=None, norm='linear', gamma=0., range_=(1., 10.)): + """Shader function to apply a colormap to a value. + + :param colormap: RGB(A) color look-up table (default: gray) + :param colormap: numpy.ndarray of numpy.uint8 of dimension Nx3 or Nx4 + :param str norm: Normalization to apply: see :attr:`NORMS`. + :param float gamma: Gamma normalization parameter + :param range_: Range of value to map to the colormap. + :type range_: 2-tuple of float (begin, end). + """ + super(Colormap, self).__init__() + + # Init privates to default + self._colormap = None + self._norm = 'linear' + self._gamma = -1. + self._range = 1., 10. + self._displayValuesBelowMin = True + self._nancolor = numpy.array((1., 1., 1., 0.), dtype=numpy.float32) + + self._texture = None + self._textureToDiscard = None + + if colormap is None: + # default colormap + colormap = numpy.empty((256, 3), dtype=numpy.uint8) + colormap[:] = numpy.arange(256, + dtype=numpy.uint8)[:, numpy.newaxis] + + # Set to values through properties to perform asserts and updates + self.colormap = colormap + self.norm = norm + self.gamma = gamma + 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) + + @colormap.setter + def colormap(self, colormap): + colormap = numpy.array(colormap, copy=True) + assert colormap.ndim == 2 + assert colormap.shape[1] in (3, 4) + self._colormap = colormap + + if self._texture is not None and self._texture.name is not None: + self._textureToDiscard = self._texture + + data = numpy.empty( + (16, self._colormap.shape[0], self._colormap.shape[1]), + dtype=self._colormap.dtype) + data[:] = self._colormap + + format_ = gl.GL_RGBA if data.shape[-1] == 4 else gl.GL_RGB + + self._texture = _glutils.Texture( + format_, data, format_, + texUnit=self._COLORMAP_TEXTURE_UNIT, + minFilter=gl.GL_NEAREST, + magFilter=gl.GL_NEAREST, + wrap=gl.GL_CLAMP_TO_EDGE) + + self.notify() + + @property + def nancolor(self): + """RGBA color to use for Not-A-Number values as 4 float in [0., 1.]""" + return self._nancolor + + @nancolor.setter + def nancolor(self, color): + color = numpy.clip(numpy.array(color, dtype=numpy.float32), 0., 1.) + assert color.ndim == 1 + assert len(color) == 4 + if not numpy.array_equal(self._nancolor, color): + self._nancolor = color + self.notify() + + @property + def norm(self): + """Normalization to use for colormap mapping. + + One of 'linear' (the default), 'log' for log10 mapping or 'sqrt'. + Invalid values (e.g., negative values with 'log' or 'sqrt') are mapped to 0. + """ + return self._norm + + @norm.setter + def norm(self, norm): + if norm != self._norm: + assert norm in self.NORMS + self._norm = norm + if norm in ('log', 'sqrt'): + self.range_ = self.range_ # To test for positive range_ + self.notify() + + @property + def gamma(self): + """Gamma correction normalization parameter (float >= 0.)""" + return self._gamma + + @gamma.setter + def gamma(self, gamma): + if gamma != self._gamma: + assert gamma >= 0. + self._gamma = gamma + self.notify() + + @property + def range_(self): + """Range of values to map to the colormap. + + 2-tuple of floats: (begin, end). + The begin value is mapped to the origin of the colormap and the + end value is mapped to the other end of the colormap. + The colormap is reversed if begin > end. + """ + return self._range + + @range_.setter + def range_(self, range_): + assert len(range_) == 2 + range_ = float(range_[0]), float(range_[1]) + + if self.norm == 'log' and (range_[0] <= 0. or range_[1] <= 0.): + _logger.warning( + "Log normalization and negative range: updating range.") + minPos = numpy.finfo(numpy.float32).tiny + range_ = max(range_[0], minPos), max(range_[1], minPos) + elif self.norm == 'sqrt' and (range_[0] < 0. or range_[1] < 0.): + _logger.warning( + "Sqrt normalization and negative range: updating range.") + range_ = max(range_[0], 0.), max(range_[1], 0.) + + if range_ != self._range: + 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. + + :param RenderContext context: The current rendering context + :param GLProgram program: The program to set-up. + It MUST be in use and using this function. + """ + self.prepareGL2(context) # TODO see how to handle + + self._texture.bind() + + gl.glUniform1i(program.uniforms['cmap_texture'], + self._texture.texUnit) + + min_, max_ = self.range_ + param = 0. + if self._norm == 'log': + min_, max_ = numpy.log10(min_), numpy.log10(max_) + normID = 1 + elif self._norm == 'sqrt': + min_, max_ = numpy.sqrt(min_), numpy.sqrt(max_) + normID = 2 + elif self._norm == 'gamma': + # Keep min_, max_ as is + param = self._gamma + normID = 3 + elif self._norm == 'arcsinh': + min_, max_ = numpy.arcsinh(min_), numpy.arcsinh(max_) + normID = 4 + else: # Linear + normID = 0 + + gl.glUniform1i(program.uniforms['cmap_normalization'], normID) + gl.glUniform1f(program.uniforms['cmap_parameter'], param) + gl.glUniform1f(program.uniforms['cmap_min'], min_) + gl.glUniform1f(program.uniforms['cmap_oneOverRange'], + (1. / (max_ - min_)) if max_ != min_ else 0.) + gl.glUniform4f(program.uniforms['nancolor'], *self._nancolor) + + def prepareGL2(self, context): + if self._textureToDiscard is not None: + self._textureToDiscard.discard() + self._textureToDiscard = None + + self._texture.prepare() diff --git a/src/silx/gui/plot3d/scene/interaction.py b/src/silx/gui/plot3d/scene/interaction.py new file mode 100644 index 0000000..14a54dc --- /dev/null +++ b/src/silx/gui/plot3d/scene/interaction.py @@ -0,0 +1,701 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2015-2019 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 interaction to plug on the scene graph.""" + +from __future__ import absolute_import + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__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 + +from . import transform + + +_logger = logging.getLogger(__name__) + + +class ClickOrDrag(StateMachine): + """Click or drag interaction for a given button. + + """ + #TODO: merge this class with silx.gui.plot.Interaction.ClickOrDrag + + DRAG_THRESHOLD_SQUARE_DIST = 5 ** 2 + + class Idle(State): + def onPress(self, x, y, btn): + if btn == self.machine.button: + self.goto('clickOrDrag', x, y) + return True + + class ClickOrDrag(State): + def enterState(self, x, y): + self.initPos = x, y + + enter = enterState # silx v.0.3 support, remove when 0.4 out + + def onMove(self, x, y): + dx = (x - self.initPos[0]) ** 2 + dy = (y - self.initPos[1]) ** 2 + if (dx ** 2 + dy ** 2) >= self.machine.DRAG_THRESHOLD_SQUARE_DIST: + self.goto('drag', self.initPos, (x, y)) + + def onRelease(self, x, y, btn): + if btn == self.machine.button: + self.machine.click(x, y) + self.goto('idle') + + class Drag(State): + def enterState(self, initPos, curPos): + self.initPos = initPos + self.machine.beginDrag(*initPos) + self.machine.drag(*curPos) + + enter = enterState # silx v.0.3 support, remove when 0.4 out + + def onMove(self, x, y): + self.machine.drag(x, y) + + def onRelease(self, x, y, btn): + if btn == self.machine.button: + self.machine.endDrag(self.initPos, (x, y)) + self.goto('idle') + + def __init__(self, button=LEFT_BTN): + self.button = button + states = { + 'idle': ClickOrDrag.Idle, + 'clickOrDrag': ClickOrDrag.ClickOrDrag, + 'drag': ClickOrDrag.Drag + } + super(ClickOrDrag, self).__init__(states, 'idle') + + def click(self, x, y): + """Called upon a left or right button click. + To override in a subclass. + """ + pass + + def beginDrag(self, x, y): + """Called at the beginning of a drag gesture with left button + pressed. + To override in a subclass. + """ + pass + + def drag(self, x, y): + """Called on mouse moved during a drag gesture. + To override in a subclass. + """ + pass + + def endDrag(self, x, y): + """Called at the end of a drag gesture when the left button is + released. + To override in a subclass. + """ + pass + + +class CameraSelectRotate(ClickOrDrag): + """Camera rotation using an arcball-like interaction.""" + + def __init__(self, viewport, orbitAroundCenter=True, button=RIGHT_BTN, + selectCB=None): + self._viewport = viewport + self._orbitAroundCenter = orbitAroundCenter + self._selectCB = selectCB + self._reset() + super(CameraSelectRotate, self).__init__(button) + + def _reset(self): + self._origin, self._center = None, None + self._startExtrinsic = None + + def click(self, x, y): + if self._selectCB is not None: + ndcZ = self._viewport._pickNdcZGL(x, y) + position = self._viewport._getXZYGL(x, y) + # This assume no object lie on the far plane + # Alternative, change the depth range so that far is < 1 + if ndcZ != 1. and position is not None: + self._selectCB((x, y, ndcZ), position) + + def beginDrag(self, x, y): + centerPos = None + if not self._orbitAroundCenter: + # Try to use picked object position as center of rotation + ndcZ = self._viewport._pickNdcZGL(x, y) + if ndcZ != 1.: + # Hit an object, use picked point as center + centerPos = self._viewport._getXZYGL(x, y) # Can return None + + if centerPos is None: + # Not using picked position, use scene center + bounds = self._viewport.scene.bounds(transformed=True) + centerPos = 0.5 * (bounds[0] + bounds[1]) + + self._center = transform.Translate(*centerPos) + self._origin = x, y + self._startExtrinsic = self._viewport.camera.extrinsic.copy() + + def drag(self, x, y): + if self._center is None: + return + + dx, dy = self._origin[0] - x, self._origin[1] - y + + if dx == 0 and dy == 0: + direction = self._startExtrinsic.direction + up = self._startExtrinsic.up + position = self._startExtrinsic.position + else: + minsize = min(self._viewport.size) + distance = numpy.sqrt(dx ** 2 + dy ** 2) + angle = distance / minsize * numpy.pi + + # Take care of y inversion + direction = dx * self._startExtrinsic.side - \ + dy * self._startExtrinsic.up + direction /= numpy.linalg.norm(direction) + axis = numpy.cross(direction, self._startExtrinsic.direction) + axis /= numpy.linalg.norm(axis) + + # Orbit start camera with current angle and axis + # Rotate viewing direction + rotation = transform.Rotate(numpy.degrees(angle), *axis) + direction = rotation.transformDir(self._startExtrinsic.direction) + up = rotation.transformDir(self._startExtrinsic.up) + + # Rotate position around center + trlist = transform.StaticTransformList(( + self._center, + rotation, + self._center.inverse())) + position = trlist.transformPoint(self._startExtrinsic.position) + + camerapos = self._viewport.camera.extrinsic + camerapos.setOrientation(direction, up) + camerapos.position = position + + def endDrag(self, x, y): + self._reset() + + +class CameraSelectPan(ClickOrDrag): + """Picking on click and pan camera on drag.""" + + def __init__(self, viewport, button=LEFT_BTN, selectCB=None): + self._viewport = viewport + self._selectCB = selectCB + self._lastPosNdc = None + super(CameraSelectPan, self).__init__(button) + + def click(self, x, y): + if self._selectCB is not None: + ndcZ = self._viewport._pickNdcZGL(x, y) + position = self._viewport._getXZYGL(x, y) + # This assume no object lie on the far plane + # Alternative, change the depth range so that far is < 1 + if ndcZ != 1. and position is not None: + self._selectCB((x, y, ndcZ), position) + + def beginDrag(self, x, y): + ndc = self._viewport.windowToNdc(x, y) + ndcZ = self._viewport._pickNdcZGL(x, y) + # ndcZ is the panning plane + if ndc is not None and ndcZ is not None: + self._lastPosNdc = numpy.array((ndc[0], ndc[1], ndcZ, 1.), + dtype=numpy.float32) + else: + self._lastPosNdc = None + + def drag(self, x, y): + if self._lastPosNdc is not None: + ndc = self._viewport.windowToNdc(x, y) + if ndc is not None: + ndcPos = numpy.array((ndc[0], ndc[1], self._lastPosNdc[2], 1.), + dtype=numpy.float32) + + # Convert last and current NDC positions to scene coords + scenePos = self._viewport.camera.transformPoint( + ndcPos, direct=False, perspectiveDivide=True) + lastScenePos = self._viewport.camera.transformPoint( + self._lastPosNdc, direct=False, perspectiveDivide=True) + + # Get translation in scene coords + translation = scenePos[:3] - lastScenePos[:3] + self._viewport.camera.extrinsic.position -= translation + + # Store for next drag + self._lastPosNdc = ndcPos + + def endDrag(self, x, y): + self._lastPosNdc = None + + +class CameraWheel(object): + """StateMachine like class, just handling wheel events.""" + + # TODO choose scale of motion? Translation or Scale? + def __init__(self, viewport, mode='center', scaleTransform=None): + assert mode in ('center', 'position', 'scale') + self._viewport = viewport + if mode == 'center': + self._zoomTo = self._zoomToCenter + elif mode == 'position': + self._zoomTo = self._zoomToPosition + elif mode == 'scale': + self._zoomTo = self._zoomByScale + self._scale = scaleTransform + else: + raise ValueError('Unsupported mode: %s' % mode) + + def handleEvent(self, eventName, *args, **kwargs): + if eventName == 'wheel': + return self._zoomTo(*args, **kwargs) + + def _zoomToCenter(self, x, y, angleInDegrees): + """Zoom to center of display. + + Only works with perspective camera. + """ + direction = 'forward' if angleInDegrees > 0 else 'backward' + self._viewport.camera.move(direction) + return True + + def _zoomToPositionAbsolute(self, x, y, angleInDegrees): + """Zoom while keeping pixel under mouse invariant. + + Only works with perspective camera. + """ + ndc = self._viewport.windowToNdc(x, y) + if ndc is not None: + near = numpy.array((ndc[0], ndc[1], -1., 1.), dtype=numpy.float32) + + nearscene = self._viewport.camera.transformPoint( + near, direct=False, perspectiveDivide=True) + + far = numpy.array((ndc[0], ndc[1], 1., 1.), dtype=numpy.float32) + farscene = self._viewport.camera.transformPoint( + far, direct=False, perspectiveDivide=True) + + dirscene = farscene[:3] - nearscene[:3] + dirscene /= numpy.linalg.norm(dirscene) + + if angleInDegrees < 0: + dirscene *= -1. + + # TODO which scale + self._viewport.camera.extrinsic.position += dirscene + return True + + def _zoomToPosition(self, x, y, angleInDegrees): + """Zoom while keeping pixel under mouse invariant.""" + projection = self._viewport.camera.intrinsic + extrinsic = self._viewport.camera.extrinsic + + if isinstance(projection, transform.Perspective): + # For perspective projection, move camera + ndc = self._viewport.windowToNdc(x, y) + if ndc is not None: + ndcz = self._viewport._pickNdcZGL(x, y) + + position = numpy.array((ndc[0], ndc[1], ndcz), + dtype=numpy.float32) + positionscene = self._viewport.camera.transformPoint( + position, direct=False, perspectiveDivide=True) + + camtopos = extrinsic.position - positionscene + + step = 0.2 * (1. if angleInDegrees < 0 else -1.) + extrinsic.position += step * camtopos + + elif isinstance(projection, transform.Orthographic): + # For orthographic projection, change projection borders + ndcx, ndcy = self._viewport.windowToNdc(x, y, checkInside=False) + + step = 0.2 * (1. if angleInDegrees < 0 else -1.) + + dx = (ndcx + 1) / 2. + stepwidth = step * (projection.right - projection.left) + left = projection.left - dx * stepwidth + right = projection.right + (1. - dx) * stepwidth + + dy = (ndcy + 1) / 2. + stepheight = step * (projection.top - projection.bottom) + bottom = projection.bottom - dy * stepheight + top = projection.top + (1. - dy) * stepheight + + projection.setClipping(left, right, bottom, top) + + else: + raise RuntimeError('Unsupported camera', projection) + return True + + def _zoomByScale(self, x, y, angleInDegrees): + """Zoom by scaling scene (do not keep pixel under mouse invariant).""" + scalefactor = 1.1 + if angleInDegrees < 0.: + scalefactor = 1. / scalefactor + self._scale.scale = scalefactor * self._scale.scale + + self._viewport.adjustCameraDepthExtent() + return True + + +class FocusManager(StateMachine): + """Manages focus across multiple event handlers + + On press an event handler can acquire focus. + By default it looses focus when all buttons are released. + """ + class Idle(State): + def onPress(self, x, y, btn): + 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.currentEventHandler: + consumeEvent = eventHandler.handleEvent(*args) + if consumeEvent: + break + + def onMove(self, x, y): + self._processEvent('move', x, y) + + def onRelease(self, x, y, btn): + self._processEvent('release', x, y, btn) + + def onWheel(self, x, y, angle): + self._processEvent('wheel', x, y, angle) + + class Focus(State): + def enterState(self, eventHandler, btn): + self.eventHandler = eventHandler + self.focusBtns = {btn} # Set + + enter = enterState # silx v.0.3 support, remove when 0.4 out + + def onPress(self, x, y, btn): + self.focusBtns.add(btn) + self.eventHandler.handleEvent('press', x, y, btn) + + def onMove(self, x, y): + self.eventHandler.handleEvent('move', x, y) + + def onRelease(self, x, y, btn): + self.focusBtns.discard(btn) + requestfocus = self.eventHandler.handleEvent('release', x, y, btn) + if len(self.focusBtns) == 0 and not requestfocus: + self.goto('idle') + + def onWheel(self, x, y, angleInDegrees): + self.eventHandler.handleEvent('wheel', x, y, angleInDegrees) + + def __init__(self, eventHandlers=(), ctrlEventHandlers=None): + self.defaultEventHandlers = eventHandlers + self.ctrlEventHandlers = ctrlEventHandlers + self.currentEventHandler = self.defaultEventHandlers + + states = { + 'idle': FocusManager.Idle, + 'focus': FocusManager.Focus + } + 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.currentEventHandler: + handler.cancel() + + +class RotateCameraControl(FocusManager): + """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, + selectCB=None): + handlers = (CameraWheel(viewport, mode, scaleTransform), + CameraSelectRotate( + viewport, orbitAroundCenter, LEFT_BTN, selectCB)) + 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 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)) + ctrlHandlers = (CameraWheel(viewport, mode, scaleTransform), + CameraSelectRotate( + viewport, orbitAroundCenter, LEFT_BTN, selectCB)) + super(PanCameraControl, self).__init__(handlers, ctrlHandlers) + + +class CameraControl(FocusManager): + """Combine wheel, selectPan and rotate state machine.""" + def __init__(self, viewport, + orbitAroundCenter=False, + mode='center', scaleTransform=None, + selectCB=None): + handlers = (CameraWheel(viewport, mode, scaleTransform), + CameraSelectPan(viewport, LEFT_BTN, selectCB), + CameraSelectRotate( + viewport, orbitAroundCenter, RIGHT_BTN, selectCB)) + super(CameraControl, self).__init__(handlers) + + +class PlaneRotate(ClickOrDrag): + """Plane rotation using arcball interaction. + + Arcball ref.: + Ken Shoemake. ARCBALL: A user interface for specifying three-dimensional + orientation using a mouse. In Proc. GI '92. (1992). pp. 151-156. + """ + + def __init__(self, viewport, plane, button=RIGHT_BTN): + self._viewport = viewport + self._plane = plane + self._reset() + super(PlaneRotate, self).__init__(button) + + def _reset(self): + self._beginNormal, self._beginCenter = None, None + + def click(self, x, y): + pass # No interaction + + @staticmethod + def _sphereUnitVector(radius, center, position): + """Returns the unit vector of the projection of position on a sphere. + + It assumes an orthographic projection. + For perspective projection, it gives an approximation, but it + simplifies computations and results in consistent arcball control + in control space. + + All parameters must be in screen coordinate system + (either pixels or normalized coordinates). + + :param float radius: The radius of the sphere. + :param center: (x, y) coordinates of the center. + :param position: (x, y) coordinates of the cursor position. + :return: Unit vector. + :rtype: numpy.ndarray of 3 floats. + """ + center, position = numpy.array(center), numpy.array(position) + + # Normalize x and y on a unit circle + spherecoords = (position - center) / float(radius) + squarelength = numpy.sum(spherecoords ** 2) + + # Project on the unit sphere and compute z coordinates + if squarelength > 1.0: # Outside sphere: project + spherecoords /= numpy.sqrt(squarelength) + zsphere = 0.0 + else: # In sphere: compute z + zsphere = numpy.sqrt(1. - squarelength) + + spherecoords = numpy.append(spherecoords, zsphere) + return spherecoords + + def beginDrag(self, x, y): + # Makes sure the point defining the plane is at the center as + # it will be the center of rotation (as rotation is applied to normal) + self._plane.plane.point = self._plane.center + + # Store the plane normal + self._beginNormal = self._plane.plane.normal + + _logger.debug( + 'Begin arcball, plane center %s', str(self._plane.center)) + + # Do the arcball on the screen + radius = min(self._viewport.size) + if self._plane.center is None: + self._beginCenter = None + + else: + center = self._plane.objectToNDCTransform.transformPoint( + self._plane.center, perspectiveDivide=True) + self._beginCenter = self._viewport.ndcToWindow( + center[0], center[1], checkInside=False) + + self._startVector = self._sphereUnitVector( + radius, self._beginCenter, (x, y)) + + def drag(self, x, y): + if self._beginCenter is None: + return + + # Compute rotation: this is twice the rotation of the arcball + radius = min(self._viewport.size) + currentvector = self._sphereUnitVector( + radius, self._beginCenter, (x, y)) + crossprod = numpy.cross(self._startVector, currentvector) + dotprod = numpy.dot(self._startVector, currentvector) + + quaternion = numpy.append(crossprod, dotprod) + # Rotation was computed with Y downward, but apply in NDC, invert Y + quaternion[1] *= -1. + + rotation = transform.Rotate() + rotation.quaternion = quaternion + + # Convert to NDC, rotate, convert back to object + normal = self._plane.objectToNDCTransform.transformNormal( + self._beginNormal) + normal = rotation.transformNormal(normal) + normal = self._plane.objectToNDCTransform.transformNormal( + normal, direct=False) + self._plane.plane.normal = normal + + def endDrag(self, x, y): + self._reset() + + +class PlanePan(ClickOrDrag): + """Pan a plane along its normal on drag.""" + + def __init__(self, viewport, plane, button=LEFT_BTN): + self._plane = plane + self._viewport = viewport + self._beginPlanePoint = None + self._beginPos = None + self._dragNdcZ = 0. + super(PlanePan, self).__init__(button) + + def click(self, x, y): + pass + + def beginDrag(self, x, y): + ndc = self._viewport.windowToNdc(x, y) + ndcZ = self._viewport._pickNdcZGL(x, y) + # ndcZ is the panning plane + if ndc is not None and ndcZ is not None: + ndcPos = numpy.array((ndc[0], ndc[1], ndcZ, 1.), + dtype=numpy.float32) + scenePos = self._viewport.camera.transformPoint( + ndcPos, direct=False, perspectiveDivide=True) + self._beginPos = self._plane.objectToSceneTransform.transformPoint( + scenePos, direct=False) + self._dragNdcZ = ndcZ + else: + self._beginPos = None + self._dragNdcZ = 0. + + self._beginPlanePoint = self._plane.plane.point + + def drag(self, x, y): + if self._beginPos is not None: + ndc = self._viewport.windowToNdc(x, y) + if ndc is not None: + ndcPos = numpy.array((ndc[0], ndc[1], self._dragNdcZ, 1.), + dtype=numpy.float32) + + # Convert last and current NDC positions to scene coords + scenePos = self._viewport.camera.transformPoint( + ndcPos, direct=False, perspectiveDivide=True) + curPos = self._plane.objectToSceneTransform.transformPoint( + scenePos, direct=False) + + # Get translation in scene coords + translation = curPos[:3] - self._beginPos[:3] + + newPoint = self._beginPlanePoint + translation + + # Keep plane point in bounds + bounds = self._plane.parent.bounds(dataBounds=True) + if bounds is not None: + newPoint = numpy.clip( + newPoint, a_min=bounds[0], a_max=bounds[1]) + + # Only update plane if it is in some bounds + self._plane.plane.point = newPoint + + def endDrag(self, x, y): + self._beginPlanePoint = None + + +class PlaneControl(FocusManager): + """Combine wheel, selectPan and rotate state machine for plane control.""" + def __init__(self, viewport, plane, + mode='center', scaleTransform=None): + handlers = (CameraWheel(viewport, mode, scaleTransform), + PlanePan(viewport, plane, LEFT_BTN), + PlaneRotate(viewport, plane, RIGHT_BTN)) + super(PlaneControl, self).__init__(handlers) + + +class PanPlaneRotateCameraControl(FocusManager): + """Combine wheel, pan plane and camera rotate state machine.""" + def __init__(self, viewport, plane, + mode='center', scaleTransform=None): + handlers = (CameraWheel(viewport, mode, scaleTransform), + PlanePan(viewport, plane, LEFT_BTN), + CameraSelectRotate(viewport, + orbitAroundCenter=False, + button=RIGHT_BTN)) + super(PanPlaneRotateCameraControl, self).__init__(handlers) + + +class PanPlaneZoomOnWheelControl(FocusManager): + """Combine zoom on wheel and pan plane state machines.""" + def __init__(self, viewport, plane, + mode='center', + orbitAroundCenter=False, + scaleTransform=None): + handlers = (CameraWheel(viewport, mode, scaleTransform), + PlanePan(viewport, plane, LEFT_BTN)) + ctrlHandlers = (CameraWheel(viewport, mode, scaleTransform), + CameraSelectRotate( + viewport, orbitAroundCenter, LEFT_BTN)) + super(PanPlaneZoomOnWheelControl, self).__init__(handlers, ctrlHandlers) diff --git a/src/silx/gui/plot3d/scene/primitives.py b/src/silx/gui/plot3d/scene/primitives.py new file mode 100644 index 0000000..7f35c3c --- /dev/null +++ b/src/silx/gui/plot3d/scene/primitives.py @@ -0,0 +1,2524 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2015-2021 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ + +from __future__ import absolute_import, division, unicode_literals + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "24/04/2018" + +try: + from collections import abc +except ImportError: # Python2 support + import collections as abc +import ctypes +from functools import reduce +import logging +import string + +import numpy + +from silx.gui.colors import rgba + +from ... import _glutils +from ..._glutils import gl + +from . import event +from . import core +from . import transform +from . import utils +from .function import Colormap + +_logger = logging.getLogger(__name__) + + +# Geometry #################################################################### + +class Geometry(core.Elem): + """Set of vertices with normals and colors. + + :param str mode: OpenGL drawing mode: + 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. + """ + + _ATTR_INFO = { + 'position': {'dims': (1, 2), 'lastDim': (2, 3, 4)}, + 'normal': {'dims': (1, 2), 'lastDim': (3,)}, + 'color': {'dims': (1, 2), 'lastDim': (3, 4)}, + } + + _MODE_CHECKS = { # Min, Modulo + 'lines': (2, 2), 'line_strip': (2, 0), 'loop': (2, 0), + 'points': (1, 0), + 'triangles': (3, 3), 'triangle_strip': (3, 0), 'fan': (3, 0) + } + + _MODES = { + 'lines': gl.GL_LINES, + 'line_strip': gl.GL_LINE_STRIP, + 'loop': gl.GL_LINE_LOOP, + + 'points': gl.GL_POINTS, + + 'triangles': gl.GL_TRIANGLES, + 'triangle_strip': gl.GL_TRIANGLE_STRIP, + 'fan': gl.GL_TRIANGLE_FAN + } + + _LINE_MODES = 'lines', 'line_strip', 'loop' + + _TRIANGLE_MODES = 'triangles', 'triangle_strip', 'fan' + + 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 + + # Set attributes + self._attributes = {} + for name, data in attributes.items(): + self.setAttribute(name, data, copy=copy) + + # Set indices + self._indices = None + self.setIndices(indices, copy=copy) + + # More consistency checks + mincheck, modulocheck = self._MODE_CHECKS[self._mode] + if self._indices is not None: + nbvertices = len(self._indices) + else: + nbvertices = self.nbVertices + + 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): + """Making a contiguous array, checking float types. + + :param iterable array: array-like data to prepare for attribute + :param bool copy: True to make a copy of the array, False to use as is + """ + # Convert single value (int, float, numpy types) to tuple + if not isinstance(array, abc.Iterable): + array = (array, ) + + # Makes sure it is an array + array = numpy.array(array, copy=False) + + dtype = None + 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) + + @property + def nbVertices(self): + """Returns the number of vertices of current attributes. + + It returns None if there is no attributes. + """ + for array in self._attributes.values(): + if len(array.shape) == 2: + 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. + + :param str name: The name of the attribute + :param array: Array-like attribute data or None to remove attribute + :param bool copy: True (default) to copy the data, False to use as is + """ + # This triggers associated GL resources to be garbage collected + self._vbos.pop(name, None) + + if array is None: + self._attributes.pop(name, None) + + else: + array = self._glReadyArray(array, copy=copy) + + if name not in self._ATTR_INFO: + _logger.debug('Not checking attribute %s dimensions', name) + else: + checks = self._ATTR_INFO[name] + + if (array.ndim == 1 and checks['lastDim'] == (1,) and + len(array) > 1): + array = array.reshape((len(array), 1)) + + # Checks + 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 array.ndim == 2 and nbVertices is not None: + # assert len(array) == nbVertices + + self._attributes[name] = array + if array.ndim == 2: # Store this in a VBO + self._unsyncAttributes.append(name) + + if name in self.boundsAttributeNames: # Reset bounds + self.__bounds = None + + self.notify() + + def getAttribute(self, name, copy=True): + """Returns the numpy.ndarray corresponding to the name attribute. + + :param str name: The name of the attribute to get. + :param bool copy: True to get a copy (default), + False to get internal array (DO NOT MODIFY) + :return: The corresponding array or None if no corresponding attribute. + :rtype: numpy.ndarray + """ + attr = self._attributes.get(name, None) + return None if attr is None else numpy.array(attr, copy=copy) + + def useAttribute(self, program, name=None): + """Enable and bind attribute(s) for a specific program. + + This MUST be called with OpenGL context active and after prepareGL2 + has been called. + + :param GLProgram program: The program for which to set the attributes + :param str name: The attribute name to set or None to set then all + """ + if name is None: + for name in program.attributes: + self.useAttribute(program, name) + + else: + attribute = program.attributes.get(name) + if attribute is None: + return + + vboattrib = self._vbos.get(name) + if vboattrib is not None: + gl.glEnableVertexAttribArray(attribute) + vboattrib.setVertexAttrib(attribute) + + elif name not in self._attributes: + gl.glDisableVertexAttribArray(attribute) + + else: + array = self._attributes[name] + assert array is not None + + if array.ndim == 1: + assert len(array) in (1, 2, 3, 4) + gl.glDisableVertexAttribArray(attribute) + _glVertexAttribFunc = getattr( + _glutils.gl, 'glVertexAttrib{}f'.format(len(array))) + _glVertexAttribFunc(attribute, *array) + else: + # TODO As is this is a never event, remove? + gl.glEnableVertexAttribArray(attribute) + gl.glVertexAttribPointer( + attribute, + array.shape[-1], + _glutils.numpyToGLType(array.dtype), + gl.GL_FALSE, + 0, + array) + + def setIndices(self, indices, copy=True): + """Set the primitive indices to use. + + :param indices: Array-like of uint primitive indices or None to unset + :param bool copy: True (default) to copy the data, False to use as is + """ + # Trigger garbage collection of previous indices VBO if any + self._vbos.pop('__indices__', None) + + if indices is None: + self._indices = None + else: + indices = self._glReadyArray(indices, copy=copy).ravel() + assert indices.dtype.name in ('uint8', 'uint16', 'uint32') + if _logger.getEffectiveLevel() <= logging.DEBUG: + # 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. + + :param bool copy: True to get a copy (default), + False to get internal array (DO NOT MODIFY) + :return: The primitive indices array or None if not set. + :rtype: numpy.ndarray or None + """ + if self._indices is None: + return None + 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) + + # 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 + elif len(attribute) > 0: # Array of values, compute min/max + min_ = numpy.nanmin(attribute, axis=0) + max_ = numpy.nanmax(attribute, axis=0) + else: + min_, max_ = numpy.zeros((2, attribute.shape[1]), dtype=numpy.float32) + + 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): + # TODO manage _vbo and multiple GL context + allow to share them ! + # TODO make one or multiple VBO depending on len(vertices), + # TODO use a general common VBO for small amount of data + for name in self._unsyncAttributes: + array = self._attributes[name] + self._vbos[name] = ctx.glCtx.makeVboAttrib(array) + self._unsyncAttributes = [] + + if self._indices is not None and '__indices__' not in self._vbos: + vbo = ctx.glCtx.makeVbo(self._indices, + usage=gl.GL_STATIC_DRAW, + target=gl.GL_ELEMENT_ARRAY_BUFFER) + self._vbos['__indices__'] = vbo + + def _draw(self, program=None, nbVertices=None): + """Perform OpenGL draw calls. + + :param GLProgram program: + If not None, call :meth:`useAttribute` for this program. + :param int nbVertices: + The number of vertices to render or None to render all vertices. + """ + if program is not None: + self.useAttribute(program) + + if self._indices is None: + if nbVertices is None: + nbVertices = self.nbVertices + gl.glDrawArrays(self._MODES[self._mode], 0, nbVertices) + else: + if nbVertices is None: + nbVertices = self._indices.size + with self._vbos['__indices__']: + gl.glDrawElements(self._MODES[self._mode], + nbVertices, + _glutils.numpyToGLType(self._indices.dtype), + ctypes.c_void_p(0)) + + +# Lines ####################################################################### + +class Lines(Geometry): + """A set of segments""" + _shaders = (""" + attribute vec3 position; + attribute vec3 normal; + attribute vec4 color; + + uniform mat4 matrix; + uniform mat4 transformMat; + + varying vec4 vCameraPosition; + varying vec3 vPosition; + varying vec3 vNormal; + varying vec4 vColor; + + void main(void) + { + gl_Position = matrix * vec4(position, 1.0); + vCameraPosition = transformMat * vec4(position, 1.0); + vPosition = position; + vNormal = normal; + vColor = color; + } + """, + string.Template(""" + varying vec4 vCameraPosition; + varying vec3 vPosition; + varying vec3 vNormal; + varying vec4 vColor; + + $sceneDecl + $lightingFunction + + void main(void) + { + $scenePreCall(vCameraPosition); + gl_FragColor = $lightingCall(vColor, vPosition, vNormal); + $scenePostCall(vCameraPosition); + } + """)) + + def __init__(self, positions, normals=None, colors=(1., 1., 1., 1.), + indices=None, mode='lines', width=1.): + if mode == 'strip': + mode = 'line_strip' + assert mode in self._LINE_MODES + + self._width = width + self._smooth = True + + super(Lines, self).__init__(mode, indices, + position=positions, + normal=normals, + color=colors) + + width = event.notifyProperty('_width', converter=float, + doc="Width of the line in pixels.") + + smooth = event.notifyProperty( + '_smooth', + converter=bool, + doc="Smooth line rendering enabled (bool, default: True)") + + def renderGL2(self, ctx): + # Prepare program + isnormals = 'normal' in self._attributes + if isnormals: + fraglightfunction = ctx.viewport.light.fragmentDef + else: + fraglightfunction = ctx.viewport.light.fragmentShaderFunctionNoop + + fragment = self._shaders[1].substitute( + sceneDecl=ctx.fragDecl, + scenePreCall=ctx.fragCallPre, + scenePostCall=ctx.fragCallPost, + lightingFunction=fraglightfunction, + lightingCall=ctx.viewport.light.fragmentCall) + prog = ctx.glCtx.prog(self._shaders[0], fragment) + prog.use() + + if isnormals: + ctx.viewport.light.setupProgram(ctx, prog) + + gl.glLineWidth(self.width) + + prog.setUniformMatrix('matrix', ctx.objectToNDC.matrix) + prog.setUniformMatrix('transformMat', + ctx.objectToCamera.matrix, + safe=True) + + ctx.setupProgram(prog) + + with gl.enabled(gl.GL_LINE_SMOOTH, self._smooth): + self._draw(prog) + + +class DashedLines(Lines): + """Set of dashed lines + + This MUST be defined as a set of lines (no strip or loop). + """ + + _shaders = (""" + attribute vec3 position; + attribute vec3 origin; + attribute vec3 normal; + attribute vec4 color; + + uniform mat4 matrix; + uniform mat4 transformMat; + uniform vec2 viewportSize; /* Width, height of the viewport */ + + varying vec4 vCameraPosition; + varying vec3 vPosition; + varying vec3 vNormal; + varying vec4 vColor; + varying vec2 vOriginFragCoord; + + void main(void) + { + gl_Position = matrix * vec4(position, 1.0); + vCameraPosition = transformMat * vec4(position, 1.0); + vPosition = position; + vNormal = normal; + vColor = color; + + vec4 clipOrigin = matrix * vec4(origin, 1.0); + vec4 ndcOrigin = clipOrigin / clipOrigin.w; /* Perspective divide */ + /* Convert to same frame as gl_FragCoord: lower-left, pixel center at 0.5, 0.5 */ + vOriginFragCoord = (ndcOrigin.xy + vec2(1.0, 1.0)) * 0.5 * viewportSize + vec2(0.5, 0.5); + } + """, # noqa + string.Template(""" + varying vec4 vCameraPosition; + varying vec3 vPosition; + varying vec3 vNormal; + varying vec4 vColor; + varying vec2 vOriginFragCoord; + + uniform vec2 dash; + + $sceneDecl + $lightingFunction + + void main(void) + { + $scenePreCall(vCameraPosition); + + /* Discard off dash fragments */ + float lineDist = distance(vOriginFragCoord, gl_FragCoord.xy); + if (mod(lineDist, dash.x + dash.y) > dash.x) { + discard; + } + gl_FragColor = $lightingCall(vColor, vPosition, vNormal); + + $scenePostCall(vCameraPosition); + } + """)) + + def __init__(self, positions, colors=(1., 1., 1., 1.), + indices=None, width=1.): + self._dash = 1, 0 + super(DashedLines, self).__init__(positions=positions, + colors=colors, + indices=indices, + mode='lines', + width=width) + + @property + def dash(self): + """Dash of the line as a 2-tuple of lengths in pixels: (on, off)""" + return self._dash + + @dash.setter + def dash(self, dash): + dash = float(dash[0]), float(dash[1]) + if dash != self._dash: + self._dash = dash + self.notify() + + def getPositions(self, copy=True): + """Get coordinates of lines. + + :param bool copy: True to get a copy, False otherwise + :returns: Coordinates of lines + :rtype: numpy.ndarray of float32 of shape (N, 2, Ndim) + """ + return self.getAttribute('position', copy=copy) + + def setPositions(self, positions, copy=True): + """Set line coordinates. + + :param positions: Array of line coordinates + :param bool copy: True to copy input array, False to use as is + """ + self.setAttribute('position', positions, copy=copy) + # Update line origins from given positions + origins = numpy.array(positions, copy=True, order='C') + origins[1::2] = origins[::2] + self.setAttribute('origin', origins, copy=False) + + def renderGL2(self, context): + # Prepare program + isnormals = 'normal' in self._attributes + if isnormals: + fraglightfunction = context.viewport.light.fragmentDef + else: + fraglightfunction = \ + context.viewport.light.fragmentShaderFunctionNoop + + fragment = self._shaders[1].substitute( + sceneDecl=context.fragDecl, + scenePreCall=context.fragCallPre, + scenePostCall=context.fragCallPost, + lightingFunction=fraglightfunction, + lightingCall=context.viewport.light.fragmentCall) + program = context.glCtx.prog(self._shaders[0], fragment) + program.use() + + if isnormals: + context.viewport.light.setupProgram(context, program) + + gl.glLineWidth(self.width) + + program.setUniformMatrix('matrix', context.objectToNDC.matrix) + program.setUniformMatrix('transformMat', + context.objectToCamera.matrix, + safe=True) + + gl.glUniform2f( + program.uniforms['viewportSize'], *context.viewport.size) + gl.glUniform2f(program.uniforms['dash'], *self.dash) + + context.setupProgram(program) + + self._draw(program) + + +class Box(core.PrivateGroup): + """Rectangular box""" + + _lineIndices = numpy.array(( + (0, 1), (1, 2), (2, 3), (3, 0), # Lines with z=0 + (0, 4), (1, 5), (2, 6), (3, 7), # Lines from z=0 to z=1 + (4, 5), (5, 6), (6, 7), (7, 4)), # Lines with z=1 + dtype=numpy.uint8) + + _faceIndices = numpy.array( + (0, 3, 1, 2, 5, 6, 4, 7, 7, 6, 6, 2, 7, 3, 4, 0, 5, 1), + dtype=numpy.uint8) + + _vertices = numpy.array(( + # Corners with z=0 + (0., 0., 0.), (1., 0., 0.), (1., 1., 0.), (0., 1., 0.), + # Corners with z=1 + (0., 0., 1.), (1., 0., 1.), (1., 1., 1.), (0., 1., 1.)), + dtype=numpy.float32) + + def __init__(self, stroke=(1., 1., 1., 1.), fill=(1., 1., 1., 0.)): + super(Box, self).__init__() + + self._fill = Mesh3D(self._vertices, + colors=rgba(fill), + mode='triangle_strip', + indices=self._faceIndices) + self._fill.visible = self.fillColor[-1] != 0. + + self._stroke = Lines(self._vertices, + indices=self._lineIndices, + colors=rgba(stroke), + mode='lines') + self._stroke.visible = self.strokeColor[-1] != 0. + self.strokeWidth = 1. + + self._children = [self._stroke, self._fill] + + 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): + """Size of the box (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._fill.setAttribute( + 'position', + self._vertices * numpy.array(size, dtype=numpy.float32)) + self._stroke.setAttribute( + 'position', + self._vertices * numpy.array(size, dtype=numpy.float32)) + self.notify() + + @property + def strokeSmooth(self): + """True to draw smooth stroke, False otherwise""" + return self._stroke.smooth + + @strokeSmooth.setter + def strokeSmooth(self, smooth): + smooth = bool(smooth) + if smooth != self._stroke.smooth: + self._stroke.smooth = smooth + self.notify() + + @property + def strokeWidth(self): + """Width of the stroke (float)""" + return self._stroke.width + + @strokeWidth.setter + def strokeWidth(self, width): + width = float(width) + if width != self.strokeWidth: + self._stroke.width = width + self.notify() + + @property + def strokeColor(self): + """RGBA color of the box lines (4-tuple of float in [0, 1])""" + return tuple(self._stroke.getAttribute('color', copy=False)) + + @strokeColor.setter + def strokeColor(self, color): + color = rgba(color) + if color != self.strokeColor: + self._stroke.setAttribute('color', color) + # Fully transparent = hidden + self._stroke.visible = color[-1] != 0. + self.notify() + + @property + def fillColor(self): + """RGBA color of the box faces (4-tuple of float in [0, 1])""" + return tuple(self._fill.getAttribute('color', copy=False)) + + @fillColor.setter + def fillColor(self, color): + color = rgba(color) + if color != self.fillColor: + self._fill.setAttribute('color', color) + # Fully transparent = hidden + self._fill.visible = color[-1] != 0. + self.notify() + + @property + def fillCulling(self): + return self._fill.culling + + @fillCulling.setter + def fillCulling(self, culling): + self._fill.culling = culling + + +class Axes(Lines): + """3D RGB orthogonal axes""" + _vertices = numpy.array(((0., 0., 0.), (1., 0., 0.), + (0., 0., 0.), (0., 1., 0.), + (0., 0., 0.), (0., 0., 1.)), + dtype=numpy.float32) + + _colors = numpy.array(((255, 0, 0, 255), (255, 0, 0, 255), + (0, 255, 0, 255), (0, 255, 0, 255), + (0, 0, 255, 255), (0, 0, 255, 255)), + dtype=numpy.uint8) + + def __init__(self): + 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): + """Rectangular box with RGB OX, OY, OZ axes + + :param color: RGBA color of the box + """ + + _vertices = numpy.array(( + # Axes corners + (0., 0., 0.), (1., 0., 0.), + (0., 0., 0.), (0., 1., 0.), + (0., 0., 0.), (0., 0., 1.), + # Box corners with z=0 + (1., 0., 0.), (1., 1., 0.), (0., 1., 0.), + # Box corners with z=1 + (0., 0., 1.), (1., 0., 1.), (1., 1., 1.), (0., 1., 1.)), + dtype=numpy.float32) + + _axesColors = numpy.array(((1., 0., 0., 1.), (1., 0., 0., 1.), + (0., 1., 0., 1.), (0., 1., 0., 1.), + (0., 0., 1., 1.), (0., 0., 1., 1.)), + dtype=numpy.float32) + + _lineIndices = numpy.array(( + (0, 1), (2, 3), (4, 5), # Axes lines + (6, 7), (7, 8), # Box lines with z=0 + (6, 10), (7, 11), (8, 12), # Box lines from z=0 to z=1 + (9, 10), (10, 11), (11, 12), (12, 9)), # Box lines with z=1 + dtype=numpy.uint8) + + def __init__(self, color=(1., 1., 1., 1.)): + self._color = (1., 1., 1., 1.) + colors = numpy.ones((len(self._vertices), 4), dtype=numpy.float32) + colors[:len(self._axesColors), :] = self._axesColors + + super(BoxWithAxes, self).__init__(self._vertices, + indices=self._lineIndices, + colors=colors, + width=2.) + self._size = 1., 1., 1. + self.color = color + + @property + def color(self): + """The RGBA color to use for the box: 4 float in [0, 1]""" + return self._color + + @color.setter + def color(self, color): + color = rgba(color) + if color != self._color: + self._color = color + colors = numpy.empty((len(self._vertices), 4), dtype=numpy.float32) + colors[:len(self._axesColors), :] = self._axesColors + 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. + + If plane is outside the bounds of its parent, it is not visible. + + Cannot set the transform attribute of this primitive. + This primitive never has any bounds. + """ + # TODO inherit from Lines directly?, make sure the plane remains visible? + + def __init__(self, point=(0., 0., 0.), normal=(0., 0., 1.)): + super(PlaneInGroup, self).__init__() + self._cache = None, None # Store bounds, vertices + self._outline = None + + 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) + + def moveToCenter(self): + """Place the plane at the center of the data, not changing orientation. + """ + if self.parent is not None: + bounds = self.parent.bounds(dataBounds=True) + if bounds is not None: + center = (bounds[0] + bounds[1]) / 2. + _logger.debug('Moving plane to center: %s', str(center)) + self.plane.point = center + + @property + def color(self): + """Plane outline color (array of 4 float in [0, 1]).""" + return self._color.copy() + + @color.setter + def color(self, color): + self._color = numpy.array(color, copy=True, dtype=numpy.float32) + if self._outline is not None: + self._outline.setAttribute('color', self._color) + self.notify() # This is OK as Lines are rebuild for each rendering + + @property + def width(self): + """Width of the plane stroke in pixels""" + return self._width + + @width.setter + def width(self, width): + self._width = float(width) + 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 + def plane(self): + """The plane parameters in the frame of the object.""" + return self._plane + + def _planeChanged(self, source): + """Listener of plane changes: clear cache and notify listeners.""" + self._cache = None, None + self.notify() + + # Disable some scene features + + @property + def transforms(self): + # Ready-only transforms to prevent using it + return self._transforms + + def _bounds(self, dataBounds=False): + # This is bound less as it uses the bounds of its parent. + return None + + @property + def contourVertices(self): + """The vertices of the contour of the plane/bounds intersection.""" + parent = self.parent + if parent is None: + return None # No parent: no vertices + + bounds = parent.bounds(dataBounds=True) + if bounds is None: + return None # No bounds: no vertices + + # Check if cache is valid and return it + cachebounds, cachevertices = self._cache + if numpy.all(numpy.equal(bounds, cachebounds)): + return cachevertices + + # Cache is not OK, rebuild it + 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) + + self._cache = bounds, vertices if len(vertices) != 0 else None + + return self._cache[1] + + @property + def center(self): + """The center of the plane/bounds intersection points.""" + if not self.isValid: + return None + else: + return numpy.mean(self.contourVertices, axis=0) + + @property + def isValid(self): + """True if a contour is defined, False otherwise.""" + return self.plane.isPlane and self.contourVertices is not None + + def prepareGL2(self, ctx): + if self.isValid: + if self._outline is None: # Init outline + self._outline = Lines(self.contourVertices, + 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 + self._outline.setAttribute('position', self.contourVertices) + + super(PlaneInGroup, self).prepareGL2(ctx) + + def renderGL2(self, ctx): + if self.isValid: + 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 ###################################################################### + +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; + } + } + """ + } + + _shaders = (string.Template(""" + #version 120 + + attribute float x; + attribute float y; + attribute float z; + attribute $valueType value; + attribute float size; + + uniform mat4 matrix; + uniform mat4 transformMat; + + varying vec4 vCameraPosition; + varying $valueType vValue; + varying float vSize; + + void main(void) + { + vValue = value; + + 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 $valueType vValue; + + $valueToColorDecl + $sceneDecl + $alphaSymbolDecl + + void main(void) + { + $scenePreCall(vCameraPosition); + + float alpha = alphaSymbol(gl_PointCoord, vSize); + + gl_FragColor = $valueToColorCall(vValue); + gl_FragColor.a *= alpha; + if (gl_FragColor.a == 0.0) { + discard; + } + + $scenePostCall(vCameraPosition); + } + """)) + + _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,)}, + } + + 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' + + @property + def marker(self): + """The marker symbol used to display the scatter plot (str) + + 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() + + 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): + valueType, valueToColorDecl, valueToColorCall = \ + self._shaderValueDefinition() + vertexShader = self._shaders[0].substitute( + valueType=valueType) + fragmentShader = self._shaders[1].substitute( + sceneDecl=ctx.fragDecl, + scenePreCall=ctx.fragCallPre, + scenePostCall=ctx.fragCallPost, + 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) + + program.setUniformMatrix('matrix', ctx.objectToNDC.matrix) + program.setUniformMatrix('transformMat', + ctx.objectToCamera.matrix, + safe=True) + + ctx.setupProgram(program) + + self._renderGL2PreDrawHook(ctx, program) + + self._draw(program) + + +class Points(_Points): + """A set of data points with an associated value and size.""" + + _ATTR_INFO = _Points._ATTR_INFO.copy() + _ATTR_INFO.update({'value': {'dims': (1, 2), 'lastDim': (1,)}}) + + 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) + + self._colormap = colormap or Colormap() # Default colormap + self._colormap.addListener(self._cmapChanged) + + @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 _shaderValueDefinition(self): + """Type definition, fragment shader declaration, fragment shader call + """ + return 'float', self.colormap.decl, self.colormap.call + + def _renderGL2PreDrawHook(self, ctx, program): + """Set-up colormap before calling gl draw""" + self.colormap.setupProgram(ctx, program) + + +class ColorPoints(_Points): + """A set of points with an associated color and size.""" + + _ATTR_INFO = _Points._ATTR_INFO.copy() + _ATTR_INFO.update({'value': {'dims': (1, 2), 'lastDim': (3, 4)}}) + + 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 _shaderValueDefinition(self): + """Type definition, fragment shader declaration, fragment shader call + """ + return 'vec4', '', '' + + def setColor(self, color, copy=True): + """Set colors + + :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) + + def getColor(self, copy=True): + """Returns the color or array of colors of the points. + + :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): + # GLSL 1.30 ! + """Data points on a regular grid with an associated value and size.""" + _shaders = (""" + #version 130 + + in float value; + in float size; + + uniform ivec3 gridDims; + uniform mat4 matrix; + uniform mat4 transformMat; + uniform vec2 valRange; + + out vec4 vCameraPosition; + out float vNormValue; + + //ivec3 coordsFromIndex(int index, ivec3 shape) + //{ + /*Assumes that data is stored as z-major, then y, contiguous on x + */ + // int yxPlaneSize = shape.y * shape.x; /* nb of elem in 2d yx plane */ + // int z = index / yxPlaneSize; + // int yxIndex = index - z * yxPlaneSize; /* index in 2d yx plane */ + // int y = yxIndex / shape.x; + // int x = yxIndex - y * shape.x; + // return ivec3(x, y, z); + // } + + ivec3 coordsFromIndex(int index, ivec3 shape) + { + /*Assumes that data is stored as x-major, then y, contiguous on z + */ + int yzPlaneSize = shape.y * shape.z; /* nb of elem in 2d yz plane */ + int x = index / yzPlaneSize; + int yzIndex = index - x * yzPlaneSize; /* index in 2d yz plane */ + int y = yzIndex / shape.z; + int z = yzIndex - y * shape.z; + return ivec3(x, y, z); + } + + void main(void) + { + vNormValue = clamp((value - valRange.x) / (valRange.y - valRange.x), + 0.0, 1.0); + + bool isValueInRange = value >= valRange.x && value <= valRange.y; + if (isValueInRange) { + /* Retrieve 3D position from gridIndex */ + vec3 coords = vec3(coordsFromIndex(gl_VertexID, gridDims)); + vec3 position = coords / max(vec3(gridDims) - 1.0, 1.0); + gl_Position = matrix * vec4(position, 1.0); + vCameraPosition = transformMat * vec4(position, 1.0); + } else { + gl_Position = vec4(2.0, 0.0, 0.0, 1.0); /* Get clipped */ + vCameraPosition = vec4(0.0, 0.0, 0.0, 0.0); + } + + gl_PointSize = size; + } + """, + string.Template(""" + #version 130 + + in vec4 vCameraPosition; + in float vNormValue; + out vec4 gl_FragColor; + + $sceneDecl + + void main(void) + { + $scenePreCall(vCameraPosition); + + gl_FragColor = vec4(0.5 * vNormValue + 0.5, 0.0, 0.0, 1.0); + + $scenePostCall(vCameraPosition); + } + """)) + + _ATTR_INFO = { + 'value': {'dims': (1, 2), 'lastDim': (1,)}, + 'size': {'dims': (1, 2), 'lastDim': (1,)} + } + + # TODO Add colormap, shape? + # TODO could also use a texture to store values + + def __init__(self, values=0., shape=None, sizes=1., indices=None, + minValue=None, maxValue=None): + if isinstance(values, abc.Iterable): + values = numpy.array(values, copy=False) + + # Test if gl_VertexID will overflow + assert values.size < numpy.iinfo(numpy.int32).max + + self._shape = values.shape + values = values.ravel() # 1D to add as a 1D vertex attribute + + else: + assert shape is not None + self._shape = tuple(shape) + + assert len(self._shape) in (1, 2, 3) + + super(GridPoints, self).__init__('points', indices, + value=values, + size=sizes) + + data = self.getAttribute('value', copy=False) + self._minValue = data.min() if minValue is None else minValue + self._maxValue = data.max() if maxValue is None else maxValue + + minValue = event.notifyProperty('_minValue') + maxValue = event.notifyProperty('_maxValue') + + def _bounds(self, dataBounds=False): + # Get bounds from values shape + bounds = numpy.zeros((2, 3), dtype=numpy.float32) + bounds[1, :] = self._shape + bounds[1, :] -= 1 + return bounds + + def renderGL2(self, ctx): + fragment = self._shaders[1].substitute( + sceneDecl=ctx.fragDecl, + scenePreCall=ctx.fragCallPre, + scenePostCall=ctx.fragCallPost) + prog = ctx.glCtx.prog(self._shaders[0], fragment) + prog.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.setupProgram(prog) + + gl.glUniform3i(prog.uniforms['gridDims'], + self._shape[2] if len(self._shape) == 3 else 1, + self._shape[1] if len(self._shape) >= 2 else 1, + self._shape[0]) + + gl.glUniform2f(prog.uniforms['valRange'], self.minValue, self.maxValue) + + self._draw(prog, nbVertices=reduce(lambda a, b: a * b, self._shape)) + + +# Spheres ##################################################################### + +class Spheres(Geometry): + """A set of spheres. + + Spheres are rendered as circles using points. + This brings some limitations: + - Do not support non-uniform scaling. + - Assume the projection keeps ratio. + - Do not render distorion by perspective projection. + - If the sphere center is clipped, the whole sphere is not displayed. + """ + # TODO check those links + # Accounting for perspective projection + # http://iquilezles.org/www/articles/sphereproj/sphereproj.htm + + # Michael Mara and Morgan McGuire. + # 2D Polyhedral Bounds of a Clipped, Perspective-Projected 3D Sphere + # Journal of Computer Graphics Techniques, Vol. 2, No. 2, 2013. + # http://jcgt.org/published/0002/02/05/paper.pdf + # https://research.nvidia.com/publication/2d-polyhedral-bounds-clipped-perspective-projected-3d-sphere + + # TODO some issues with small scaling and regular grid or due to sampling + + _shaders = (""" + #version 120 + + attribute vec3 position; + attribute vec4 color; + attribute float radius; + + uniform mat4 transformMat; + uniform mat4 projMat; + uniform vec2 screenSize; + + varying vec4 vCameraPosition; + varying vec3 vPosition; + varying vec4 vColor; + varying float vViewDepth; + varying float vViewRadius; + + void main(void) + { + vCameraPosition = transformMat * vec4(position, 1.0); + gl_Position = projMat * vCameraPosition; + + vPosition = gl_Position.xyz / gl_Position.w; + + /* From object space radius to view space diameter. + * Do not support non-uniform scaling */ + vec4 viewSizeVector = transformMat * vec4(2.0 * radius, 0.0, 0.0, 0.0); + float viewSize = length(viewSizeVector.xyz); + + /* Convert to pixel size at the xy center of the view space */ + vec4 projSize = projMat * vec4(0.5 * viewSize, 0.0, + vCameraPosition.z, vCameraPosition.w); + gl_PointSize = max(1.0, screenSize[0] * projSize.x / projSize.w); + + vColor = color; + vViewRadius = 0.5 * viewSize; + vViewDepth = vCameraPosition.z; + } + """, + string.Template(""" + # version 120 + + uniform mat4 projMat; + + varying vec4 vCameraPosition; + varying vec3 vPosition; + varying vec4 vColor; + varying float vViewDepth; + varying float vViewRadius; + + $sceneDecl + $lightingFunction + + void main(void) + { + $scenePreCall(vCameraPosition); + + /* Get normal from point coords */ + vec3 normal; + normal.xy = 2.0 * gl_PointCoord - vec2(1.0); + normal.y *= -1.0; /*Invert y to match NDC orientation*/ + float sqLength = dot(normal.xy, normal.xy); + if (sqLength > 1.0) { /* Length -> out of sphere */ + discard; + } + normal.z = sqrt(1.0 - sqLength); + + /*Lighting performed in NDC*/ + /*TODO update this when lighting changed*/ + //XXX vec3 position = vPosition + vViewRadius * normal; + gl_FragColor = $lightingCall(vColor, vPosition, normal); + + /*Offset depth*/ + float viewDepth = vViewDepth + vViewRadius * normal.z; + vec2 clipZW = viewDepth * projMat[2].zw + projMat[3].zw; + gl_FragDepth = 0.5 * (clipZW.x / clipZW.y) + 0.5; + + $scenePostCall(vCameraPosition); + } + """)) + + _ATTR_INFO = { + 'position': {'dims': (2, ), 'lastDim': (2, 3, 4)}, + 'radius': {'dims': (1, 2), 'lastDim': (1, )}, + 'color': {'dims': (1, 2), 'lastDim': (3, 4)}, + } + + def __init__(self, positions, radius=1., colors=(1., 1., 1., 1.)): + self.__bounds = None + super(Spheres, self).__init__('points', None, + position=positions, + radius=radius, + color=colors) + + def renderGL2(self, ctx): + fragment = self._shaders[1].substitute( + sceneDecl=ctx.fragDecl, + scenePreCall=ctx.fragCallPre, + scenePostCall=ctx.fragCallPost, + lightingFunction=ctx.viewport.light.fragmentDef, + lightingCall=ctx.viewport.light.fragmentCall) + prog = ctx.glCtx.prog(self._shaders[0], fragment) + prog.use() + + ctx.viewport.light.setupProgram(ctx, prog) + + 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('projMat', ctx.projection.matrix) + prog.setUniformMatrix('transformMat', + ctx.objectToCamera.matrix, + safe=True) + + ctx.setupProgram(prog) + + gl.glUniform2f(prog.uniforms['screenSize'], *ctx.viewport.size) + + self._draw(prog) + + def _bounds(self, dataBounds=False): + if self.__bounds is None: + self.__bounds = numpy.zeros((2, 3), dtype=numpy.float32) + # Support vertex with to 2 to 4 coordinates + positions = self._attributes['position'] + radius = self._attributes['radius'] + self.__bounds[0, :positions.shape[1]] = \ + (positions - radius).min(axis=0)[:3] + self.__bounds[1, :positions.shape[1]] = \ + (positions + radius).max(axis=0)[:3] + return self.__bounds.copy() + + +# Meshes ###################################################################### + +class Mesh3D(Geometry): + """A conventional 3D mesh""" + + _shaders = (""" + attribute vec3 position; + attribute vec3 normal; + attribute vec4 color; + + uniform mat4 matrix; + uniform mat4 transformMat; + //uniform mat3 matrixInvTranspose; + + varying vec4 vCameraPosition; + varying vec3 vPosition; + varying vec3 vNormal; + varying vec4 vColor; + + void main(void) + { + vCameraPosition = transformMat * vec4(position, 1.0); + //vNormal = matrixInvTranspose * normalize(normal); + vPosition = position; + vNormal = normal; + vColor = color; + gl_Position = matrix * vec4(position, 1.0); + } + """, + string.Template(""" + varying vec4 vCameraPosition; + varying vec3 vPosition; + varying vec3 vNormal; + varying vec4 vColor; + + $sceneDecl + $lightingFunction + + void main(void) + { + $scenePreCall(vCameraPosition); + + gl_FragColor = $lightingCall(vColor, vPosition, vNormal); + + $scenePostCall(vCameraPosition); + } + """)) + + def __init__(self, + positions, + colors, + normals=None, + mode='triangles', + indices=None, + copy=True): + assert mode in self._TRIANGLE_MODES + super(Mesh3D, self).__init__(mode, indices, + position=positions, + normal=normals, + color=colors, + copy=copy) + + self._culling = None + + @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() + + def renderGL2(self, ctx): + isnormals = 'normal' in self._attributes + if isnormals: + fragLightFunction = ctx.viewport.light.fragmentDef + else: + fragLightFunction = ctx.viewport.light.fragmentShaderFunctionNoop + + fragment = self._shaders[1].substitute( + sceneDecl=ctx.fragDecl, + scenePreCall=ctx.fragCallPre, + scenePostCall=ctx.fragCallPost, + lightingFunction=fragLightFunction, + lightingCall=ctx.viewport.light.fragmentCall) + prog = ctx.glCtx.prog(self._shaders[0], fragment) + prog.use() + + if isnormals: + ctx.viewport.light.setupProgram(ctx, prog) + + 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) + + prog.setUniformMatrix('matrix', ctx.objectToNDC.matrix) + prog.setUniformMatrix('transformMat', + ctx.objectToCamera.matrix, + safe=True) + + ctx.setupProgram(prog) + + self._draw(prog) + + if self.culling is not None: + 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(""" + uniform float alpha; + + varying vec4 vCameraPosition; + varying vec3 vPosition; + varying vec3 vNormal; + varying float vValue; + + $colormapDecl + $sceneDecl + $lightingFunction + + void main(void) + { + $scenePreCall(vCameraPosition); + + vec4 color = $colormapCall(vValue); + gl_FragColor = $lightingCall(color, vPosition, vNormal); + gl_FragColor.a *= alpha; + + $scenePostCall(vCameraPosition); + } + """)) + + def __init__(self, + position, + value, + colormap=None, + normal=None, + mode='triangles', + indices=None, + copy=True): + super(ColormapMesh3D, self).__init__(mode, indices, + position=position, + normal=normal, + value=value, + copy=copy) + + self._alpha = 1.0 + 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)") + + alpha = event.notifyProperty( + '_alpha', converter=float, + doc="Transparency of the mesh, float in [0, 1]") + + @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( + sceneDecl=ctx.fragDecl, + scenePreCall=ctx.fragCallPre, + scenePostCall=ctx.fragCallPost, + 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.setupProgram(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) + gl.glUniform1f(program.uniforms['alpha'], self._alpha) + + 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 + $sceneDecl + $lightingFunction + + void main(void) + { + $scenePreCall(vCameraPosition); + + vec4 color = imageColor(data, vTexCoords); + color.a *= alpha; + if (color.a == 0.) { /* Discard fully transparent pixels */ + discard; + } + + vec3 normal = vec3(0.0, 0.0, 1.0); + gl_FragColor = $lightingCall(color, vPosition, normal); + + $scenePostCall(vCameraPosition); + } + """)) + + _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[1], self._data.shape[0])) + 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( + sceneDecl=ctx.fragDecl, + scenePreCall=ctx.fragCallPre, + scenePostCall=ctx.fragCallPost, + 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[1], 1./shape[0]) + + gl.glUniform1i(program.uniforms['data'], self._texture.texUnit) + + ctx.setupProgram(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? +# group composition? + +class GroupDepthOffset(core.Group): + """A group using 2-pass rendering and glDepthRange to avoid Z-fighting""" + + def __init__(self, children=(), epsilon=None): + super(GroupDepthOffset, self).__init__(children) + self._epsilon = epsilon + self.isDepthRangeOn = True + + def prepareGL2(self, ctx): + if self._epsilon is None: + depthbits = gl.glGetInteger(gl.GL_DEPTH_BITS) + self._epsilon = 1. / (1 << (depthbits - 1)) + + def renderGL2(self, ctx): + if self.isDepthRangeOn: + self._renderGL2WithDepthRange(ctx) + else: + super(GroupDepthOffset, self).renderGL2(ctx) + + def _renderGL2WithDepthRange(self, ctx): + # gl.glDepthFunc(gl.GL_LESS) + with gl.enabled(gl.GL_CULL_FACE): + gl.glCullFace(gl.GL_BACK) + for child in self.children: + gl.glColorMask( + gl.GL_FALSE, gl.GL_FALSE, gl.GL_FALSE, gl.GL_FALSE) + gl.glDepthMask(gl.GL_TRUE) + gl.glDepthRange(self._epsilon, 1.) + + child.render(ctx) + + gl.glColorMask( + gl.GL_TRUE, gl.GL_TRUE, gl.GL_TRUE, gl.GL_TRUE) + gl.glDepthMask(gl.GL_FALSE) + gl.glDepthRange(0., 1. - self._epsilon) + + child.render(ctx) + + gl.glCullFace(gl.GL_FRONT) + for child in reversed(self.children): + gl.glColorMask( + gl.GL_FALSE, gl.GL_FALSE, gl.GL_FALSE, gl.GL_FALSE) + gl.glDepthMask(gl.GL_TRUE) + gl.glDepthRange(self._epsilon, 1.) + + child.render(ctx) + + gl.glColorMask( + gl.GL_TRUE, gl.GL_TRUE, gl.GL_TRUE, gl.GL_TRUE) + gl.glDepthMask(gl.GL_FALSE) + gl.glDepthRange(0., 1. - self._epsilon) + + child.render(ctx) + + gl.glDepthMask(gl.GL_TRUE) + gl.glDepthRange(0., 1.) + # gl.glDepthFunc(gl.GL_LEQUAL) + # TODO use epsilon for all rendering? + # 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.""" + + def __init__(self, children=(), color=(1., 1., 1., 1.)): + super(GroupBBox, self).__init__() + self._group = core.Group(children) + + 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._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] + size = bounds[1] - bounds[0] + else: + origin, size = (0., 0., 0.), (1., 1., 1.) + + self._boxTransforms[0].translation = origin + + self._boxWithAxes.size = size + self._box.size = size + self._axes.size = size + + def _bounds(self, dataBounds=False): + self._updateBoxAndAxes() + return super(GroupBBox, self)._bounds(dataBounds) + + def prepareGL2(self, ctx): + self._updateBoxAndAxes() + super(GroupBBox, self).prepareGL2(ctx) + + # Give access to _group children + + @property + def children(self): + return self._group.children + + @children.setter + def children(self, iterable): + self._group.children = iterable + + # 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._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 ############################################################## + +class ClipPlane(PlaneInGroup): + """A clipping plane attached to a box""" + + def renderGL2(self, ctx): + super(ClipPlane, self).renderGL2(ctx) + + if self.visible: + # Set-up clipping plane for following brothers + + # No need of perspective divide, no projection + point = ctx.objectToCamera.transformPoint(self.plane.point, + perspectiveDivide=False) + normal = ctx.objectToCamera.transformNormal(self.plane.normal) + ctx.setClipPlane(point, normal) + + def postRender(self, ctx): + if self.visible: + # Disable clip planes + ctx.setClipPlane() diff --git a/src/silx/gui/plot3d/scene/test/__init__.py b/src/silx/gui/plot3d/scene/test/__init__.py new file mode 100644 index 0000000..3bb978e --- /dev/null +++ b/src/silx/gui/plot3d/scene/test/__init__.py @@ -0,0 +1,24 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2015-2017 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ diff --git a/src/silx/gui/plot3d/scene/test/test_transform.py b/src/silx/gui/plot3d/scene/test/test_transform.py new file mode 100644 index 0000000..69e991b --- /dev/null +++ b/src/silx/gui/plot3d/scene/test/test_transform.py @@ -0,0 +1,80 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2015-2017 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ + +from __future__ import absolute_import, division, unicode_literals + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "05/01/2017" + + +import numpy +import unittest + +from silx.gui.plot3d.scene import transform + + +class TestTransformList(unittest.TestCase): + + def assertSameArrays(self, a, b): + return self.assertTrue(numpy.allclose(a, b, atol=1e-06)) + + def testTransformList(self): + """Minimalistic test of TransformList""" + transforms = transform.TransformList() + refmatrix = numpy.identity(4, dtype=numpy.float32) + self.assertSameArrays(refmatrix, transforms.matrix) + + # Append translate + transforms.append(transform.Translate(1., 1., 1.)) + refmatrix = numpy.array(((1., 0., 0., 1.), + (0., 1., 0., 1.), + (0., 0., 1., 1.), + (0., 0., 0., 1.)), dtype=numpy.float32) + self.assertSameArrays(refmatrix, transforms.matrix) + + # Extend scale + transforms.extend([transform.Scale(0.1, 2., 1.)]) + refmatrix = numpy.dot(refmatrix, + numpy.array(((0.1, 0., 0., 0.), + (0., 2., 0., 0.), + (0., 0., 1., 0.), + (0., 0., 0., 1.)), + dtype=numpy.float32)) + self.assertSameArrays(refmatrix, transforms.matrix) + + # Insert rotate + transforms.insert(0, transform.Rotate(360.)) + self.assertSameArrays(refmatrix, transforms.matrix) + + # Update translate and check for listener called + self._callCount = 0 + + def listener(source): + self._callCount += 1 + transforms.addListener(listener) + + transforms[1].tx += 1 + self.assertEqual(self._callCount, 1) diff --git a/src/silx/gui/plot3d/scene/test/test_utils.py b/src/silx/gui/plot3d/scene/test/test_utils.py new file mode 100644 index 0000000..65d0ce0 --- /dev/null +++ b/src/silx/gui/plot3d/scene/test/test_utils.py @@ -0,0 +1,258 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2015-2017 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ + +from __future__ import absolute_import, division, unicode_literals + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "17/01/2018" + + +import unittest +from silx.utils.testutils import ParametricTestCase + +import numpy + +from silx.gui.plot3d.scene import utils + + +# angleBetweenVectors ######################################################### + +class TestAngleBetweenVectors(ParametricTestCase): + + TESTS = { # name: (refvector, vectors, norm, refangles) + 'single vector': + ((1., 0., 0.), (1., 0., 0.), (0., 0., 1.), 0.), + 'single vector, no norm': + ((1., 0., 0.), (1., 0., 0.), None, 0.), + + 'with orthogonal norm': + ((1., 0., 0.), + ((1., 0., 0.), (0., 1., 0.), (-1., 0., 0.), (0., -1., 0.)), + (0., 0., 1.), + (0., 90., 180., 270.)), + + 'with coplanar norm': # = similar to no norm + ((1., 0., 0.), + ((1., 0., 0.), (0., 1., 0.), (-1., 0., 0.), (0., -1., 0.)), + (1., 0., 0.), + (0., 90., 180., 90.)), + + 'without norm': + ((1., 0., 0.), + ((1., 0., 0.), (0., 1., 0.), (-1., 0., 0.), (0., -1., 0.)), + None, + (0., 90., 180., 90.)), + + 'not unit vectors': + ((2., 2., 0.), ((1., 1., 0.), (1., -1., 0.)), None, (0., 90.)), + } + + def testAngleBetweenVectorsFunction(self): + for name, params in self.TESTS.items(): + refvector, vectors, norm, refangles = params + with self.subTest(name): + refangles = numpy.radians(refangles) + + refvector = numpy.array(refvector) + vectors = numpy.array(vectors) + if norm is not None: + norm = numpy.array(norm) + + testangles = utils.angleBetweenVectors( + refvector, vectors, norm) + + self.assertTrue( + numpy.allclose(testangles, refangles, atol=1e-5)) + + +# Plane ####################################################################### + +class AssertNotificationContext(object): + """Context that checks if an event.Notifier is sending events.""" + + def __init__(self, notifier, count=1): + """Initializer. + + :param event.Notifier notifier: The notifier to test. + :param int count: The expected number of calls. + """ + self._notifier = notifier + self._callCount = None + self._count = count + + def __enter__(self): + self._callCount = 0 + self._notifier.addListener(self._callback) + + def __exit__(self, exc_type, exc_value, traceback): + # Do not return True so exceptions are propagated + self._notifier.removeListener(self._callback) + assert self._callCount == self._count + self._callCount = None + + def _callback(self, *args, **kwargs): + self._callCount += 1 + + +class TestPlaneParameters(ParametricTestCase): + """Test Plane.parameters read/write and notifications.""" + + PARAMETERS = { + 'unit normal': (1., 0., 0., 1.), + 'not unit normal': (1., 1., 0., 1.), + 'd = 0': (1., 0., 0., 0.) + } + + def testParameters(self): + """Check parameters read/write and notification.""" + plane = utils.Plane() + + for name, parameters in self.PARAMETERS.items(): + with self.subTest(name, parameters=parameters): + with AssertNotificationContext(plane): + plane.parameters = parameters + + # Plane parameters are converted to have a unit normal + normparams = parameters / numpy.linalg.norm(parameters[:3]) + self.assertTrue(numpy.allclose(plane.parameters, normparams)) + + ZEROS_PARAMETERS = ( + (0., 0., 0., 0.), + (0., 0., 0., 1.) + ) + + ZEROS = 0., 0., 0., 0. + + def testParametersNoPlane(self): + """Test Plane.parameters with ||normal|| == 0 .""" + plane = utils.Plane() + plane.parameters = self.ZEROS + + for parameters in self.ZEROS_PARAMETERS: + with self.subTest(parameters=parameters): + with AssertNotificationContext(plane, count=0): + plane.parameters = parameters + self.assertTrue( + numpy.allclose(plane.parameters, self.ZEROS, 0., 0.)) + + +# unindexArrays ############################################################### + +class TestUnindexArrays(ParametricTestCase): + """Test unindexArrays function.""" + + def testBasicModes(self): + """Test for modes: points, lines and triangles""" + indices = numpy.array((1, 2, 0)) + arrays = (numpy.array((0., 1., 2.)), + numpy.array(((0, 0), (1, 1), (2, 2)))) + refresults = (numpy.array((1., 2., 0.)), + numpy.array(((1, 1), (2, 2), (0, 0)))) + + for mode in ('points', 'lines', 'triangles'): + with self.subTest(mode=mode): + testresults = utils.unindexArrays(mode, indices, *arrays) + for ref, test in zip(refresults, testresults): + self.assertTrue(numpy.equal(ref, test).all()) + + def testPackedLines(self): + """Test for modes: line_strip, loop""" + indices = numpy.array((1, 2, 0)) + arrays = (numpy.array((0., 1., 2.)), + numpy.array(((0, 0), (1, 1), (2, 2)))) + results = { + 'line_strip': ( + numpy.array((1., 2., 2., 0.)), + numpy.array(((1, 1), (2, 2), (2, 2), (0, 0)))), + 'loop': ( + numpy.array((1., 2., 2., 0., 0., 1.)), + numpy.array(((1, 1), (2, 2), (2, 2), (0, 0), (0, 0), (1, 1)))), + } + + for mode, refresults in results.items(): + with self.subTest(mode=mode): + testresults = utils.unindexArrays(mode, indices, *arrays) + for ref, test in zip(refresults, testresults): + self.assertTrue(numpy.equal(ref, test).all()) + + def testPackedTriangles(self): + """Test for modes: triangle_strip, fan""" + indices = numpy.array((1, 2, 0, 3)) + arrays = (numpy.array((0., 1., 2., 3.)), + numpy.array(((0, 0), (1, 1), (2, 2), (3, 3)))) + results = { + 'triangle_strip': ( + numpy.array((1., 2., 0., 2., 0., 3.)), + numpy.array(((1, 1), (2, 2), (0, 0), (2, 2), (0, 0), (3, 3)))), + 'fan': ( + numpy.array((1., 2., 0., 1., 0., 3.)), + numpy.array(((1, 1), (2, 2), (0, 0), (1, 1), (0, 0), (3, 3)))), + } + + for mode, refresults in results.items(): + with self.subTest(mode=mode): + testresults = utils.unindexArrays(mode, indices, *arrays) + for ref, test in zip(refresults, testresults): + self.assertTrue(numpy.equal(ref, test).all()) + + def testBadIndices(self): + """Test with negative indices and indices higher than array length""" + arrays = numpy.array((0, 1)), numpy.array((0, 1, 2)) + + # negative indices + with self.assertRaises(AssertionError): + utils.unindexArrays('points', (-1, 0), *arrays) + + # Too high indices + with self.assertRaises(AssertionError): + utils.unindexArrays('points', (0, 10), *arrays) + + +# triangleNormals ############################################################# + +class TestTriangleNormals(ParametricTestCase): + """Test triangleNormals function.""" + + def test(self): + """Test for modes: points, lines and triangles""" + positions = numpy.array( + ((0., 0., 0.), (1., 0., 0.), (0., 1., 0.), # normal = Z + (1., 1., 1.), (1., 2., 3.), (4., 5., 6.), # Random triangle + # Degenerated triangles: + (0., 0., 0.), (1., 0., 0.), (2., 0., 0.), # Colinear points + (1., 1., 1.), (1., 1., 1.), (1., 1., 1.), # All same point + ), + dtype='float32') + + normals = numpy.array( + ((0., 0., 1.), + (-0.40824829, 0.81649658, -0.40824829), + (0., 0., 0.), + (0., 0., 0.)), + dtype='float32') + + testnormals = utils.trianglesNormal(positions) + self.assertTrue(numpy.allclose(testnormals, normals)) diff --git a/src/silx/gui/plot3d/scene/text.py b/src/silx/gui/plot3d/scene/text.py new file mode 100644 index 0000000..bacc2e6 --- /dev/null +++ b/src/silx/gui/plot3d/scene/text.py @@ -0,0 +1,535 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2016-2020 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. +# +# ###########################################################################*/ +"""Primitive displaying a text field in the scene.""" + +from __future__ import absolute_import, division, unicode_literals + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "24/04/2018" + + +import logging +import numpy + +from silx.gui.colors import rgba + +from ... import _glutils +from ..._glutils import gl + +from ..._glutils import font as _font +from ...plot._utils import ticklayout + +from . import event, primitives, core, transform + + +_logger = logging.getLogger(__name__) + + +class Font(event.Notifier): + """Description of a font. + + :param str name: Family of the font + :param int size: Size of the font in points + :param int weight: Font weight + :param bool italic: True for italic font, False (default) otherwise + """ + + def __init__(self, name=None, size=-1, weight=-1, italic=False): + self._name = name if name is not None else _font.getDefaultFontFamily() + self._size = size + self._weight = weight + self._italic = italic + super(Font, self).__init__() + + name = event.notifyProperty( + '_name', + doc="""Name of the font (str)""", + converter=str) + + size = event.notifyProperty( + '_size', + doc="""Font size in points (int)""", + converter=int) + + weight = event.notifyProperty( + '_weight', + doc="""Font size in points (int)""", + converter=int) + + italic = event.notifyProperty( + '_italic', + doc="""True for italic (bool)""", + converter=bool) + + +class Text2D(primitives.Geometry): + """Text field as a 2D texture displayed with bill-boarding + + :param str text: Text to display + :param Font font: The font to use + """ + + # Text anchor values + CENTER = 'center' + + LEFT = 'left' + RIGHT = 'right' + + TOP = 'top' + BASELINE = 'baseline' + BOTTOM = 'bottom' + + _ALIGN = LEFT, CENTER, RIGHT + _VALIGN = TOP, BASELINE, CENTER, BOTTOM + + _rasterTextCache = {} + """Internal cache storing already rasterized text""" + # TODO limit cache size and discard least recent used + + def __init__(self, text='', font=None): + self._dirtyTexture = True + self._dirtyAlign = True + self._baselineOffset = 0 + self._text = text + self._font = font if font is not None else Font() + self._foreground = 1., 1., 1., 1. + self._background = 0., 0., 0., 0. + self._overlay = False + self._align = 'left' + self._valign = 'baseline' + self._devicePixelRatio = 1.0 # Store it to check for changes + + self._texture = None + self._textureDirty = True + + super(Text2D, self).__init__( + 'triangle_strip', + copy=False, + # Keep an array for position as it is bound to attr 0 and MUST + # be active and an array at least on Mac OS X + position=numpy.zeros((4, 3), dtype=numpy.float32), + vertexID=numpy.arange(4., dtype=numpy.float32).reshape(4, 1), + offsetInViewportCoords=(0., 0.)) + + @property + def text(self): + """Text displayed by this primitive (str)""" + return self._text + + @text.setter + def text(self, text): + text = str(text) + if text != self._text: + self._dirtyTexture = True + self._text = text + self.notify() + + @property + def font(self): + """Font to use to raster text (Font)""" + return self._font + + @font.setter + def font(self, font): + self._font.removeListener(self._fontChanged) + self._font = font + self._font.addListener(self._fontChanged) + self._fontChanged(self) # Which calls notify and primitive as dirty + + def _fontChanged(self, source): + """Listen for font change""" + self._dirtyTexture = True + self.notify() + + foreground = event.notifyProperty( + '_foreground', doc="""RGBA color of the text: 4 float in [0, 1]""", + converter=rgba) + + background = event.notifyProperty( + '_background', + doc="RGBA background color of the text field: 4 float in [0, 1]", + converter=rgba) + + overlay = event.notifyProperty( + '_overlay', + doc="True to always display text on top of the scene (default: False)", + converter=bool) + + def _setAlign(self, align): + assert align in self._ALIGN + self._align = align + self._dirtyAlign = True + self.notify() + + align = property( + lambda self: self._align, + _setAlign, + doc="""Horizontal anchor position of the text field (str). + + Either 'left' (default), 'center' or 'right'.""") + + def _setVAlign(self, valign): + assert valign in self._VALIGN + self._valign = valign + self._dirtyAlign = True + self.notify() + + valign = property( + lambda self: self._valign, + _setVAlign, + doc="""Vertical anchor position of the text field (str). + + Either 'top', 'baseline' (default), 'center' or 'bottom'""") + + def _raster(self, devicePixelRatio): + """Raster current primitive to a bitmap + + :param float devicePixelRatio: + The ratio between device and device-independent pixels + :return: Corresponding image in grayscale and baseline offset from top + :rtype: (HxW numpy.ndarray of uint8, int) + """ + params = (self.text, + self.font.name, + self.font.size, + self.font.weight, + self.font.italic, + devicePixelRatio) + + if params not in self._rasterTextCache: # Add to cache + self._rasterTextCache[params] = _font.rasterText(*params) + + array, offset = self._rasterTextCache[params] + return array.copy(), offset + + def _bounds(self, dataBounds=False): + return None + + def prepareGL2(self, context): + # Check if devicePixelRatio has changed since last rendering + devicePixelRatio = context.glCtx.devicePixelRatio + if self._devicePixelRatio != devicePixelRatio: + self._devicePixelRatio = devicePixelRatio + self._dirtyTexture = True + + if self._dirtyTexture: + self._dirtyTexture = False + + if self._texture is not None: + self._texture.discard() + self._texture = None + self._baselineOffset = 0 + + if self.text: + image, self._baselineOffset = self._raster( + self._devicePixelRatio) + self._texture = _glutils.Texture( + gl.GL_R8, image, gl.GL_RED, + minFilter=gl.GL_NEAREST, + magFilter=gl.GL_NEAREST, + wrap=gl.GL_CLAMP_TO_EDGE) + self._texture.prepare() + self._dirtyAlign = True # To force update of offset + + if self._dirtyAlign: + self._dirtyAlign = False + + if self._texture is not None: + height, width = self._texture.shape + + if self._align == 'left': + ox = 0. + elif self._align == 'center': + ox = - width // 2 + elif self._align == 'right': + ox = - width + else: + _logger.error("Unsupported align: %s", self._align) + ox = 0. + + if self._valign == 'top': + oy = 0. + elif self._valign == 'baseline': + oy = self._baselineOffset + elif self._valign == 'center': + oy = height // 2 + elif self._valign == 'bottom': + oy = height + else: + _logger.error("Unsupported valign: %s", self._valign) + oy = 0. + + offsets = (ox, oy) + numpy.array( + ((0., 0.), (width, 0.), (0., -height), (width, -height)), + dtype=numpy.float32) + self.setAttribute('offsetInViewportCoords', offsets) + + super(Text2D, self).prepareGL2(context) + + def renderGL2(self, context): + if not self.text: + return # Nothing to render + + program = context.glCtx.prog(*self._shaders) + program.use() + + program.setUniformMatrix('matrix', context.objectToNDC.matrix) + gl.glUniform2f( + program.uniforms['viewportSize'], *context.viewport.size) + gl.glUniform4f(program.uniforms['foreground'], *self.foreground) + gl.glUniform4f(program.uniforms['background'], *self.background) + gl.glUniform1i(program.uniforms['texture'], self._texture.texUnit) + gl.glUniform1i(program.uniforms['isOverlay'], + 1 if self._overlay else 0) + + self._texture.bind() + + if not self._overlay or not gl.glGetBoolean(gl.GL_DEPTH_TEST): + self._draw(program) + else: # overlay and depth test currently enabled + gl.glDisable(gl.GL_DEPTH_TEST) + self._draw(program) + gl.glEnable(gl.GL_DEPTH_TEST) + + # TODO texture atlas + viewportSize as attribute to chain text rendering + + _shaders = ( + """ + attribute vec3 position; + attribute vec2 offsetInViewportCoords; /* Offset in pixels (y upward) */ + attribute float vertexID; /* Index of rectangle corner */ + + uniform mat4 matrix; + uniform vec2 viewportSize; /* Width, height of the viewport */ + uniform int isOverlay; + + varying vec2 texCoords; + + void main(void) + { + vec4 clipPos = matrix * vec4(position, 1.0); + vec4 ndcPos = clipPos / clipPos.w; /* Perspective divide */ + + /* Align ndcPos with pixels in viewport-like coords (origin useless) */ + vec2 viewportPos = floor((ndcPos.xy + vec2(1.0, 1.0)) * 0.5 * viewportSize); + + /* Apply offset in viewport coords */ + viewportPos += offsetInViewportCoords; + + /* Convert back to NDC */ + vec2 pointPos = 2.0 * viewportPos / viewportSize - vec2(1.0, 1.0); + float z = (isOverlay != 0) ? -1.0 : ndcPos.z; + gl_Position = vec4(pointPos, z, 1.0); + + /* Index : texCoords: + * 0: (0., 0.) + * 1: (1., 0.) + * 2: (0., 1.) + * 3: (1., 1.) + */ + texCoords = vec2(vertexID == 0.0 || vertexID == 2.0 ? 0.0 : 1.0, + vertexID < 1.5 ? 0.0 : 1.0); + } + """, # noqa + + """ + varying vec2 texCoords; + + uniform vec4 foreground; + uniform vec4 background; + uniform sampler2D texture; + + void main(void) + { + float value = texture2D(texture, texCoords).r; + + if (background.a != 0.0) { + gl_FragColor = mix(background, foreground, value); + } else { + gl_FragColor = foreground; + gl_FragColor.a *= value; + if (gl_FragColor.a <= 0.01) { + discard; + } + } + } + """) + + +class LabelledAxes(primitives.GroupBBox): + """A group displaying a bounding box with axes labels around its children. + """ + + def __init__(self): + super(LabelledAxes, self).__init__() + self._ticksForBounds = None + + self._font = Font() + + # TODO offset labels from anchor in pixels + + self._xlabel = Text2D(font=self._font) + self._xlabel.align = 'center' + self._xlabel.transforms = [self._boxTransforms, + transform.Translate(tx=0.5)] + self._children.append(self._xlabel) + + self._ylabel = Text2D(font=self._font) + self._ylabel.align = 'center' + self._ylabel.transforms = [self._boxTransforms, + transform.Translate(ty=0.5)] + self._children.append(self._ylabel) + + self._zlabel = Text2D(font=self._font) + self._zlabel.align = 'center' + self._zlabel.transforms = [self._boxTransforms, + transform.Translate(tz=0.5)] + self._children.append(self._zlabel) + + self._tickLines = primitives.Lines( # Init tick lines with dummy pos + positions=((0., 0., 0.), (0., 0., 0.)), + mode='lines') + self._tickLines.visible = False + self._children.append(self._tickLines) + + self._tickLabels = core.Group() + self._children.append(self._tickLabels) + + @property + def font(self): + """Font of axes text labels (Font)""" + return self._font + + @font.setter + def font(self, font): + self._font = font + self._xlabel.font = font + self._ylabel.font = font + self._zlabel.font = font + for label in self._tickLabels.children: + label.font = font + + @property + def xlabel(self): + """Text label of the X axis (str)""" + return self._xlabel.text + + @xlabel.setter + def xlabel(self, text): + self._xlabel.text = text + + @property + def ylabel(self): + """Text label of the Y axis (str)""" + return self._ylabel.text + + @ylabel.setter + def ylabel(self, text): + self._ylabel.text = text + + @property + def zlabel(self): + """Text label of the Z axis (str)""" + return self._zlabel.text + + @zlabel.setter + def zlabel(self, text): + self._zlabel.text = text + + def _updateTicks(self): + """Check if ticks need update and update them if needed.""" + bounds = self._group.bounds(transformed=False, dataBounds=True) + if bounds is None: # No content + if self._ticksForBounds is not None: + self._ticksForBounds = None + self._tickLines.visible = False + self._tickLabels.children = [] # Reset previous labels + + elif (self._ticksForBounds is None or + not numpy.all(numpy.equal(bounds, self._ticksForBounds))): + self._ticksForBounds = bounds + + # Update ticks + # TODO make ticks having a constant length on the screen + ticklength = numpy.abs(bounds[1] - bounds[0]) / 20. + + xticks, xlabels = ticklayout.ticks(*bounds[:, 0]) + yticks, ylabels = ticklayout.ticks(*bounds[:, 1]) + zticks, zlabels = ticklayout.ticks(*bounds[:, 2]) + + # Update tick lines + coords = numpy.empty( + ((len(xticks) + len(yticks) + len(zticks)), 4, 3), + dtype=numpy.float32) + coords[:, :, :] = bounds[0, :] # account for offset from origin + + xcoords = coords[:len(xticks)] + xcoords[:, :, 0] = numpy.asarray(xticks)[:, numpy.newaxis] + xcoords[:, 1, 1] += ticklength[1] # X ticks on XY plane + xcoords[:, 3, 2] += ticklength[2] # X ticks on XZ plane + + ycoords = coords[len(xticks):len(xticks) + len(yticks)] + ycoords[:, :, 1] = numpy.asarray(yticks)[:, numpy.newaxis] + ycoords[:, 1, 0] += ticklength[0] # Y ticks on XY plane + ycoords[:, 3, 2] += ticklength[2] # Y ticks on YZ plane + + zcoords = coords[len(xticks) + len(yticks):] + zcoords[:, :, 2] = numpy.asarray(zticks)[:, numpy.newaxis] + zcoords[:, 1, 0] += ticklength[0] # Z ticks on XZ plane + zcoords[:, 3, 1] += ticklength[1] # Z ticks on YZ plane + + self._tickLines.setAttribute('position', coords.reshape(-1, 3)) + self._tickLines.visible = True + + # Update labels + offsets = bounds[0] - ticklength + labels = [] + for tick, label in zip(xticks, xlabels): + text = Text2D(text=label, font=self.font) + text.align = 'center' + text.transforms = [transform.Translate( + tx=tick, ty=offsets[1], tz=offsets[2])] + labels.append(text) + + for tick, label in zip(yticks, ylabels): + text = Text2D(text=label, font=self.font) + text.align = 'center' + text.transforms = [transform.Translate( + tx=offsets[0], ty=tick, tz=offsets[2])] + labels.append(text) + + for tick, label in zip(zticks, zlabels): + text = Text2D(text=label, font=self.font) + text.align = 'center' + text.transforms = [transform.Translate( + tx=offsets[0], ty=offsets[1], tz=tick)] + labels.append(text) + + self._tickLabels.children = labels # Reset previous labels + + def prepareGL2(self, context): + self._updateTicks() + super(LabelledAxes, self).prepareGL2(context) diff --git a/src/silx/gui/plot3d/scene/transform.py b/src/silx/gui/plot3d/scene/transform.py new file mode 100644 index 0000000..43b739b --- /dev/null +++ b/src/silx/gui/plot3d/scene/transform.py @@ -0,0 +1,1027 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2015-2020 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 4x4 matrix operation and classes to handle them.""" + +from __future__ import absolute_import, division, unicode_literals + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "25/07/2016" + + +import itertools +import numpy + +from . import event + + +# Functions ################################################################### + +# Projections + +def mat4LookAtDir(position, direction, up): + """Creates matrix to look in direction from position. + + :param position: Array-like 3 coordinates of the point of view position. + :param direction: Array-like 3 coordinates of the sight direction vector. + :param up: Array-like 3 coordinates of the upward direction + in the image plane. + :returns: Corresponding matrix. + :rtype: numpy.ndarray of shape (4, 4) + """ + assert len(position) == 3 + assert len(direction) == 3 + assert len(up) == 3 + + direction = numpy.array(direction, copy=True, dtype=numpy.float32) + dirnorm = numpy.linalg.norm(direction) + assert dirnorm != 0. + direction /= dirnorm + + side = numpy.cross(direction, + numpy.array(up, copy=False, dtype=numpy.float32)) + sidenorm = numpy.linalg.norm(side) + assert sidenorm != 0. + up = numpy.cross(side / sidenorm, direction) + upnorm = numpy.linalg.norm(up) + assert upnorm != 0. + up /= upnorm + + matrix = numpy.identity(4, dtype=numpy.float32) + matrix[0, :3] = side + matrix[1, :3] = up + matrix[2, :3] = -direction + return numpy.dot(matrix, + mat4Translate(-position[0], -position[1], -position[2])) + + +def mat4LookAt(position, center, up): + """Creates matrix to look at center from position. + + See gluLookAt. + + :param position: Array-like 3 coordinates of the point of view position. + :param center: Array-like 3 coordinates of the center of the scene. + :param up: Array-like 3 coordinates of the upward direction + in the image plane. + :returns: Corresponding matrix. + :rtype: numpy.ndarray of shape (4, 4) + """ + position = numpy.array(position, copy=False, dtype=numpy.float32) + center = numpy.array(center, copy=False, dtype=numpy.float32) + direction = center - position + return mat4LookAtDir(position, direction, up) + + +def mat4Frustum(left, right, bottom, top, near, far): + """Creates a frustum projection matrix. + + See glFrustum. + """ + return numpy.array(( + (2.*near / (right-left), 0., (right+left) / (right-left), 0.), + (0., 2.*near / (top-bottom), (top+bottom) / (top-bottom), 0.), + (0., 0., -(far+near) / (far-near), -2.*far*near / (far-near)), + (0., 0., -1., 0.)), dtype=numpy.float32) + + +def mat4Perspective(fovy, width, height, near, far): + """Creates a perspective projection matrix. + + Similar to gluPerspective. + + :param float fovy: Field of view angle in degrees in the y direction. + :param float width: Width of the viewport. + :param float height: Height of the viewport. + :param float near: Distance to the near plane (strictly positive). + :param float far: Distance to the far plane (strictly positive). + :return: Corresponding matrix. + :rtype: numpy.ndarray of shape (4, 4) + """ + assert fovy != 0 + assert height != 0 + assert width != 0 + assert near > 0. + assert far > near + aspectratio = width / height + f = 1. / numpy.tan(numpy.radians(fovy) / 2.) + return numpy.array(( + (f / aspectratio, 0., 0., 0.), + (0., f, 0., 0.), + (0., 0., (far + near) / (near - far), 2. * far * near / (near - far)), + (0., 0., -1., 0.)), dtype=numpy.float32) + + +def mat4Orthographic(left, right, bottom, top, near, far): + """Creates an orthographic (i.e., parallel) projection matrix. + + See glOrtho. + """ + return numpy.array(( + (2. / (right - left), 0., 0., - (right + left) / (right - left)), + (0., 2. / (top - bottom), 0., - (top + bottom) / (top - bottom)), + (0., 0., -2. / (far - near), - (far + near) / (far - near)), + (0., 0., 0., 1.)), dtype=numpy.float32) + + +# Affine + +def mat4Translate(tx, ty, tz): + """4x4 translation matrix.""" + return numpy.array(( + (1., 0., 0., tx), + (0., 1., 0., ty), + (0., 0., 1., tz), + (0., 0., 0., 1.)), dtype=numpy.float32) + + +def mat4Scale(sx, sy, sz): + """4x4 scale matrix.""" + return numpy.array(( + (sx, 0., 0., 0.), + (0., sy, 0., 0.), + (0., 0., sz, 0.), + (0., 0., 0., 1.)), dtype=numpy.float32) + + +def mat4RotateFromAngleAxis(angle, x=0., y=0., z=1.): + """4x4 rotation matrix from angle and axis. + + :param float angle: The rotation angle in radians. + :param float x: The rotation vector x coordinate. + :param float y: The rotation vector y coordinate. + :param float z: The rotation vector z coordinate. + """ + ca = numpy.cos(angle) + sa = numpy.sin(angle) + return numpy.array(( + ((1.-ca) * x*x + ca, (1.-ca) * x*y - sa*z, (1.-ca) * x*z + sa*y, 0.), + ((1.-ca) * x*y + sa*z, (1.-ca) * y*y + ca, (1.-ca) * y*z - sa*x, 0.), + ((1.-ca) * x*z - sa*y, (1.-ca) * y*z + sa*x, (1.-ca) * z*z + ca, 0.), + (0., 0., 0., 1.)), dtype=numpy.float32) + + +def mat4RotateFromQuaternion(quaternion): + """4x4 rotation matrix from quaternion. + + :param quaternion: Array-like unit quaternion stored as (x, y, z, w) + """ + quaternion = numpy.array(quaternion, copy=True) + quaternion /= numpy.linalg.norm(quaternion) + + qx, qy, qz, qw = quaternion + return numpy.array(( + (1. - 2.*(qy**2 + qz**2), 2.*(qx*qy - qw*qz), 2.*(qx*qz + qw*qy), 0.), + (2.*(qx*qy + qw*qz), 1. - 2.*(qx**2 + qz**2), 2.*(qy*qz - qw*qx), 0.), + (2.*(qx*qz - qw*qy), 2.*(qy*qz + qw*qx), 1. - 2.*(qx**2 + qy**2), 0.), + (0., 0., 0., 1.)), dtype=numpy.float32) + + +def mat4Shear(axis, sx=0., sy=0., sz=0.): + """4x4 shear matrix: Skew two axes relative to a third fixed one. + + shearFactor = tan(shearAngle) + + :param str axis: The axis to keep constant and shear against. + In 'x', 'y', 'z'. + :param float sx: The shear factor for the X axis relative to axis. + :param float sy: The shear factor for the Y axis relative to axis. + :param float sz: The shear factor for the Z axis relative to axis. + """ + assert axis in ('x', 'y', 'z') + + matrix = numpy.identity(4, dtype=numpy.float32) + + # Make the shear column + index = 'xyz'.find(axis) + shearcolumn = numpy.array((sx, sy, sz, 0.), dtype=numpy.float32) + shearcolumn[index] = 1. + matrix[:, index] = shearcolumn + return matrix + + +# Transforms ################################################################## + +class Transform(event.Notifier): + + def __init__(self, static=False): + """Base class for (row-major) 4x4 matrix transforms. + + :param bool static: False (default) to reset cache when changed, + True for static matrices. + """ + super(Transform, self).__init__() + self._matrix = None + self._inverse = None + if not static: + self.addListener(self._changed) # Listening self for changes + + def __repr__(self): + return '%s(%s)' % (self.__class__.__init__, + repr(self.getMatrix(copy=False))) + + def inverse(self): + """Return the Transform of the inverse. + + The returned Transform is static, it is not updated when this + Transform is modified. + + :return: A Transform which is the inverse of this Transform. + """ + return Inverse(self) + + # Matrix + + def _makeMatrix(self): + """Override to build matrix""" + return numpy.identity(4, dtype=numpy.float32) + + def _makeInverse(self): + """Override to build inverse matrix.""" + return numpy.linalg.inv(self.getMatrix(copy=False)) + + def getMatrix(self, copy=True): + """The 4x4 matrix of this transform. + + :param bool copy: True (the default) to get a copy of the matrix, + False to get the internal matrix, do not modify! + :return: 4x4 matrix of this transform. + """ + if self._matrix is None: + self._matrix = self._makeMatrix() + if copy: + return self._matrix.copy() + else: + return self._matrix + + matrix = property(getMatrix, doc="The 4x4 matrix of this transform.") + + def getInverseMatrix(self, copy=False): + """The 4x4 matrix of the inverse of this transform. + + :param bool copy: True (the default) to get a copy of the matrix, + False to get the internal matrix, do not modify! + :return: 4x4 matrix of the inverse of this transform. + """ + if self._inverse is None: + self._inverse = self._makeInverse() + if copy: + return self._inverse.copy() + else: + return self._inverse + + inverseMatrix = property( + getInverseMatrix, + doc="The 4x4 matrix of the inverse of this transform.") + + # Listener + + def _changed(self, source): + """Default self listener reseting matrix cache.""" + self._matrix = None + self._inverse = None + + # Multiplication with vectors + + def transformPoints(self, points, direct=True, perspectiveDivide=False): + """Apply the transform to an array of points. + + :param points: 2D array of N vectors of 3 or 4 coordinates + :param bool direct: Whether to apply the direct (True, the default) + or inverse (False) transform. + :param bool perspectiveDivide: Whether to apply the perspective divide + (True) or not (False, the default). + :return: The transformed points. + :rtype: numpy.ndarray of same shape as points. + """ + if direct: + matrix = self.getMatrix(copy=False) + else: + matrix = self.getInverseMatrix(copy=False) + + points = numpy.array(points, copy=False) + assert points.ndim == 2 + + points = numpy.transpose(points) + + dimension = points.shape[0] + assert dimension in (3, 4) + + if dimension == 3: # Add 4th coordinate + points = numpy.append( + points, + numpy.ones((1, points.shape[1]), dtype=points.dtype), + axis=0) + + result = numpy.transpose(numpy.dot(matrix, points)) + + if perspectiveDivide: + mask = result[:, 3] != 0. + result[mask] /= result[mask, 3][:, numpy.newaxis] + + return result[:, :3] if dimension == 3 else result + + @staticmethod + def _prepareVector(vector, w): + """Add 4th coordinate (w) to vector if missing.""" + assert len(vector) in (3, 4) + vector = numpy.array(vector, copy=False, dtype=numpy.float32) + if len(vector) == 3: + vector = numpy.append(vector, w) + return vector + + def transformPoint(self, point, direct=True, perspectiveDivide=False): + """Apply the transform to a point. + + :param point: Array-like vector of 3 or 4 coordinates. + :param bool direct: Whether to apply the direct (True, the default) + or inverse (False) transform. + :param bool perspectiveDivide: Whether to apply the perspective divide + (True) or not (False, the default). + :return: The transformed point. + :rtype: numpy.ndarray of same length as point. + """ + if direct: + matrix = self.getMatrix(copy=False) + else: + matrix = self.getInverseMatrix(copy=False) + result = numpy.dot(matrix, self._prepareVector(point, 1.)) + + if perspectiveDivide and result[3] != 0.: + result /= result[3] + + if len(point) == 3: + return result[:3] + else: + return result + + def transformDir(self, direction, direct=True): + """Apply the transform to a direction. + + :param direction: Array-like vector of 3 coordinates. + :param bool direct: Whether to apply the direct (True, the default) + or inverse (False) transform. + :return: The transformed direction. + :rtype: numpy.ndarray of length 3. + """ + if direct: + matrix = self.getMatrix(copy=False) + else: + matrix = self.getInverseMatrix(copy=False) + return numpy.dot(matrix[:3, :3], direction[:3]) + + def transformNormal(self, normal, direct=True): + """Apply the transform to a normal: R = (M-1)t * V. + + :param normal: Array-like vector of 3 coordinates. + :param bool direct: Whether to apply the direct (True, the default) + or inverse (False) transform. + :return: The transformed normal. + :rtype: numpy.ndarray of length 3. + """ + if direct: + matrix = self.getInverseMatrix(copy=False).T + else: + matrix = self.getMatrix(copy=False).T + return numpy.dot(matrix[:3, :3], normal[:3]) + + _CUBE_CORNERS = numpy.array(list(itertools.product((0., 1.), repeat=3)), + dtype=numpy.float32) + """Unit cube corners used by :meth:`transformBounds`""" + + def transformBounds(self, bounds, direct=True): + """Apply the transform to an axes-aligned rectangular box. + + :param bounds: Min and max coords of the box for each axes. + :type bounds: 2x3 numpy.ndarray + :param bool direct: Whether to apply the direct (True, the default) + or inverse (False) transform. + :return: Axes-aligned rectangular box including the transformed box. + :rtype: 2x3 numpy.ndarray of float32 + """ + corners = numpy.ones((8, 4), dtype=numpy.float32) + corners[:, :3] = bounds[0] + \ + self._CUBE_CORNERS * (bounds[1] - bounds[0]) + + if direct: + matrix = self.getMatrix(copy=False) + else: + matrix = self.getInverseMatrix(copy=False) + + # Transform corners + cornerstransposed = numpy.dot(matrix, corners.T) + cornerstransposed = cornerstransposed / cornerstransposed[3] + + # Get min/max for each axis + transformedbounds = numpy.empty((2, 3), dtype=numpy.float32) + transformedbounds[0] = cornerstransposed.T[:, :3].min(axis=0) + transformedbounds[1] = cornerstransposed.T[:, :3].max(axis=0) + + return transformedbounds + + +class Inverse(Transform): + """Transform which is the inverse of another one. + + Static: It never gets updated. + """ + + def __init__(self, transform): + """Initializer. + + :param Transform transform: The transform to invert. + """ + + super(Inverse, self).__init__(static=True) + self._matrix = transform.getInverseMatrix(copy=True) + self._inverse = transform.getMatrix(copy=True) + + +class TransformList(Transform, event.HookList): + """List of transforms.""" + + def __init__(self, iterable=()): + Transform.__init__(self) + event.HookList.__init__(self, iterable) + + def _listWillChangeHook(self, methodName, *args, **kwargs): + for item in self: + item.removeListener(self._transformChanged) + + def _listWasChangedHook(self, methodName, *args, **kwargs): + for item in self: + item.addListener(self._transformChanged) + self.notify() + + def _transformChanged(self, source): + """Listen to transform changes of the list and its items.""" + if source is not self: # Avoid infinite recursion + self.notify() + + def _makeMatrix(self): + matrix = numpy.identity(4, dtype=numpy.float32) + for transform in self: + matrix = numpy.dot(matrix, transform.getMatrix(copy=False)) + return matrix + + +class StaticTransformList(Transform): + """Transform that is a snapshot of a list of Transforms + + It does not keep reference to the list of Transforms. + + :param iterable: Iterable of Transform used for initialization + """ + + def __init__(self, iterable=()): + super(StaticTransformList, self).__init__(static=True) + matrix = numpy.identity(4, dtype=numpy.float32) + for transform in iterable: + matrix = numpy.dot(matrix, transform.getMatrix(copy=False)) + self._matrix = matrix # Init matrix once + + +# Affine ###################################################################### + +class Matrix(Transform): + + def __init__(self, matrix=None): + """4x4 Matrix. + + :param matrix: 4x4 array-like matrix or None for identity matrix. + """ + super(Matrix, self).__init__(static=True) + self.setMatrix(matrix) + + def setMatrix(self, matrix=None): + """Update the 4x4 Matrix. + + :param matrix: 4x4 array-like matrix or None for identity matrix. + """ + if matrix is None: + self._matrix = numpy.identity(4, dtype=numpy.float32) + else: + matrix = numpy.array(matrix, copy=True, dtype=numpy.float32) + assert matrix.shape == (4, 4) + self._matrix = matrix + # Reset cached inverse as Transform is declared static + self._inverse = None + self.notify() + + # Redefined here to add a setter + matrix = property(Transform.getMatrix, setMatrix, + doc="The 4x4 matrix of this transform.") + + +class Translate(Transform): + """4x4 translation matrix.""" + + def __init__(self, tx=0., ty=0., tz=0.): + super(Translate, self).__init__() + self._tx, self._ty, self._tz = 0., 0., 0. + self.setTranslate(tx, ty, tz) + + def _makeMatrix(self): + return mat4Translate(self.tx, self.ty, self.tz) + + def _makeInverse(self): + return mat4Translate(-self.tx, -self.ty, -self.tz) + + @property + def tx(self): + return self._tx + + @tx.setter + def tx(self, tx): + self.setTranslate(tx=tx) + + @property + def ty(self): + return self._ty + + @ty.setter + def ty(self, ty): + self.setTranslate(ty=ty) + + @property + def tz(self): + return self._tz + + @tz.setter + def tz(self, tz): + self.setTranslate(tz=tz) + + @property + def translation(self): + return numpy.array((self.tx, self.ty, self.tz), dtype=numpy.float32) + + @translation.setter + def translation(self, translations): + tx, ty, tz = translations + self.setTranslate(tx, ty, tz) + + def setTranslate(self, tx=None, ty=None, tz=None): + if tx is not None: + self._tx = tx + if ty is not None: + self._ty = ty + if tz is not None: + self._tz = tz + self.notify() + + +class Scale(Transform): + """4x4 scale matrix.""" + + def __init__(self, sx=1., sy=1., sz=1.): + super(Scale, self).__init__() + self._sx, self._sy, self._sz = 0., 0., 0. + self.setScale(sx, sy, sz) + + def _makeMatrix(self): + return mat4Scale(self.sx, self.sy, self.sz) + + def _makeInverse(self): + return mat4Scale(1. / self.sx, 1. / self.sy, 1. / self.sz) + + @property + def sx(self): + return self._sx + + @sx.setter + def sx(self, sx): + self.setScale(sx=sx) + + @property + def sy(self): + return self._sy + + @sy.setter + def sy(self, sy): + self.setScale(sy=sy) + + @property + def sz(self): + return self._sz + + @sz.setter + def sz(self, sz): + self.setScale(sz=sz) + + @property + def scale(self): + return numpy.array((self._sx, self._sy, self._sz), dtype=numpy.float32) + + @scale.setter + def scale(self, scales): + sx, sy, sz = scales + self.setScale(sx, sy, sz) + + def setScale(self, sx=None, sy=None, sz=None): + if sx is not None: + assert sx != 0. + self._sx = sx + if sy is not None: + assert sy != 0. + self._sy = sy + if sz is not None: + assert sz != 0. + self._sz = sz + self.notify() + + +class Rotate(Transform): + + def __init__(self, angle=0., ax=0., ay=0., az=1.): + """4x4 rotation matrix. + + :param float angle: The rotation angle in degrees. + :param float ax: The x coordinate of the rotation axis. + :param float ay: The y coordinate of the rotation axis. + :param float az: The z coordinate of the rotation axis. + """ + super(Rotate, self).__init__() + self._angle = 0. + self._axis = None + self.setAngleAxis(angle, (ax, ay, az)) + + @property + def angle(self): + """The rotation angle in degrees.""" + return self._angle + + @angle.setter + def angle(self, angle): + self.setAngleAxis(angle=angle) + + @property + def axis(self): + """The normalized rotation axis as a numpy.ndarray.""" + return self._axis.copy() + + @axis.setter + def axis(self, axis): + self.setAngleAxis(axis=axis) + + def setAngleAxis(self, angle=None, axis=None): + """Update the angle and/or axis of the rotation. + + :param float angle: The rotation angle in degrees. + :param axis: Array-like axis vector (3 coordinates). + """ + if angle is not None: + self._angle = angle + if axis is not None: + assert len(axis) == 3 + axis = numpy.array(axis, copy=True, dtype=numpy.float32) + assert axis.size == 3 + norm = numpy.linalg.norm(axis) + if norm == 0.: # No axis, set rotation angle to 0. + self._angle = 0. + self._axis = numpy.array((0., 0., 1.), dtype=numpy.float32) + else: + self._axis = axis / norm + + if angle is not None or axis is not None: + self.notify() + + @property + def quaternion(self): + """Rotation unit quaternion as (x, y, z, w). + + Where: ||(x, y, z)|| = sin(angle/2), w = cos(angle/2). + """ + if numpy.linalg.norm(self._axis) == 0.: + return numpy.array((0., 0., 0., 1.), dtype=numpy.float32) + + else: + quaternion = numpy.empty((4,), dtype=numpy.float32) + halfangle = 0.5 * numpy.radians(self.angle) + quaternion[0:3] = numpy.sin(halfangle) * self._axis + quaternion[3] = numpy.cos(halfangle) + return quaternion + + @quaternion.setter + def quaternion(self, quaternion): + assert len(quaternion) == 4 + + # Normalize quaternion + quaternion = numpy.array(quaternion, copy=True) + quaternion /= numpy.linalg.norm(quaternion) + + # Get angle + sinhalfangle = numpy.linalg.norm(quaternion[0:3]) + coshalfangle = quaternion[3] + angle = 2. * numpy.arctan2(sinhalfangle, coshalfangle) + + # Axis will be normalized in setAngleAxis + self.setAngleAxis(numpy.degrees(angle), quaternion[0:3]) + + def _makeMatrix(self): + angle = numpy.radians(self.angle, dtype=numpy.float32) + return mat4RotateFromAngleAxis(angle, *self.axis) + + def _makeInverse(self): + return numpy.array(self.getMatrix(copy=False).transpose(), + copy=True, order='C', + dtype=numpy.float32) + + +class Shear(Transform): + + def __init__(self, axis, sx=0., sy=0., sz=0.): + """4x4 shear/skew matrix of 2 axes relative to the third one. + + :param str axis: The axis to keep fixed, in 'x', 'y', 'z' + :param float sx: The shear factor for the x axis. + :param float sy: The shear factor for the y axis. + :param float sz: The shear factor for the z axis. + """ + assert axis in ('x', 'y', 'z') + super(Shear, self).__init__() + self._axis = axis + self._factors = sx, sy, sz + + @property + def axis(self): + """The axis against which other axes are skewed.""" + return self._axis + + @property + def factors(self): + """The shear factors: shearFactor = tan(shearAngle)""" + return self._factors + + def _makeMatrix(self): + return mat4Shear(self.axis, *self.factors) + + def _makeInverse(self): + sx, sy, sz = self.factors + return mat4Shear(self.axis, -sx, -sy, -sz) + + +# Projection ################################################################## + +class _Projection(Transform): + """Base class for projection matrix. + + Handles near and far clipping plane values. + Subclasses must implement :meth:`_makeMatrix`. + + :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 (width, height). + :type size: 2-tuple of float + """ + + def __init__(self, near, far, checkDepthExtent=False, size=(1., 1.)): + super(_Projection, self).__init__() + self._checkDepthExtent = checkDepthExtent + self._depthExtent = 1, 10 + self.setDepthExtent(near, far) # set _depthExtent + self._size = 1., 1. + self.size = size # set _size + + def setDepthExtent(self, near=None, far=None): + """Set the extent of the visible area along the viewing direction. + + :param float near: The near clipping plane Z coord. + :param float far: The far clipping plane Z coord. + """ + near = float(near) if near is not None else self._depthExtent[0] + far = float(far) if far is not None else self._depthExtent[1] + + if self._checkDepthExtent: + assert near > 0. + assert far > near + + self._depthExtent = near, far + self.notify() + + @property + def near(self): + """Distance to the near plane.""" + return self._depthExtent[0] + + @near.setter + def near(self, near): + if near != self.near: + self.setDepthExtent(near=near) + + @property + def far(self): + """Distance to the far plane.""" + return self._depthExtent[1] + + @far.setter + def far(self, far): + if far != self.far: + self.setDepthExtent(far=far) + + @property + def size(self): + """Viewport size as a 2-tuple of float (width, height).""" + return self._size + + @size.setter + def size(self, size): + assert len(size) == 2 + self._size = tuple(size) + self.notify() + + +class Orthographic(_Projection): + """Orthographic (i.e., parallel) projection which can keep aspect ratio. + + Clipping planes are adjusted to match the aspect ratio of + the :attr:`size` attribute if :attr:`keepaspect` is True. + + In this case, the left, right, bottom and top parameters defines the area + which must always remain visible. + Effective clipping planes are adjusted to keep the aspect ratio. + + :param float left: Coord of the left clipping plane. + :param float right: Coord of the right clipping plane. + :param float bottom: Coord of the bottom clipping plane. + :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 (width, height). + :type size: 2-tuple of float + :param bool keepaspect: + True (default) to keep aspect ratio, False otherwise. + """ + + def __init__(self, left=0., right=1., bottom=1., top=0., near=-1., far=1., + size=(1., 1.), keepaspect=True): + self._left, self._right = left, right + self._bottom, self._top = bottom, top + self._keepaspect = bool(keepaspect) + super(Orthographic, self).__init__(near, far, checkDepthExtent=False, + size=size) + # _update called when setting size + + def _makeMatrix(self): + return mat4Orthographic( + self.left, self.right, self.bottom, self.top, self.near, self.far) + + def _update(self, left, right, bottom, top): + if self.keepaspect: + width, height = self.size + aspect = width / height + + orthoaspect = abs(left - right) / abs(bottom - top) + + if orthoaspect >= aspect: # Keep width, enlarge height + newheight = \ + numpy.sign(top - bottom) * abs(left - right) / aspect + bottom = 0.5 * (bottom + top) - 0.5 * newheight + top = bottom + newheight + + else: # Keep height, enlarge width + newwidth = \ + numpy.sign(right - left) * abs(bottom - top) * aspect + left = 0.5 * (left + right) - 0.5 * newwidth + right = left + newwidth + + # Store values + self._left, self._right = left, right + self._bottom, self._top = bottom, top + + def setClipping(self, left=None, right=None, bottom=None, top=None): + """Set the clipping planes of the projection. + + Parameters are adjusted to keep aspect ratio. + If a clipping plane coord is not provided, it uses its current value + + :param float left: Coord of the left clipping plane. + :param float right: Coord of the right clipping plane. + :param float bottom: Coord of the bottom clipping plane. + :param float top: Coord of the top clipping plane. + """ + left = float(left) if left is not None else self.left + right = float(right) if right is not None else self.right + bottom = float(bottom) if bottom is not None else self.bottom + top = float(top) if top is not None else self.top + + self._update(left, right, bottom, top) + self.notify() + + left = property(lambda self: self._left, + doc="Coord of the left clipping plane.") + + right = property(lambda self: self._right, + doc="Coord of the right clipping plane.") + + bottom = property(lambda self: self._bottom, + doc="Coord of the bottom clipping plane.") + + top = property(lambda self: self._top, + doc="Coord of the top clipping plane.") + + @property + def size(self): + """Viewport size as a 2-tuple of float (width, height)""" + return self._size + + @size.setter + def size(self, size): + assert len(size) == 2 + size = float(size[0]), float(size[1]) + if size != self._size: + self._size = size + self._update(self.left, self.right, self.bottom, self.top) + self.notify() + + @property + def keepaspect(self): + """True to keep aspect ratio, False otherwise.""" + return self._keepaspect + + @keepaspect.setter + def keepaspect(self, aspect): + aspect = bool(aspect) + if aspect != self._keepaspect: + self._keepaspect = aspect + self._update(self.left, self.right, self.bottom, self.top) + self.notify() + + +class Ortho2DWidget(_Projection): + """Orthographic projection with pixel as unit. + + Provides same coordinates as widgets: + origin: top left, X axis goes left, Y axis goes down. + + :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 (width, height). + :type size: 2-tuple of float + """ + + def __init__(self, near=-1., far=1., size=(1., 1.)): + + super(Ortho2DWidget, self).__init__(near, far, size) + + def _makeMatrix(self): + width, height = self.size + return mat4Orthographic(0., width, height, 0., self.near, self.far) + + +class Perspective(_Projection): + """Perspective projection matrix defined by FOV and aspect ratio. + + :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 (width, height). + :type size: 2-tuple of float + """ + + def __init__(self, fovy=90., near=0.1, far=1., size=(1., 1.)): + + super(Perspective, self).__init__(near, far, checkDepthExtent=True) + self._fovy = 90. + self.fovy = fovy # Set _fovy + self.size = size # Set _ size + + def _makeMatrix(self): + width, height = self.size + return mat4Perspective(self.fovy, width, height, self.near, self.far) + + @property + def fovy(self): + """Vertical field-of-view in degrees.""" + return self._fovy + + @fovy.setter + def fovy(self, fovy): + self._fovy = float(fovy) + self.notify() diff --git a/src/silx/gui/plot3d/scene/utils.py b/src/silx/gui/plot3d/scene/utils.py new file mode 100644 index 0000000..c6cd129 --- /dev/null +++ b/src/silx/gui/plot3d/scene/utils.py @@ -0,0 +1,662 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2015-2020 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 functions to generate indices, to check intersection +and to handle planes. +""" + +from __future__ import absolute_import, division, unicode_literals + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "25/07/2016" + + +import logging +import numpy + +from . import event + + +_logger = logging.getLogger(__name__) + + +# numpy ####################################################################### + +def _uniqueAlongLastAxis(a): + """Numpy unique on the last axis of a 2D array + + Implemented here as not in numpy as of writing. + + See adding axis parameter to numpy.unique: + https://github.com/numpy/numpy/pull/3584/files#r6225452 + + :param array_like a: Input array. + :return: Unique elements along the last axis. + :rtype: numpy.ndarray + """ + assert len(a.shape) == 2 + + # Construct a type over last array dimension to run unique on a 1D array + if a.dtype.char in numpy.typecodes['AllInteger']: + # Bit-wise comparison of the 2 indices of a line at once + # Expect a C contiguous array of shape N, 2 + uniquedt = numpy.dtype((numpy.void, a.itemsize * a.shape[-1])) + elif a.dtype.char in numpy.typecodes['Float']: + uniquedt = [('f{i}'.format(i=i), a.dtype) for i in range(a.shape[-1])] + else: + raise TypeError("Unsupported type {dtype}".format(dtype=a.dtype)) + + uniquearray = numpy.unique(numpy.ascontiguousarray(a).view(uniquedt)) + return uniquearray.view(a.dtype).reshape((-1, a.shape[-1])) + + +# conversions ################################################################# + +def triangleToLineIndices(triangleIndices, unicity=False): + """Generates lines indices from triangle indices. + + This is generating lines indices for the edges of the triangles. + + :param triangleIndices: The indices to draw a set of vertices as triangles. + :type triangleIndices: numpy.ndarray + :param bool unicity: If True remove duplicated lines, + else (the default) returns all lines. + :return: The indices to draw the edges of the triangles as lines. + :rtype: 1D numpy.ndarray of uint16 or uint32. + """ + # Makes sure indices ar packed by triangle + triangleIndices = triangleIndices.reshape(-1, 3) + + # Pack line indices by triangle and by edge + lineindices = numpy.empty((len(triangleIndices), 3, 2), + dtype=triangleIndices.dtype) + lineindices[:, 0] = triangleIndices[:, :2] # edge = t0, t1 + lineindices[:, 1] = triangleIndices[:, 1:] # edge =t1, t2 + lineindices[:, 2] = triangleIndices[:, ::2] # edge = t0, t2 + + if unicity: + lineindices = _uniqueAlongLastAxis(lineindices.reshape(-1, 2)) + + # Make sure it is 1D + lineindices.shape = -1 + + return lineindices + + +def verticesNormalsToLines(vertices, normals, scale=1.): + """Return vertices of lines representing normals at given positions. + + :param vertices: Positions of the points. + :type vertices: numpy.ndarray with shape: (nbPoints, 3) + :param normals: Corresponding normals at the points. + :type normals: numpy.ndarray with shape: (nbPoints, 3) + :param float scale: The scale factor to apply to normals. + :returns: Array of vertices to draw corresponding lines. + :rtype: numpy.ndarray with shape: (nbPoints * 2, 3) + """ + linevertices = numpy.empty((len(vertices) * 2, 3), dtype=vertices.dtype) + linevertices[0::2] = vertices + linevertices[1::2] = vertices + scale * normals + return linevertices + + +def unindexArrays(mode, indices, *arrays): + """Convert indexed GL primitives to unindexed ones. + + Given indices in arrays and the OpenGL primitive they represent, + return the unindexed equivalent. + + :param str mode: + Kind of primitive represented by indices. + In: points, lines, line_strip, loop, triangles, triangle_strip, fan. + :param indices: Indices in other arrays + :type indices: numpy.ndarray of dimension 1. + :param arrays: Remaining arguments are arrays to convert + :return: Converted arrays + :rtype: tuple of numpy.ndarray + """ + indices = numpy.array(indices, copy=False) + + assert mode in ('points', + 'lines', 'line_strip', 'loop', + 'triangles', 'triangle_strip', 'fan') + + if mode in ('lines', 'line_strip', 'loop'): + assert len(indices) >= 2 + elif mode in ('triangles', 'triangle_strip', 'fan'): + assert len(indices) >= 3 + + assert indices.min() >= 0 + max_index = indices.max() + for data in arrays: + assert len(data) >= max_index + + if mode == 'line_strip': + unpacked = numpy.empty((2 * (len(indices) - 1),), dtype=indices.dtype) + unpacked[0::2] = indices[:-1] + unpacked[1::2] = indices[1:] + indices = unpacked + + elif mode == 'loop': + unpacked = numpy.empty((2 * len(indices),), dtype=indices.dtype) + unpacked[0::2] = indices + unpacked[1:-1:2] = indices[1:] + unpacked[-1] = indices[0] + indices = unpacked + + elif mode == 'triangle_strip': + unpacked = numpy.empty((3 * (len(indices) - 2),), dtype=indices.dtype) + unpacked[0::3] = indices[:-2] + unpacked[1::3] = indices[1:-1] + unpacked[2::3] = indices[2:] + indices = unpacked + + elif mode == 'fan': + unpacked = numpy.empty((3 * (len(indices) - 2),), dtype=indices.dtype) + unpacked[0::3] = indices[0] + unpacked[1::3] = indices[1:-1] + unpacked[2::3] = indices[2:] + indices = unpacked + + 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. + + :param positions: Serie of triangle's corners + :type positions: numpy.ndarray of shape (NbTriangles*3, 3) + :return: Normals corresponding to each position. + :rtype: numpy.ndarray of shape (NbTriangles, 3) + """ + assert positions.ndim == 2 + assert positions.shape[1] == 3 + + positions = numpy.array(positions, copy=False).reshape(-1, 3, 3) + + normals = numpy.cross(positions[:, 1] - positions[:, 0], + positions[:, 2] - positions[:, 0]) + + # Normalize normals + norms = numpy.linalg.norm(normals, axis=1) + norms[norms == 0] = 1 + + return normals / norms.reshape(-1, 1) + + +# grid ######################################################################## + +def gridVertices(dim0Array, dim1Array, dtype): + """Generate an array of 2D positions from 2 arrays of 1D coordinates. + + :param dim0Array: 1D array-like of coordinates along the first dimension. + :param dim1Array: 1D array-like of coordinates along the second dimension. + :param numpy.dtype dtype: Data type of the output array. + :return: Array of grid coordinates. + :rtype: numpy.ndarray with shape: (len(dim0Array), len(dim1Array), 2) + """ + grid = numpy.empty((len(dim0Array), len(dim1Array), 2), dtype=dtype) + grid.T[0, :, :] = dim0Array + grid.T[1, :, :] = numpy.array(dim1Array, copy=False)[:, None] + return grid + + +def triangleStripGridIndices(dim0, dim1): + """Generate indices to draw a grid of vertices as a triangle strip. + + Vertices are expected to be stored as row-major (i.e., C contiguous). + + :param int dim0: The number of rows of vertices. + :param int dim1: The number of columns of vertices. + :return: The vertex indices + :rtype: 1D numpy.ndarray of uint32 + """ + assert dim0 >= 2 + assert dim1 >= 2 + + # Filling a row of squares + + # an index before and one after for degenerated triangles + indices = numpy.empty((dim0 - 1, 2 * (dim1 + 1)), dtype=numpy.uint32) + + # Init indices with minimum indices for each row of squares + indices[:] = (dim1 * numpy.arange(dim0 - 1, dtype=numpy.uint32))[:, None] + + # Update indices with offset per row of squares + offset = numpy.arange(dim1, dtype=numpy.uint32) + indices[:, 1:-1:2] += offset + offset += dim1 + indices[:, 2::2] += offset + indices[:, -1] += offset[-1] + + # Remove extra indices for degenerated triangles before returning + return indices.ravel()[1:-1] + + # Alternative: + # indices = numpy.zeros(2 * dim1 * (dim0 - 1) + 2 * (dim0 - 2), + # dtype=numpy.uint32) + # + # offset = numpy.arange(dim1, dtype=numpy.uint32) + # for d0Index in range(dim0 - 1): + # start = 2 * d0Index * (dim1 + 1) + # end = start + 2 * dim1 + # if d0Index != 0: + # indices[start - 2] = offset[-1] + # indices[start - 1] = offset[0] + # indices[start:end:2] = offset + # offset += dim1 + # indices[start + 1:end:2] = offset + # return indices + + +def linesGridIndices(dim0, dim1): + """Generate indices to draw a grid of vertices as lines. + + Vertices are expected to be stored as row-major (i.e., C contiguous). + + :param int dim0: The number of rows of vertices. + :param int dim1: The number of columns of vertices. + :return: The vertex indices. + :rtype: 1D numpy.ndarray of uint32 + """ + # Horizontal and vertical lines + nbsegmentalongdim1 = 2 * (dim1 - 1) + nbsegmentalongdim0 = 2 * (dim0 - 1) + + indices = numpy.empty(nbsegmentalongdim1 * dim0 + + nbsegmentalongdim0 * dim1, + dtype=numpy.uint32) + + # Line indices over dim0 + onedim1line = (numpy.arange(nbsegmentalongdim1, + dtype=numpy.uint32) + 1) // 2 + indices[:dim0 * nbsegmentalongdim1] = \ + (dim1 * numpy.arange(dim0, dtype=numpy.uint32)[:, None] + + onedim1line[None, :]).ravel() + + # Line indices over dim1 + onedim0line = (numpy.arange(nbsegmentalongdim0, + dtype=numpy.uint32) + 1) // 2 + indices[dim0 * nbsegmentalongdim1:] = \ + (numpy.arange(dim1, dtype=numpy.uint32)[:, None] + + dim1 * onedim0line[None, :]).ravel() + + return indices + + +# intersection ################################################################ + +def angleBetweenVectors(refVector, vectors, norm=None): + """Return the angle between 2 vectors. + + :param refVector: Coordinates of the reference vector. + :type refVector: numpy.ndarray of shape: (NCoords,) + :param vectors: Coordinates of the vector(s) to get angle from reference. + :type vectors: numpy.ndarray of shape: (NCoords,) or (NbVector, NCoords) + :param norm: A direction vector giving an orientation to the angles + or None. + :returns: The angles in radians in [0, pi] if norm is None + else in [0, 2pi]. + :rtype: float or numpy.ndarray of shape (NbVectors,) + """ + singlevector = len(vectors.shape) == 1 + if singlevector: # Make it a 2D array for the computation + vectors = vectors.reshape(1, -1) + + assert len(refVector.shape) == 1 + assert len(vectors.shape) == 2 + assert len(refVector) == vectors.shape[1] + + # Normalize vectors + refVector /= numpy.linalg.norm(refVector) + vectors = numpy.array([v / numpy.linalg.norm(v) for v in vectors]) + + dots = numpy.sum(refVector * vectors, axis=-1) + angles = numpy.arccos(numpy.clip(dots, -1., 1.)) + if norm is not None: + signs = numpy.sum(norm * numpy.cross(refVector, vectors), axis=-1) < 0. + angles[signs] = numpy.pi * 2. - angles[signs] + + return angles[0] if singlevector else angles + + +def segmentPlaneIntersect(s0, s1, planeNorm, planePt): + """Compute the intersection of a segment with a plane. + + :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 + :param planeNorm: Normal vector of the plane. + :type planeNorm: numpy.ndarray of shape: (3,) + :param planePt: A point of the plane. + :type planePt: numpy.ndarray of shape: (3,) + :return: The intersection points. The number of points goes + from 0 (no intersection) to 2 (segment in the plane) + :rtype: list of numpy.ndarray + """ + s0, s1 = numpy.asarray(s0), numpy.asarray(s1) + + segdir = s1 - s0 + dotnormseg = numpy.dot(planeNorm, segdir) + if dotnormseg == 0: + # line and plane are parallels + if numpy.dot(planeNorm, planePt - s0) == 0: # segment is in plane + return [s0, s1] + else: # No intersection + return [] + + alpha = - numpy.dot(planeNorm, s0 - planePt) / dotnormseg + if 0. <= alpha <= 1.: # Intersection with segment + return [s0 + alpha * segdir] + else: # intersection outside segment + return [] + + +def boxPlaneIntersect(boxVertices, boxLineIndices, planeNorm, planePt): + """Return intersection points between a box and a plane. + + :param boxVertices: Position of the corners of the box. + :type boxVertices: numpy.ndarray with shape: (8, 3) + :param boxLineIndices: Indices of the box edges. + :type boxLineIndices: numpy.ndarray-like with shape: (12, 2) + :param planeNorm: Normal vector of the plane. + :type planeNorm: numpy.ndarray of shape: (3,) + :param planePt: A point of the plane. + :type planePt: numpy.ndarray of shape: (3,) + :return: The found intersection points + :rtype: numpy.ndarray with 2 dimensions + """ + segments = numpy.take(boxVertices, boxLineIndices, axis=0) + + points = set() # Gather unique intersection points + for seg in segments: + for point in segmentPlaneIntersect(seg[0], seg[1], planeNorm, planePt): + points.add(tuple(point)) + points = numpy.array(list(points)) + + if len(points) <= 2: + return numpy.array(()) + elif len(points) == 3: + return points + else: # len(points) > 3 + # Order point to have a polyline lying on the unit cube's faces + vectors = points - numpy.mean(points, axis=0) + angles = angleBetweenVectors(vectors[0], vectors, planeNorm) + points = numpy.take(points, numpy.argsort(angles), axis=0) + return points + + +def clipSegmentToBounds(segment, bounds): + """Clip segment to volume aligned with axes. + + :param numpy.ndarray segment: (p0, p1) + :param numpy.ndarray bounds: (lower corner, upper corner) + :return: Either clipped (p0, p1) or None if outside volume + :rtype: Union[None,List[numpy.ndarray]] + """ + segment = numpy.array(segment, copy=False) + bounds = numpy.array(bounds, copy=False) + + p0, p1 = segment + # Get intersection points of ray with volume boundary planes + # Line equation: P = offset * delta + p0 + delta = p1 - p0 + deltaNotZero = numpy.array(delta, copy=True) + deltaNotZero[deltaNotZero == 0] = numpy.nan # Invalidated to avoid division by zero + offsets = ((bounds - p0) / deltaNotZero).reshape(-1) + points = offsets.reshape(-1, 1) * delta + p0 + + # Avoid precision errors by using bounds value + points.shape = 2, 3, 3 # Reshape 1 point per bound value + for dim in range(3): + points[:, dim, dim] = bounds[:, dim] + points.shape = -1, 3 # Set back to 2D array + + # Find intersection points that are included in the volume + mask = numpy.logical_and(numpy.all(bounds[0] <= points, axis=1), + numpy.all(points <= bounds[1], axis=1)) + intersections = numpy.unique(offsets[mask]) + if len(intersections) != 2: + return None + + intersections.sort() + # Do p1 first as p0 is need to compute it + if intersections[1] < 1: # clip p1 + segment[1] = intersections[1] * delta + p0 + if intersections[0] > 0: # clip p0 + segment[0] = intersections[0] * delta + p0 + return segment + + +def segmentVolumeIntersect(segment, nbins): + """Get bin indices intersecting with segment + + It should work with N dimensions. + Coordinate convention (z, y, x) or (x, y, z) should not matter + as long as segment and nbins are consistent. + + :param numpy.ndarray segment: + Segment end points as a 2xN array of coordinates + :param numpy.ndarray nbins: + Shape of the volume with same coordinates order as segment + :return: List of bins indices as a 2D array or None if no bins + :rtype: Union[None,numpy.ndarray] + """ + segment = numpy.asarray(segment) + nbins = numpy.asarray(nbins) + + assert segment.ndim == 2 + assert segment.shape[0] == 2 + assert nbins.ndim == 1 + assert segment.shape[1] == nbins.size + + dim = len(nbins) + + bounds = numpy.array((numpy.zeros_like(nbins), nbins)) + segment = clipSegmentToBounds(segment, bounds) + if segment is None: + return None # Segment outside volume + p0, p1 = segment + + # Get intersections + + # Get coordinates of bin edges crossing the segment + clipped = numpy.ceil(numpy.clip(segment, 0, nbins)) + start = numpy.min(clipped, axis=0) + stop = numpy.max(clipped, axis=0) # stop is NOT included + edgesByDim = [numpy.arange(start[i], stop[i]) for i in range(dim)] + + # Line equation: P = t * delta + p0 + delta = p1 - p0 + + # Get bin edge/line intersections as sorted points along the line + # Get corresponding line parameters + t = [] + if numpy.all(0 <= p0) and numpy.all(p0 <= nbins): + t.append([0.]) # p0 within volume, add it + t += [(edgesByDim[i] - p0[i]) / delta[i] for i in range(dim) if delta[i] != 0] + if numpy.all(0 <= p1) and numpy.all(p1 <= nbins): + t.append([1.]) # p1 within volume, add it + t = numpy.concatenate(t) + t.sort(kind='mergesort') + + # Remove duplicates + unique = numpy.ones((len(t),), dtype=bool) + numpy.not_equal(t[1:], t[:-1], out=unique[1:]) + t = t[unique] + + if len(t) < 2: + return None # Not enough intersection points + + # bin edges/line intersection points + points = t.reshape(-1, 1) * delta + p0 + centers = (points[:-1] + points[1:]) / 2. + bins = numpy.floor(centers).astype(numpy.int64) + return bins + + +# Plane ####################################################################### + +class Plane(event.Notifier): + """Object handling a plane and notifying plane changes. + + :param point: A point on the plane. + :type point: 3-tuple of float. + :param normal: Normal of the plane. + :type normal: 3-tuple of float. + """ + + def __init__(self, point=(0., 0., 0.), normal=(0., 0., 1.)): + super(Plane, self).__init__() + + assert len(point) == 3 + self._point = numpy.array(point, copy=True, dtype=numpy.float32) + assert len(normal) == 3 + self._normal = numpy.array(normal, copy=True, dtype=numpy.float32) + self.notify() + + def setPlane(self, point=None, normal=None): + """Set plane point and normal and notify. + + :param point: A point on the plane. + :type point: 3-tuple of float or None. + :param normal: Normal of the plane. + :type normal: 3-tuple of float or None. + """ + planechanged = False + + if point is not None: + assert len(point) == 3 + point = numpy.array(point, copy=True, dtype=numpy.float32) + if not numpy.all(numpy.equal(self._point, point)): + self._point = point + planechanged = True + + if normal is not None: + assert len(normal) == 3 + normal = numpy.array(normal, copy=True, dtype=numpy.float32) + + norm = numpy.linalg.norm(normal) + if norm != 0.: + normal /= norm + + if not numpy.all(numpy.equal(self._normal, normal)): + self._normal = normal + planechanged = True + + if planechanged: + _logger.debug('Plane updated:\n\tpoint: %s\n\tnormal: %s', + str(self._point), str(self._normal)) + self.notify() + + @property + def point(self): + """A point on the plane.""" + return self._point.copy() + + @point.setter + def point(self, point): + self.setPlane(point=point) + + @property + def normal(self): + """The (normalized) normal of the plane.""" + return self._normal.copy() + + @normal.setter + def normal(self, normal): + self.setPlane(normal=normal) + + @property + def parameters(self): + """Plane equation parameters: a*x + b*y + c*z + d = 0.""" + return numpy.append(self._normal, + - numpy.dot(self._point, self._normal)) + + @parameters.setter + def parameters(self, parameters): + assert len(parameters) == 4 + parameters = numpy.array(parameters, dtype=numpy.float32) + + # Normalize normal + norm = numpy.linalg.norm(parameters[:3]) + if norm != 0: + parameters /= norm + + normal = parameters[:3] + point = - parameters[3] * normal + self.setPlane(point, normal) + + @property + def isPlane(self): + """True if a plane is defined (i.e., ||normal|| != 0).""" + return numpy.any(self.normal != 0.) + + 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/src/silx/gui/plot3d/scene/viewport.py b/src/silx/gui/plot3d/scene/viewport.py new file mode 100644 index 0000000..6de640e --- /dev/null +++ b/src/silx/gui/plot3d/scene/viewport.py @@ -0,0 +1,603 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2015-2019 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 to control a viewport on the rendering window. + +The :class:`Viewport` describes a Viewport rendering a scene. +The attribute :attr:`scene` is the root group of the scene tree. +:class:`RenderContext` handles the current state during rendering. +""" + +from __future__ import absolute_import, division, unicode_literals + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "24/04/2018" + + +import string +import numpy + +from silx.gui.colors import rgba + +from ..._glutils import gl + +from . import camera +from . import event +from . import transform +from .function import DirectionalLight, ClippingPlane, Fog + + +class RenderContext(object): + """Handle a current rendering context. + + An instance of this class is passed to rendering method through + the scene during render. + + User should NEVER use an instance of this class beyond the method + it is passed to as an argument (i.e., do not keep a reference to it). + + :param Viewport viewport: The viewport doing the rendering. + :param Context glContext: The operating system OpenGL context in use. + """ + + _FRAGMENT_SHADER_SRC = string.Template(""" + void scene_post(vec4 cameraPosition) { + gl_FragColor = $fogCall(gl_FragColor, cameraPosition); + } + """) + + def __init__(self, viewport, glContext): + self._viewport = viewport + self._glContext = glContext + self._transformStack = [viewport.camera.extrinsic] + self._clipPlane = ClippingPlane(normal=(0., 0., 0.)) + + # cache + self.__cache = {} + + def cache(self, key, factory, *args, **kwargs): + """Lazy-loading cache to store values in the context for rendering + + :param key: The key to retrieve + :param factory: A callback taking args and kwargs as arguments + and returning the value to store. + :return: The stored or newly allocated value + """ + if key not in self.__cache: + self.__cache[key] = factory(*args, **kwargs) + return self.__cache[key] + + @property + def viewport(self): + """Viewport doing the current rendering""" + return self._viewport + + @property + def glCtx(self): + """The OpenGL context in use""" + return self._glContext + + @property + def objectToCamera(self): + """The current transform from object to camera coords. + + Do not modify. + """ + return self._transformStack[-1] + + @property + def projection(self): + """Projection transform. + + Do not modify. + """ + return self.viewport.camera.intrinsic + + @property + def objectToNDC(self): + """The transform from object to NDC (this includes projection). + + Do not modify. + """ + return transform.StaticTransformList( + (self.projection, self.objectToCamera)) + + def pushTransform(self, transform_, multiply=True): + """Push a :class:`Transform` on the transform stack. + + :param Transform transform_: The transform to add to the stack. + :param bool multiply: + True (the default) to multiply with the top of the stack, + False to push the transform as is without multiplication. + """ + if multiply: + assert len(self._transformStack) >= 1 + transform_ = transform.StaticTransformList( + (self._transformStack[-1], transform_)) + + self._transformStack.append(transform_) + + def popTransform(self): + """Pop the transform on top of the stack. + + :return: The Transform that is popped from the stack. + """ + assert len(self._transformStack) > 1 + return self._transformStack.pop() + + @property + def clipper(self): + """The current clipping plane (ClippingPlane)""" + return self._clipPlane + + def setClipPlane(self, point=(0., 0., 0.), normal=(0., 0., 0.)): + """Set the clipping plane to use + + For now only handles a single clipping plane. + + :param point: A point of the plane + :type point: 3-tuple of float + :param normal: Normal vector of the plane or (0, 0, 0) for no clipping + :type normal: 3-tuple of float + """ + self._clipPlane = ClippingPlane(point, normal) + + def setupProgram(self, program): + """Sets-up uniforms of a program using the context shader functions. + + :param GLProgram program: The program to set-up. + It MUST be in use and using the context function. + """ + self.clipper.setupProgram(self, program) + self.viewport.fog.setupProgram(self, program) + + @property + def fragDecl(self): + """Fragment shader declaration for scene shader functions""" + return '\n'.join(( + self.clipper.fragDecl, + self.viewport.fog.fragDecl, + self._FRAGMENT_SHADER_SRC.substitute( + fogCall=self.viewport.fog.fragCall))) + + @property + def fragCallPre(self): + """Fragment shader call for scene shader functions (to do first) + + It takes the camera position (vec4) as argument. + """ + return self.clipper.fragCall + + @property + def fragCallPost(self): + """Fragment shader call for scene shader functions (to do last) + + It takes the camera position (vec4) as argument. + """ + return "scene_post" + + +class Viewport(event.Notifier): + """Rendering a single scene through a camera in part of a framebuffer. + + :param int framebuffer: The framebuffer ID this viewport is rendering into + """ + + def __init__(self, framebuffer=0): + from . import Group # Here to avoid cyclic import + super(Viewport, self).__init__() + self._dirty = True + self._origin = 0, 0 + self._size = 1, 1 + self._framebuffer = int(framebuffer) + self.scene = Group() # The stuff to render, add overlaid scenes? + self.scene._setParent(self) + self.scene.addListener(self._changed) + self._background = 0., 0., 0., 1. + self._camera = camera.Camera(fovy=30., near=1., far=100., + position=(0., 0., 12.)) + self._camera.addListener(self._changed) + self._transforms = transform.TransformList([self._camera]) + + self._light = DirectionalLight(direction=(0., 0., -1.), + ambient=(0.3, 0.3, 0.3), + diffuse=(0.7, 0.7, 0.7)) + self._light.addListener(self._changed) + self._fog = Fog() + self._fog.isOn = False + self._fog.addListener(self._changed) + + @property + def transforms(self): + """Proxy of camera transforms. + + Do not modify the list. + """ + return self._transforms + + def _changed(self, *args, **kwargs): + """Callback handling scene updates""" + self._dirty = True + self.notify() + + @property + def dirty(self): + """True if scene is dirty and needs redisplay.""" + return self._dirty + + def resetDirty(self): + """Mark the scene as not being dirty. + + To call after rendering. + """ + self._dirty = False + + @property + def background(self): + """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): + if color is not None: + color = rgba(color) + if self._background != color: + self._background = color + self._changed() + + @property + def camera(self): + """The camera used to render the scene.""" + return self._camera + + @property + def light(self): + """The light used to render the scene.""" + return self._light + + @property + def fog(self): + """The fog function used to render the scene""" + return self._fog + + @property + def origin(self): + """Origin (ox, oy) of the viewport in pixels""" + return self._origin + + @origin.setter + def origin(self, origin): + ox, oy = origin + origin = int(ox), int(oy) + if origin != self._origin: + self._origin = origin + self._changed() + + @property + def size(self): + """Size (width, height) of the viewport in pixels""" + return self._size + + @size.setter + def size(self, size): + w, h = size + size = int(w), int(h) + if size != self._size: + self._size = size + + self.camera.intrinsic.size = size + self._changed() + + @property + def shape(self): + """Shape (height, width) of the viewport in pixels. + + This is a convenient wrapper to the inverse of size. + """ + return self._size[1], self._size[0] + + @shape.setter + def shape(self, shape): + self.size = shape[1], shape[0] + + @property + def framebuffer(self): + """The framebuffer ID this viewport is rendering into (int)""" + return self._framebuffer + + @framebuffer.setter + def framebuffer(self, framebuffer): + self._framebuffer = int(framebuffer) + + def render(self, glContext): + """Perform the rendering of the viewport + + :param Context glContext: The context used for rendering""" + # Get a chance to run deferred delete + glContext.cleanGLGarbage() + + # OpenGL set-up: really need to be done once + ox, oy = self.origin + w, h = self.size + gl.glViewport(ox, oy, w, h) + + gl.glEnable(gl.GL_SCISSOR_TEST) + gl.glScissor(ox, oy, w, h) + + gl.glEnable(gl.GL_BLEND) + gl.glBlendFunc(gl.GL_SRC_ALPHA, gl.GL_ONE_MINUS_SRC_ALPHA) + + gl.glEnable(gl.GL_DEPTH_TEST) + gl.glDepthFunc(gl.GL_LEQUAL) + gl.glDepthRange(0., 1.) + + # gl.glEnable(gl.GL_POLYGON_OFFSET_FILL) + # gl.glPolygonOffset(1., 1.) + + gl.glHint(gl.GL_LINE_SMOOTH_HINT, gl.GL_NICEST) + gl.glEnable(gl.GL_LINE_SMOOTH) + + 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) + + ctx = RenderContext(self, glContext) + self.scene.render(ctx) + self.scene.postRender(ctx) + + def adjustCameraDepthExtent(self): + """Update camera depth extent to fit the scene bounds. + + Only near and far planes are updated. + The scene might still not be fully visible + (e.g., if spanning behind the viewpoint with perspective projection). + """ + bounds = self.scene.bounds(transformed=True) + if bounds is None: + bounds = numpy.array(((0., 0., 0.), (1., 1., 1.)), + dtype=numpy.float32) + bounds = self.camera.extrinsic.transformBounds(bounds) + + if isinstance(self.camera.intrinsic, transform.Perspective): + # This needs to be reworked + zbounds = - bounds[:, 2] + zextent = max(numpy.fabs(zbounds[0] - zbounds[1]), 0.0001) + near = max(zextent / 1000., 0.95 * zbounds[1]) + far = max(near + 0.1, 1.05 * zbounds[0]) + + self.camera.intrinsic.setDepthExtent(near, far) + elif isinstance(self.camera.intrinsic, transform.Orthographic): + # Makes sure z bounds are included + border = max(abs(bounds[:, 2])) + self.camera.intrinsic.setDepthExtent(-border, border) + else: + raise RuntimeError('Unsupported camera', self.camera.intrinsic) + + def resetCamera(self): + """Change camera to have the whole scene in the viewing frustum. + + It updates the camera position and depth extent. + Camera sight direction and up are not affected. + """ + bounds = self.scene.bounds(transformed=True) + if bounds is None: + bounds = numpy.array(((0., 0., 0.), (1., 1., 1.)), + dtype=numpy.float32) + self.camera.resetCamera(bounds) + + def orbitCamera(self, direction, angle=1.): + """Rotate the camera around center of the scene. + + :param str direction: Direction of movement relative to image plane. + In: 'up', 'down', 'left', 'right'. + :param float angle: he angle in degrees of the rotation. + """ + bounds = self.scene.bounds(transformed=True) + if bounds is None: + bounds = numpy.array(((0., 0., 0.), (1., 1., 1.)), + dtype=numpy.float32) + center = 0.5 * (bounds[0] + bounds[1]) + self.camera.orbit(direction, center, angle) + + def moveCamera(self, direction, step=0.1): + """Move the camera relative to the image plane. + + :param str direction: Direction relative to image plane. + One of: 'up', 'down', 'left', 'right', + 'forward', 'backward'. + :param float step: The ratio of data to step for each pan. + """ + bounds = self.scene.bounds(transformed=True) + if bounds is None: + bounds = numpy.array(((0., 0., 0.), (1., 1., 1.)), + dtype=numpy.float32) + bounds = self.camera.extrinsic.transformBounds(bounds) + center = 0.5 * (bounds[0] + bounds[1]) + ndcCenter = self.camera.intrinsic.transformPoint( + center, perspectiveDivide=True) + + step *= 2. # NDC has size 2 + + if direction == 'up': + ndcCenter[1] -= step + elif direction == 'down': + ndcCenter[1] += step + + elif direction == 'right': + ndcCenter[0] -= step + elif direction == 'left': + ndcCenter[0] += step + + elif direction == 'forward': + ndcCenter[2] += step + elif direction == 'backward': + ndcCenter[2] -= step + + else: + raise ValueError('Unsupported direction: %s' % direction) + + newCenter = self.camera.intrinsic.transformPoint( + ndcCenter, direct=False, perspectiveDivide=True) + + self.camera.move(direction, numpy.linalg.norm(newCenter - center)) + + def windowToNdc(self, winX, winY, checkInside=True): + """Convert position from window to normalized device coordinates. + + If window coordinates are int, they are moved half a pixel + to be positioned at the center of pixel. + + :param winX: X window coord, origin left. + :param winY: Y window coord, origin top. + :param bool checkInside: If True, returns None if position is + outside viewport. + :return: (x, y) Normalize device coordinates in [-1, 1] or None. + Origin center, x to the right, y goes upward. + """ + ox, oy = self._origin + width, height = self.size + + # If int, move it to the center of pixel + if isinstance(winX, int): + winX += 0.5 + if isinstance(winY, int): + winY += 0.5 + + x, y = winX - ox, winY - oy + + if checkInside and (x < 0. or x > width or y < 0. or y > height): + return None # Out of viewport + + ndcx = 2. * x / float(width) - 1. + ndcy = 1. - 2. * y / float(height) + return ndcx, ndcy + + def ndcToWindow(self, ndcX, ndcY, checkInside=True): + """Convert position from normalized device coordinates (NDC) to window. + + :param float ndcX: X NDC coord. + :param float ndcY: Y NDC coord. + :param bool checkInside: If True, returns None if position is + outside viewport. + :return: (x, y) window coordinates or None. + Origin top-left, x to the right, y goes downward. + """ + if (checkInside and + (ndcX < -1. or ndcX > 1. or ndcY < -1. or ndcY > 1.)): + return None # Outside viewport + + ox, oy = self._origin + width, height = self.size + + winx = ox + width * 0.5 * (ndcX + 1.) + winy = oy + height * 0.5 * (1. - ndcY) + return winx, winy + + 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 + """ + ox, oy = self._origin + width, height = self.size + + x = int(x) + y = height - int(y) # Invert y coord + + if x < ox or x > ox + width or y < oy or y > oy + height: + # Outside viewport + return None + + # Get depth from depth buffer in [0., 1.] + # Bind used framebuffer to get depth + gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, self.framebuffer) + + 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) + + # Z in NDC in [-1., 1.] + return float(depth) * 2. - 1. + + def _getXZYGL(self, x, y): + ndc = self.windowToNdc(x, y) + if ndc is None: + return None # Outside viewport + ndcz = self._pickNdcZGL(x, y) + ndcpos = numpy.array((ndc[0], ndc[1], ndcz, 1.), dtype=numpy.float32) + + camerapos = self.camera.intrinsic.transformPoint( + ndcpos, direct=False, perspectiveDivide=True) + + scenepos = self.camera.extrinsic.transformPoint(camerapos, + direct=False) + return scenepos[:3] + + def pick(self, x, y): + pass + # ndcX, ndcY = self.windowToNdc(x, y) + # ndcNearPt = ndcX, ndcY, -1. + # ndcFarPT = ndcX, ndcY, 1. diff --git a/src/silx/gui/plot3d/scene/window.py b/src/silx/gui/plot3d/scene/window.py new file mode 100644 index 0000000..b92c404 --- /dev/null +++ b/src/silx/gui/plot3d/scene/window.py @@ -0,0 +1,433 @@ +# coding: utf-8 +# /*########################################################################## +# +# 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 +# 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 for Viewports rendering on the screen. + +The :class:`Window` renders a list of Viewports in the current framebuffer. +The rendering can be performed in an off-screen framebuffer that is only +updated when the scene has changed and not each time Qt is requiring a repaint. + +The :class:`Context` and :class:`ContextGL2` represent the operating system +OpenGL context and handle OpenGL resources. +""" + +from __future__ import absolute_import, division, unicode_literals + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "10/01/2017" + + +import weakref +import numpy + +from ..._glutils import gl +from ... import _glutils + +from . import event + + +class Context(object): + """Correspond to an operating system OpenGL context. + + User should NEVER use an instance of this class beyond the method + it is passed to as an argument (i.e., do not keep a reference to it). + + :param glContextHandle: System specific OpenGL context handle. + """ + + def __init__(self, glContextHandle): + self._context = glContextHandle + self._isCurrent = False + self._devicePixelRatio = 1.0 + + @property + def isCurrent(self): + """Whether this OpenGL context is the current one or not.""" + return self._isCurrent + + def setCurrent(self, isCurrent=True): + """Set the state of the OpenGL context to reflect OpenGL state. + + This should not be called from the scene graph, only in the + wrapper that handle the OpenGL context to reflect its state. + + :param bool isCurrent: The state of the system OpenGL context. + """ + self._isCurrent = bool(isCurrent) + + @property + def devicePixelRatio(self): + """Ratio between device and device independent pixels (float) + + This is useful for font rendering. + """ + return self._devicePixelRatio + + @devicePixelRatio.setter + def devicePixelRatio(self, ratio): + assert ratio > 0 + self._devicePixelRatio = float(ratio) + + def __enter__(self): + self.setCurrent(True) + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.setCurrent(False) + + @property + def glContext(self): + """The handle to the OpenGL context provided by the system.""" + return self._context + + def cleanGLGarbage(self): + """This is releasing OpenGL resource that are no longer used.""" + pass + + +class ContextGL2(Context): + """Handle a system GL2 context. + + User should NEVER use an instance of this class beyond the method + it is passed to as an argument (i.e., do not keep a reference to it). + + :param glContextHandle: System specific OpenGL context handle. + """ + def __init__(self, glContextHandle): + super(ContextGL2, self).__init__(glContextHandle) + + self._programs = {} # GL programs already compiled + self._vbos = {} # GL Vbos already set + self._vboGarbage = [] # Vbos waiting to be discarded + + # programs + + 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, 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 + + def makeVbo(self, data=None, sizeInBytes=None, + usage=None, target=None): + """Create a VBO in this context with the data. + + Current limitations: + + - One array per VBO + - Do not support sharing VertexBuffer across VboAttrib + + Automatically discards the VBO when the returned + :class:`VertexBuffer` istance is deleted. + + :param numpy.ndarray data: 2D array of data to store in VBO or None. + :param int sizeInBytes: Size of the VBO or None. + It should be <= data.nbytes if both are given. + :param usage: OpenGL usage define in VertexBuffer._USAGES. + :param target: OpenGL target in VertexBuffer._TARGETS. + :return: The VertexBuffer created in this context. + """ + assert self.isCurrent + vbo = _glutils.VertexBuffer(data, sizeInBytes, usage, target) + vboref = weakref.ref(vbo, self._deadVbo) + # weakref is hashable as far as target is + self._vbos[vboref] = vbo.name + return vbo + + def makeVboAttrib(self, data, usage=None, target=None): + """Create a VBO from data and returns the associated VBOAttrib. + + Automatically discards the VBO when the returned + :class:`VBOAttrib` istance is deleted. + + :param numpy.ndarray data: 2D array of data to store in VBO or None. + :param usage: OpenGL usage define in VertexBuffer._USAGES. + :param target: OpenGL target in VertexBuffer._TARGETS. + :returns: A VBOAttrib instance created in this context. + """ + assert self.isCurrent + vbo = self.makeVbo(data, usage=usage, target=target) + + assert len(data.shape) <= 2 + dimension = 1 if len(data.shape) == 1 else data.shape[1] + + return _glutils.VertexBufferAttrib( + vbo, + type_=_glutils.numpyToGLType(data.dtype), + size=data.shape[0], + dimension=dimension, + offset=0, + stride=0) + + def _deadVbo(self, vboRef): + """Callback handling dead VBOAttribs.""" + vboid = self._vbos.pop(vboRef) + if self.isCurrent: + # Direct delete if context is active + gl.glDeleteBuffers(vboid) + else: + # Deferred VBO delete if context is not active + self._vboGarbage.append(vboid) + + def cleanGLGarbage(self): + """Delete OpenGL resources that are pending for destruction. + + This requires the associated OpenGL context to be active. + This is meant to be called before rendering. + """ + assert self.isCurrent + if self._vboGarbage: + vboids = self._vboGarbage + gl.glDeleteBuffers(vboids) + self._vboGarbage = [] + + +class Window(event.Notifier): + """OpenGL Framebuffer where to render viewports + + :param str mode: Rendering mode to use: + + - 'direct' to render everything for each render call + - 'framebuffer' to cache viewport rendering in a texture and + update the texture only when needed. + """ + + _position = numpy.array(((-1., -1., 0., 0.), + (1., -1., 1., 0.), + (-1., 1., 0., 1.), + (1., 1., 1., 1.)), + dtype=numpy.float32) + + _shaders = (""" + attribute vec4 position; + varying vec2 textureCoord; + + void main(void) { + gl_Position = vec4(position.x, position.y, 0., 1.); + textureCoord = position.zw; + } + """, + """ + uniform sampler2D texture; + varying vec2 textureCoord; + + void main(void) { + gl_FragColor = texture2D(texture, textureCoord); + gl_FragColor.a = 1.0; + } + """) + + def __init__(self, mode='framebuffer'): + super(Window, self).__init__() + self._dirty = True + self._size = 0, 0 + self._contexts = {} # To map system GL context id to Context objects + self._viewports = event.NotifierList() + self._viewports.addListener(self._updated) + self._framebufferid = 0 + self._framebuffers = {} # Cache of framebuffers + + assert mode in ('direct', 'framebuffer') + self._isframebuffer = mode == 'framebuffer' + + @property + def dirty(self): + """True if this object or any attached viewports is dirty.""" + for viewport in self._viewports: + if viewport.dirty: + return True + return self._dirty + + @property + def size(self): + """Size (width, height) of the window in pixels""" + return self._size + + @size.setter + def size(self, size): + w, h = size + size = int(w), int(h) + if size != self._size: + self._size = size + self._dirty = True + self.notify() + + @property + def shape(self): + """Shape (height, width) of the window in pixels. + + This is a convenient wrapper to the reverse of size. + """ + return self._size[1], self._size[0] + + @shape.setter + def shape(self, shape): + self.size = shape[1], shape[0] + + @property + def viewports(self): + """List of viewports to render in the corresponding framebuffer""" + return self._viewports + + @viewports.setter + def viewports(self, iterable): + self._viewports.removeListener(self._updated) + self._viewports = event.NotifierList(iterable) + self._viewports.addListener(self._updated) + self._updated(self) + + def _updated(self, source, *args, **kwargs): + self._dirty = True + self.notify(*args, **kwargs) + + framebufferid = property(lambda self: self._framebufferid, + doc="Framebuffer ID used to perform rendering") + + def grab(self, glcontext): + """Returns the raster of the scene as an RGB numpy array + + :returns: OpenGL scene RGB bitmap + 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) + + previousFramebuffer = gl.glGetInteger(gl.GL_FRAMEBUFFER_BINDING) + gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, self.framebufferid) + gl.glPixelStorei(gl.GL_PACK_ALIGNMENT, 1) + gl.glReadPixels( + 0, 0, width, height, gl.GL_RGB, gl.GL_UNSIGNED_BYTE, image) + gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, previousFramebuffer) + + # glReadPixels gives bottom to top, + # while images are stored as top to bottom + image = numpy.flipud(image) + + return numpy.array(image, copy=False, order='C') + + def render(self, glcontext, devicePixelRatio): + """Perform the rendering of attached viewports + + :param glcontext: System identifier of the OpenGL context + :param float devicePixelRatio: + Ratio between device and device-independent pixels + """ + if self.size == (0, 0): + return + + if glcontext not in self._contexts: + self._contexts[glcontext] = ContextGL2(glcontext) # New context + + with self._contexts[glcontext] as context: + context.devicePixelRatio = devicePixelRatio + if self._isframebuffer: + self._renderWithOffscreenFramebuffer(context) + else: + self._renderDirect(context) + + self._dirty = False + + def _renderDirect(self, context): + """Perform the direct rendering of attached viewports + + :param Context context: Object wrapping OpenGL context + """ + for viewport in self._viewports: + viewport.framebuffer = self.framebufferid + viewport.render(context) + viewport.resetDirty() + + def _renderWithOffscreenFramebuffer(self, context): + """Renders viewports in a texture and render this texture on screen. + + The texture is updated only if viewport or size has changed. + + :param ContextGL2 context: Object wrappign OpenGL context + """ + if self.dirty or context not in self._framebuffers: + # Need to redraw framebuffer content + + if (context not in self._framebuffers or + self._framebuffers[context].shape != self.shape): + # Need to rebuild framebuffer + + if context in self._framebuffers: + self._framebuffers[context].discard() + + fbo = _glutils.FramebufferTexture(gl.GL_RGBA, + shape=self.shape, + minFilter=gl.GL_NEAREST, + magFilter=gl.GL_NEAREST, + wrap=gl.GL_CLAMP_TO_EDGE) + self._framebuffers[context] = fbo + self._framebufferid = fbo.name + + # Render in framebuffer + with self._framebuffers[context]: + self._renderDirect(context) + + # Render framebuffer texture to screen + fbo = self._framebuffers[context] + height, width = fbo.shape + + program = context.prog(*self._shaders) + program.use() + + gl.glViewport(0, 0, width, height) + gl.glDisable(gl.GL_BLEND) + gl.glDisable(gl.GL_DEPTH_TEST) + gl.glDisable(gl.GL_SCISSOR_TEST) + # gl.glScissor(0, 0, width, height) + gl.glClearColor(0., 0., 0., 0.) + gl.glClear(gl.GL_COLOR_BUFFER_BIT) + gl.glUniform1i(program.uniforms['texture'], fbo.texture.texUnit) + gl.glEnableVertexAttribArray(program.attributes['position']) + gl.glVertexAttribPointer(program.attributes['position'], + 4, + gl.GL_FLOAT, + gl.GL_FALSE, + 0, + self._position) + fbo.texture.bind() + gl.glDrawArrays(gl.GL_TRIANGLE_STRIP, 0, len(self._position)) + gl.glBindTexture(gl.GL_TEXTURE_2D, 0) diff --git a/src/silx/gui/plot3d/setup.py b/src/silx/gui/plot3d/setup.py new file mode 100644 index 0000000..59c0230 --- /dev/null +++ b/src/silx/gui/plot3d/setup.py @@ -0,0 +1,50 @@ +# coding: utf-8 +# /*########################################################################## +# +# 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 +# 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. +# +# ###########################################################################*/ +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "25/07/2016" + + +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('scene.test') + config.add_subpackage('tools') + config.add_subpackage('tools.test') + config.add_subpackage('test') + config.add_subpackage('utils') + return config + + +if __name__ == "__main__": + from numpy.distutils.core import setup + + setup(configuration=configuration) diff --git a/src/silx/gui/plot3d/test/__init__.py b/src/silx/gui/plot3d/test/__init__.py new file mode 100644 index 0000000..83491ad --- /dev/null +++ b/src/silx/gui/plot3d/test/__init__.py @@ -0,0 +1,25 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2015-2019 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. +# +# ###########################################################################*/ +"""plot3d test suite.""" diff --git a/src/silx/gui/plot3d/test/testGL.py b/src/silx/gui/plot3d/test/testGL.py new file mode 100644 index 0000000..a7309a9 --- /dev/null +++ b/src/silx/gui/plot3d/test/testGL.py @@ -0,0 +1,73 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2017 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# ###########################################################################*/ +"""Test OpenGL""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "10/08/2017" + + +import logging +import unittest + +from silx.gui._glutils import gl, OpenGLWidget +from silx.gui.utils.testutils import TestCaseQt +from silx.gui import qt + + +_logger = logging.getLogger(__name__) + + +class TestOpenGL(TestCaseQt): + """Tests of OpenGL widget.""" + + class OpenGLWidgetLogger(OpenGLWidget): + """Widget logging information of available OpenGL version""" + + def __init__(self): + self._dump = False + super(TestOpenGL.OpenGLWidgetLogger, self).__init__(version=(1, 0)) + + def paintOpenGL(self): + """Perform the rendering and logging""" + if not self._dump: + self._dump = True + _logger.info('OpenGL info:') + _logger.info('\tQt OpenGL context version: %d.%d', *self.getOpenGLVersion()) + _logger.info('\tGL_VERSION: %s' % gl.glGetString(gl.GL_VERSION)) + _logger.info('\tGL_SHADING_LANGUAGE_VERSION: %s' % + gl.glGetString(gl.GL_SHADING_LANGUAGE_VERSION)) + _logger.debug('\tGL_EXTENSIONS: %s' % gl.glGetString(gl.GL_EXTENSIONS)) + + gl.glClearColor(1., 1., 1., 1.) + gl.glClear(gl.GL_COLOR_BUFFER_BIT) + + def testOpenGL(self): + """Log OpenGL version using an OpenGLWidget""" + super(TestOpenGL, self).setUp() + widget = self.OpenGLWidgetLogger() + widget.show() + widget.setAttribute(qt.Qt.WA_DeleteOnClose) + self.qWaitForWindowExposed(widget) + widget.close() diff --git a/src/silx/gui/plot3d/test/testScalarFieldView.py b/src/silx/gui/plot3d/test/testScalarFieldView.py new file mode 100644 index 0000000..e6535fc --- /dev/null +++ b/src/silx/gui/plot3d/test/testScalarFieldView.py @@ -0,0 +1,128 @@ +# 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. +# ###########################################################################*/ +"""Test ScalarFieldView widget""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "17/01/2018" + + +import logging +import unittest + +import numpy + +from silx.utils.testutils import ParametricTestCase +from silx.gui.utils.testutils import TestCaseQt +from silx.gui import qt + +from silx.gui.plot3d.ScalarFieldView import ScalarFieldView +from silx.gui.plot3d.SFViewParamTree import TreeView + + +_logger = logging.getLogger(__name__) + + +class TestScalarFieldView(TestCaseQt, ParametricTestCase): + """Tests of ScalarFieldView widget.""" + + def setUp(self): + super(TestScalarFieldView, self).setUp() + self.widget = ScalarFieldView() + self.widget.show() + + paramTreeWidget = TreeView() + paramTreeWidget.setSfView(self.widget) + + dock = qt.QDockWidget() + dock.setWidget(paramTreeWidget) + self.widget.addDockWidget(qt.Qt.BottomDockWidgetArea, dock) + + # Commented as it slows down the tests + # self.qWaitForWindowExposed(self.widget) + + def tearDown(self): + self.qapp.processEvents() + self.widget.setAttribute(qt.Qt.WA_DeleteOnClose) + self.widget.close() + del self.widget + super(TestScalarFieldView, self).tearDown() + + @staticmethod + def _buildData(size): + """Make a 3D dataset""" + coords = numpy.linspace(-10, 10, size) + z = coords.reshape(-1, 1, 1) + y = coords.reshape(1, -1, 1) + x = coords.reshape(1, 1, -1) + return numpy.sin(x * y * z) / (x * y * z) + + def testSimple(self): + """Set the data and an isosurface""" + data = self._buildData(size=32) + + self.widget.setData(data) + self.widget.addIsosurface(0.5, (1., 0., 0., 0.5)) + self.widget.addIsosurface(0.7, qt.QColor('green')) + self.qapp.processEvents() + + def testNotFinite(self): + """Test with NaN and inf in data set""" + + # Some NaNs and inf + data = self._buildData(size=32) + data[8, :, :] = numpy.nan + data[16, :, :] = numpy.inf + data[24, :, :] = - numpy.inf + + self.widget.addIsosurface(0.5, 'red') + self.widget.setData(data, copy=True) + self.qapp.processEvents() + self.widget.setData(None) + + # All NaNs or inf + data = numpy.empty((4, 4, 4), dtype=numpy.float32) + for value in (numpy.nan, numpy.inf): + with self.subTest(value=str(value)): + data[:] = value + self.widget.setData(data, copy=True) + self.qapp.processEvents() + + def testIsoSliderNormalization(self): + """Test set TreeView with a different isoslider normalization""" + data = self._buildData(size=32) + + self.widget.setData(data) + self.widget.addIsosurface(0.5, (1., 0., 0., 0.5)) + self.widget.addIsosurface(0.7, qt.QColor('green')) + self.qapp.processEvents() + + # Add a second TreeView + paramTreeWidget = TreeView(self.widget) + paramTreeWidget.setIsoLevelSliderNormalization('arcsinh') + paramTreeWidget.setSfView(self.widget) + + dock = qt.QDockWidget() + dock.setWidget(paramTreeWidget) + self.widget.addDockWidget(qt.Qt.BottomDockWidgetArea, dock) diff --git a/src/silx/gui/plot3d/test/testSceneWidget.py b/src/silx/gui/plot3d/test/testSceneWidget.py new file mode 100644 index 0000000..fc96781 --- /dev/null +++ b/src/silx/gui/plot3d/test/testSceneWidget.py @@ -0,0 +1,72 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2019 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# ###########################################################################*/ +"""Test SceneWidget""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "06/03/2019" + + +import unittest + +import numpy + +from silx.utils.testutils import ParametricTestCase +from silx.gui.utils.testutils import TestCaseQt +from silx.gui import qt + +from silx.gui.plot3d.SceneWidget import SceneWidget + + +class TestSceneWidget(TestCaseQt, ParametricTestCase): + """Tests SceneWidget picking feature""" + + def setUp(self): + super(TestSceneWidget, self).setUp() + self.widget = SceneWidget() + self.widget.show() + self.qWaitForWindowExposed(self.widget) + + def tearDown(self): + self.qapp.processEvents() + self.widget.setAttribute(qt.Qt.WA_DeleteOnClose) + self.widget.close() + del self.widget + super(TestSceneWidget, self).tearDown() + + def testFogEffect(self): + """Test fog effect on scene primitive""" + image = self.widget.addImage(numpy.arange(100).reshape(10, 10)) + scatter = self.widget.add3DScatter(*numpy.random.random(4000).reshape(4, -1)) + scatter.setTranslation(10, 10) + scatter.setScale(10, 10, 10) + + self.widget.resetZoom('front') + self.qapp.processEvents() + + self.widget.setFogMode(self.widget.FogMode.LINEAR) + self.qapp.processEvents() + + self.widget.setFogMode(self.widget.FogMode.NONE) + self.qapp.processEvents() diff --git a/src/silx/gui/plot3d/test/testSceneWidgetPicking.py b/src/silx/gui/plot3d/test/testSceneWidgetPicking.py new file mode 100644 index 0000000..d4d8db7 --- /dev/null +++ b/src/silx/gui/plot3d/test/testSceneWidgetPicking.py @@ -0,0 +1,314 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2018-2019 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# ###########################################################################*/ +"""Test SceneWidget picking feature""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "03/10/2018" + + +import unittest + +import numpy + +from silx.utils.testutils import ParametricTestCase +from silx.gui.utils.testutils import TestCaseQt +from silx.gui import qt + +from silx.gui.plot3d.SceneWidget import SceneWidget, items + + +class TestSceneWidgetPicking(TestCaseQt, ParametricTestCase): + """Tests SceneWidget picking feature""" + + def setUp(self): + super(TestSceneWidgetPicking, self).setUp() + self.widget = SceneWidget() + self.widget.resize(300, 300) + self.widget.show() + # self.qWaitForWindowExposed(self.widget) + + def tearDown(self): + self.qapp.processEvents() + self.widget.setAttribute(qt.Qt.WA_DeleteOnClose) + self.widget.close() + del self.widget + super(TestSceneWidgetPicking, self).tearDown() + + def _widgetCenter(self): + """Returns widget center""" + size = self.widget.size() + return size.width() // 2, size.height() // 2 + + def testPickImage(self): + """Test picking of ImageData and ImageRgba items""" + imageData = items.ImageData() + imageData.setData(numpy.arange(100).reshape(10, 10)) + + imageRgba = items.ImageRgba() + imageRgba.setData( + numpy.arange(300, dtype=numpy.uint8).reshape(10, 10, 3)) + + for item in (imageData, imageRgba): + with self.subTest(item=item.__class__.__name__): + # Add item + self.widget.clearItems() + self.widget.addItem(item) + self.widget.resetZoom('front') + self.qapp.processEvents() + + # Picking on data (at widget center) + picking = list(self.widget.pickItems(*self._widgetCenter())) + + self.assertEqual(len(picking), 1) + self.assertIs(picking[0].getItem(), item) + self.assertEqual(picking[0].getPositions('ndc').shape, (1, 3)) + data = picking[0].getData() + self.assertEqual(len(data), 1) + self.assertTrue(numpy.array_equal( + data, + item.getData()[picking[0].getIndices()])) + + # Picking outside data + picking = list(self.widget.pickItems(1, 1)) + self.assertEqual(len(picking), 0) + + def testPickScatter(self): + """Test picking of Scatter2D and Scatter3D items""" + data = numpy.arange(100) + + scatter2d = items.Scatter2D() + scatter2d.setData(x=data, y=data, value=data) + + scatter3d = items.Scatter3D() + scatter3d.setData(x=data, y=data, z=data, value=data) + + for item in (scatter2d, scatter3d): + with self.subTest(item=item.__class__.__name__): + # Add item + self.widget.clearItems() + self.widget.addItem(item) + self.widget.resetZoom('front') + self.qapp.processEvents() + + # Picking on data (at widget center) + picking = list(self.widget.pickItems(*self._widgetCenter())) + + self.assertEqual(len(picking), 1) + self.assertIs(picking[0].getItem(), item) + nbPos = len(picking[0].getPositions('ndc')) + data = picking[0].getData() + self.assertEqual(nbPos, len(data)) + self.assertTrue(numpy.array_equal( + data, + item.getValueData()[picking[0].getIndices()])) + + # Picking outside data + picking = list(self.widget.pickItems(1, 1)) + self.assertEqual(len(picking), 0) + + def testPickVolume(self): + """Test picking of volume CutPlane and Isosurface items""" + for dtype in (numpy.float32, numpy.complex64): + with self.subTest(dtype=dtype): + refData = numpy.arange(10**3, dtype=dtype).reshape(10, 10, 10) + volume = self.widget.addVolume(refData) + if dtype == numpy.complex64: + volume.setComplexMode(volume.ComplexMode.REAL) + refData = numpy.real(refData) + self.widget.resetZoom('front') + + cutplane = volume.getCutPlanes()[0] + if dtype == numpy.complex64: + cutplane.setComplexMode(volume.ComplexMode.REAL) + cutplane.getColormap().setVRange(0, 100) + cutplane.setNormal((0, 0, 1)) + + # Picking on data without anything displayed + cutplane.setVisible(False) + picking = list(self.widget.pickItems(*self._widgetCenter())) + self.assertEqual(len(picking), 0) + + # Picking on data with the cut plane + cutplane.setVisible(True) + picking = list(self.widget.pickItems(*self._widgetCenter())) + + self.assertEqual(len(picking), 1) + self.assertIs(picking[0].getItem(), cutplane) + data = picking[0].getData() + self.assertEqual(len(data), 1) + self.assertEqual(picking[0].getPositions().shape, (1, 3)) + self.assertTrue(numpy.array_equal( + data, + refData[picking[0].getIndices()])) + + # Picking on data with an isosurface + isosurface = volume.addIsosurface( + level=500, color=(1., 0., 0., .5)) + picking = list(self.widget.pickItems(*self._widgetCenter())) + self.assertEqual(len(picking), 2) + self.assertIs(picking[0].getItem(), cutplane) + self.assertIs(picking[1].getItem(), isosurface) + self.assertEqual(picking[1].getPositions().shape, (1, 3)) + data = picking[1].getData() + self.assertEqual(len(data), 1) + self.assertTrue(numpy.array_equal( + data, + refData[picking[1].getIndices()])) + + # Picking outside data + picking = list(self.widget.pickItems(1, 1)) + self.assertEqual(len(picking), 0) + + self.widget.clearItems() + + def testPickMesh(self): + """Test picking of Mesh items""" + + triangles = items.Mesh() + triangles.setData( + position=((0, 0, 0), (1, 0, 0), (1, 1, 0), + (0, 0, 0), (1, 1, 0), (0, 1, 0)), + color=(1, 0, 0, 1), + mode='triangles') + triangleStrip = items.Mesh() + triangleStrip.setData( + position=(((1, 0, 0), (0, 0, 0), (1, 1, 0), (0, 1, 0))), + color=(0, 1, 0, 1), + mode='triangle_strip') + triangleFan = items.Mesh() + triangleFan.setData( + position=((0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)), + color=(0, 0, 1, 1), + mode='fan') + + for item in (triangles, triangleStrip, triangleFan): + with self.subTest(mode=item.getDrawMode()): + # Add item + self.widget.clearItems() + self.widget.addItem(item) + self.widget.resetZoom('front') + self.qapp.processEvents() + + # Picking on data (at widget center) + picking = list(self.widget.pickItems(*self._widgetCenter())) + + self.assertEqual(len(picking), 1) + self.assertIs(picking[0].getItem(), item) + nbPos = len(picking[0].getPositions()) + data = picking[0].getData() + self.assertEqual(nbPos, len(data)) + self.assertTrue(numpy.array_equal( + data, + item.getPositionData()[picking[0].getIndices()])) + + # Picking outside data + picking = list(self.widget.pickItems(1, 1)) + self.assertEqual(len(picking), 0) + + def testPickMeshWithIndices(self): + """Test picking of Mesh items defined by indices""" + + triangles = items.Mesh() + triangles.setData( + position=((0, 0, 0), (1, 0, 0), (0, 1, 0), (1, 1, 0)), + color=(1, 0, 0, 1), + indices=numpy.array( # dummy triangles and square + (0, 0, 1, 0, 1, 2, 1, 2, 3), dtype=numpy.uint8), + mode='triangles') + triangleStrip = items.Mesh() + triangleStrip.setData( + position=((0, 0, 0), (1, 0, 0), (0, 1, 0), (1, 1, 0)), + color=(0, 1, 0, 1), + indices=numpy.array( # dummy triangles and square + (1, 0, 0, 1, 2, 3), dtype=numpy.uint8), + mode='triangle_strip') + triangleFan = items.Mesh() + triangleFan.setData( + position=((0, 0, 0), (1, 0, 0), (0, 1, 0), (1, 1, 0)), + color=(0, 0, 1, 1), + indices=numpy.array( # dummy triangle, square, dummy + (1, 1, 0, 2, 3, 3), dtype=numpy.uint8), + mode='fan') + + for item in (triangles, triangleStrip, triangleFan): + with self.subTest(mode=item.getDrawMode()): + # Add item + self.widget.clearItems() + self.widget.addItem(item) + self.widget.resetZoom('front') + self.qapp.processEvents() + + # Picking on data (at widget center) + picking = list(self.widget.pickItems(*self._widgetCenter())) + + self.assertEqual(len(picking), 1) + self.assertIs(picking[0].getItem(), item) + nbPos = len(picking[0].getPositions()) + data = picking[0].getData() + self.assertEqual(nbPos, len(data)) + self.assertTrue(numpy.array_equal( + data, + item.getPositionData()[picking[0].getIndices()])) + + # Picking outside data + picking = list(self.widget.pickItems(1, 1)) + self.assertEqual(len(picking), 0) + + def testPickCylindricalMesh(self): + """Test picking of Box, Cylinder and Hexagon items""" + + positions = numpy.array(((0., 0., 0.), (1., 1., 0.), (2., 2., 0.))) + box = items.Box() + box.setData(position=positions) + cylinder = items.Cylinder() + cylinder.setData(position=positions) + hexagon = items.Hexagon() + hexagon.setData(position=positions) + + for item in (box, cylinder, hexagon): + with self.subTest(item=item.__class__.__name__): + # Add item + self.widget.clearItems() + self.widget.addItem(item) + self.widget.resetZoom('front') + self.qapp.processEvents() + + # Picking on data (at widget center) + picking = list(self.widget.pickItems(*self._widgetCenter())) + + self.assertEqual(len(picking), 1) + self.assertIs(picking[0].getItem(), item) + nbPos = len(picking[0].getPositions()) + data = picking[0].getData() + print(item.__class__.__name__, [positions[1]], data) + self.assertTrue(numpy.all(numpy.equal(positions[1], data))) + self.assertEqual(nbPos, len(data)) + self.assertTrue(numpy.array_equal( + data, + item.getPosition()[picking[0].getIndices()])) + + # Picking outside data + picking = list(self.widget.pickItems(1, 1)) + self.assertEqual(len(picking), 0) diff --git a/src/silx/gui/plot3d/test/testSceneWindow.py b/src/silx/gui/plot3d/test/testSceneWindow.py new file mode 100644 index 0000000..6b61335 --- /dev/null +++ b/src/silx/gui/plot3d/test/testSceneWindow.py @@ -0,0 +1,233 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2019-2021 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# ###########################################################################*/ +"""Test SceneWindow""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "22/03/2019" + + +import unittest + +import numpy + +from silx.utils.testutils import ParametricTestCase +from silx.gui.utils.testutils import TestCaseQt +from silx.gui import qt + +from silx.gui.plot3d.SceneWindow import SceneWindow +from silx.gui.plot3d.items import HeightMapData, HeightMapRGBA + +class TestSceneWindow(TestCaseQt, ParametricTestCase): + """Tests SceneWidget picking feature""" + + def setUp(self): + super(TestSceneWindow, self).setUp() + self.window = SceneWindow() + self.window.show() + self.qWaitForWindowExposed(self.window) + + def tearDown(self): + self.qapp.processEvents() + self.window.setAttribute(qt.Qt.WA_DeleteOnClose) + self.window.close() + del self.window + super(TestSceneWindow, self).tearDown() + + def testAdd(self): + """Test add basic scene primitive""" + sceneWidget = self.window.getSceneWidget() + items = [] + + # RGB image + image = sceneWidget.addImage(numpy.random.random( + 10*10*3).astype(numpy.float32).reshape(10, 10, 3)) + image.setLabel('RGB image') + items.append(image) + self.assertEqual(sceneWidget.getItems(), tuple(items)) + + # Data image + image = sceneWidget.addImage( + numpy.arange(100, dtype=numpy.float32).reshape(10, 10)) + image.setTranslation(10.) + items.append(image) + self.assertEqual(sceneWidget.getItems(), tuple(items)) + + # 2D scatter + scatter = sceneWidget.add2DScatter( + *numpy.random.random(3000).astype(numpy.float32).reshape(3, -1), + index=0) + scatter.setTranslation(0, 10) + scatter.setScale(10, 10, 10) + items.insert(0, scatter) + self.assertEqual(sceneWidget.getItems(), tuple(items)) + + # 3D scatter + scatter = sceneWidget.add3DScatter( + *numpy.random.random(4000).astype(numpy.float32).reshape(4, -1)) + scatter.setTranslation(10, 10) + scatter.setScale(10, 10, 10) + items.append(scatter) + self.assertEqual(sceneWidget.getItems(), tuple(items)) + + # 3D array of float + volume = sceneWidget.addVolume( + numpy.arange(10**3, dtype=numpy.float32).reshape(10, 10, 10)) + volume.setTranslation(0, 0, 10) + volume.setRotation(45, (0, 0, 1)) + volume.addIsosurface(500, 'red') + volume.getCutPlanes()[0].getColormap().setName('viridis') + items.append(volume) + self.assertEqual(sceneWidget.getItems(), tuple(items)) + + # 3D array of complex + volume = sceneWidget.addVolume( + numpy.arange(10**3).reshape(10, 10, 10).astype(numpy.complex64)) + volume.setTranslation(10, 0, 10) + volume.setRotation(45, (0, 0, 1)) + volume.setComplexMode(volume.ComplexMode.REAL) + volume.addIsosurface(500, (1., 0., 0., .5)) + items.append(volume) + self.assertEqual(sceneWidget.getItems(), tuple(items)) + + sceneWidget.resetZoom('front') + self.qapp.processEvents() + + def testHeightMap(self): + """Test height map items""" + sceneWidget = self.window.getSceneWidget() + + height = numpy.arange(10000).reshape(100, 100) /100. + + for shape in ((100, 100), (4, 5), (150, 20), (110, 110)): + with self.subTest(shape=shape): + items = [] + + # Colormapped data height map + data = numpy.arange(numpy.prod(shape)).astype(numpy.float32).reshape(shape) + + heightmap = HeightMapData() + heightmap.setData(height) + heightmap.setColormappedData(data) + heightmap.getColormap().setName('viridis') + items.append(heightmap) + sceneWidget.addItem(heightmap) + + # RGBA height map + colors = numpy.zeros(shape + (3,), dtype=numpy.float32) + colors[:, :, 1] = numpy.random.random(shape) + + heightmap = HeightMapRGBA() + heightmap.setData(height) + heightmap.setColorData(colors) + heightmap.setTranslation(100., 0., 0.) + items.append(heightmap) + sceneWidget.addItem(heightmap) + + self.assertEqual(sceneWidget.getItems(), tuple(items)) + sceneWidget.resetZoom('front') + self.qapp.processEvents() + sceneWidget.clearItems() + + def testChangeContent(self): + """Test add/remove/clear items""" + sceneWidget = self.window.getSceneWidget() + items = [] + + # Add 2 images + image = numpy.arange(100, dtype=numpy.float32).reshape(10, 10) + items.append(sceneWidget.addImage(image)) + items.append(sceneWidget.addImage(image)) + self.qapp.processEvents() + self.assertEqual(sceneWidget.getItems(), tuple(items)) + + # Clear + sceneWidget.clearItems() + self.qapp.processEvents() + self.assertEqual(sceneWidget.getItems(), ()) + + # Add 2 images and remove first one + image = numpy.arange(100, dtype=numpy.float32).reshape(10, 10) + sceneWidget.addImage(image) + items = (sceneWidget.addImage(image),) + self.qapp.processEvents() + + sceneWidget.removeItem(sceneWidget.getItems()[0]) + self.qapp.processEvents() + self.assertEqual(sceneWidget.getItems(), items) + + def testColors(self): + """Test setting scene colors""" + sceneWidget = self.window.getSceneWidget() + + color = qt.QColor(128, 128, 128) + sceneWidget.setBackgroundColor(color) + self.assertEqual(sceneWidget.getBackgroundColor(), color) + + color = qt.QColor(0, 0, 0) + sceneWidget.setForegroundColor(color) + self.assertEqual(sceneWidget.getForegroundColor(), color) + + color = qt.QColor(255, 0, 0) + sceneWidget.setTextColor(color) + self.assertEqual(sceneWidget.getTextColor(), color) + + color = qt.QColor(0, 255, 0) + sceneWidget.setHighlightColor(color) + self.assertEqual(sceneWidget.getHighlightColor(), color) + + self.qapp.processEvents() + + def testInteractiveMode(self): + """Test changing interactive mode""" + sceneWidget = self.window.getSceneWidget() + center = numpy.array((sceneWidget.width() //2, sceneWidget.height() // 2)) + + self.mouseMove(sceneWidget, pos=center) + self.mouseClick(sceneWidget, qt.Qt.LeftButton, pos=center) + + volume = sceneWidget.addVolume( + numpy.arange(10**3).astype(numpy.float32).reshape(10, 10, 10)) + sceneWidget.selection().setCurrentItem( volume.getCutPlanes()[0]) + sceneWidget.resetZoom('side') + + for mode in (None, 'rotate', 'pan', 'panSelectedPlane'): + with self.subTest(mode=mode): + sceneWidget.setInteractiveMode(mode) + self.qapp.processEvents() + self.assertEqual(sceneWidget.getInteractiveMode(), mode) + + self.mouseMove(sceneWidget, pos=center) + self.mousePress(sceneWidget, qt.Qt.LeftButton, pos=center) + self.mouseMove(sceneWidget, pos=center-10) + self.mouseMove(sceneWidget, pos=center-20) + self.mouseRelease(sceneWidget, qt.Qt.LeftButton, pos=center-20) + + self.keyPress(sceneWidget, qt.Qt.Key_Control) + self.mouseMove(sceneWidget, pos=center) + self.mousePress(sceneWidget, qt.Qt.LeftButton, pos=center) + self.mouseMove(sceneWidget, pos=center-10) + self.mouseMove(sceneWidget, pos=center-20) + self.mouseRelease(sceneWidget, qt.Qt.LeftButton, pos=center-20) + self.keyRelease(sceneWidget, qt.Qt.Key_Control) diff --git a/src/silx/gui/plot3d/test/testStatsWidget.py b/src/silx/gui/plot3d/test/testStatsWidget.py new file mode 100644 index 0000000..d452eb5 --- /dev/null +++ b/src/silx/gui/plot3d/test/testStatsWidget.py @@ -0,0 +1,201 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2019 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# ###########################################################################*/ +"""Test silx.gui.plot.StatsWidget with SceneWidget and ScalarFieldView""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "25/01/2019" + + +import unittest + +import numpy + +from silx.utils.testutils import ParametricTestCase +from silx.gui.utils.testutils import TestCaseQt +from silx.gui.plot.stats.stats import Stats +from silx.gui import qt + +from silx.gui.plot.StatsWidget import BasicStatsWidget + +from silx.gui.plot3d.ScalarFieldView import ScalarFieldView +from silx.gui.plot3d.SceneWidget import SceneWidget, items + + +class TestSceneWidget(TestCaseQt, ParametricTestCase): + """Tests StatsWidget combined with SceneWidget""" + + def setUp(self): + super(TestSceneWidget, self).setUp() + self.sceneWidget = SceneWidget() + self.sceneWidget.resize(300, 300) + self.sceneWidget.show() + self.statsWidget = BasicStatsWidget() + self.statsWidget.setPlot(self.sceneWidget) + # self.qWaitForWindowExposed(self.sceneWidget) + + def tearDown(self): + Stats._getContext.cache_clear() + self.qapp.processEvents() + self.sceneWidget.setAttribute(qt.Qt.WA_DeleteOnClose) + self.sceneWidget.close() + del self.sceneWidget + self.statsWidget.setAttribute(qt.Qt.WA_DeleteOnClose) + self.statsWidget.close() + del self.statsWidget + super(TestSceneWidget, self).tearDown() + + def test(self): + """Test StatsWidget with SceneWidget""" + # Prepare scene + + # Data image + image = self.sceneWidget.addImage(numpy.arange(100).reshape(10, 10)) + image.setLabel('Image') + # RGB image + imageRGB = self.sceneWidget.addImage( + numpy.arange(300, dtype=numpy.uint8).reshape(10, 10, 3)) + imageRGB.setLabel('RGB Image') + # 2D scatter + data = numpy.arange(100) + scatter2D = self.sceneWidget.add2DScatter(x=data, y=data, value=data) + scatter2D.setLabel('2D Scatter') + # 3D scatter + scatter3D = self.sceneWidget.add3DScatter(x=data, y=data, z=data, value=data) + scatter3D.setLabel('3D Scatter') + # Add a group + group = items.GroupItem() + self.sceneWidget.addItem(group) + # 3D scalar field + data = numpy.arange(64**3).reshape(64, 64, 64) + scalarField = items.ScalarField3D() + scalarField.setData(data, copy=False) + scalarField.setLabel('3D Scalar field') + group.addItem(scalarField) + + statsTable = self.statsWidget._getStatsTable() + + # Test selection only + self.statsWidget.setDisplayOnlyActiveItem(True) + self.assertEqual(statsTable.rowCount(), 0) + + self.sceneWidget.selection().setCurrentItem(group) + self.assertEqual(statsTable.rowCount(), 0) + + for item in (image, scatter2D, scatter3D, scalarField): + with self.subTest('selection only', item=item.getLabel()): + self.sceneWidget.selection().setCurrentItem(item) + self.assertEqual(statsTable.rowCount(), 1) + self._checkItem(item) + + # Test all data + self.statsWidget.setDisplayOnlyActiveItem(False) + self.assertEqual(statsTable.rowCount(), 4) + + for item in (image, scatter2D, scatter3D, scalarField): + with self.subTest('all items', item=item.getLabel()): + self._checkItem(item) + + def _checkItem(self, item): + """Check that item is in StatsTable and that stats are OK + + :param silx.gui.plot3d.items.Item3D item: + """ + if isinstance(item, (items.Scatter2D, items.Scatter3D)): + data = item.getValueData(copy=False) + else: + data = item.getData(copy=False) + + statsTable = self.statsWidget._getStatsTable() + tableItems = statsTable._itemToTableItems(item) + self.assertTrue(len(tableItems) > 0) + self.assertEqual(tableItems['legend'].text(), item.getLabel()) + self.assertEqual(float(tableItems['min'].text()), numpy.min(data)) + self.assertEqual(float(tableItems['max'].text()), numpy.max(data)) + # TODO + + +class TestScalarFieldView(TestCaseQt): + """Tests StatsWidget combined with ScalarFieldView""" + + def setUp(self): + super(TestScalarFieldView, self).setUp() + self.scalarFieldView = ScalarFieldView() + self.scalarFieldView.resize(300, 300) + self.scalarFieldView.show() + self.statsWidget = BasicStatsWidget() + self.statsWidget.setPlot(self.scalarFieldView) + # self.qWaitForWindowExposed(self.sceneWidget) + + def tearDown(self): + Stats._getContext.cache_clear() + self.qapp.processEvents() + self.scalarFieldView.setAttribute(qt.Qt.WA_DeleteOnClose) + self.scalarFieldView.close() + del self.scalarFieldView + self.statsWidget.setAttribute(qt.Qt.WA_DeleteOnClose) + self.statsWidget.close() + del self.statsWidget + super(TestScalarFieldView, self).tearDown() + + def _getTextFor(self, row, name): + """Returns text in table at given row for column name + + :param int row: Row number in the table + :param str name: Column id + :rtype: Union[str,None] + """ + statsTable = self.statsWidget._getStatsTable() + + for column in range(statsTable.columnCount()): + headerItem = statsTable.horizontalHeaderItem(column) + if headerItem.data(qt.Qt.UserRole) == name: + tableItem = statsTable.item(row, column) + return tableItem.text() + + return None + + def test(self): + """Test StatsWidget with ScalarFieldView""" + data = numpy.arange(64**3, dtype=numpy.float64).reshape(64, 64, 64) + self.scalarFieldView.setData(data) + + statsTable = self.statsWidget._getStatsTable() + + # Test selection only + self.statsWidget.setDisplayOnlyActiveItem(True) + self.assertEqual(statsTable.rowCount(), 1) + + # Test all data + self.statsWidget.setDisplayOnlyActiveItem(False) + self.assertEqual(statsTable.rowCount(), 1) + + for column in range(statsTable.columnCount()): + self.assertEqual(float(self._getTextFor(0, 'min')), numpy.min(data)) + self.assertEqual(float(self._getTextFor(0, 'max')), numpy.max(data)) + sum_ = numpy.sum(data) + comz = numpy.sum(numpy.arange(data.shape[0]) * numpy.sum(data, axis=(1, 2))) / sum_ + comy = numpy.sum(numpy.arange(data.shape[1]) * numpy.sum(data, axis=(0, 2))) / sum_ + comx = numpy.sum(numpy.arange(data.shape[2]) * numpy.sum(data, axis=(0, 1))) / sum_ + self.assertEqual(self._getTextFor(0, 'COM'), str((comx, comy, comz))) diff --git a/src/silx/gui/plot3d/tools/GroupPropertiesWidget.py b/src/silx/gui/plot3d/tools/GroupPropertiesWidget.py new file mode 100644 index 0000000..146c2cd --- /dev/null +++ b/src/silx/gui/plot3d/tools/GroupPropertiesWidget.py @@ -0,0 +1,202 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2018-2021 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +""":class:`GroupPropertiesWidget` allows to reset properties in a GroupItem.""" + +from __future__ import absolute_import + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "24/04/2018" + +from ....gui import qt +from ....gui.colors import Colormap +from ....gui.dialog.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.setGammaNormalizationParameter( + colormap.getGammaNormalizationParameter()) + 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/src/silx/gui/plot3d/tools/PositionInfoWidget.py b/src/silx/gui/plot3d/tools/PositionInfoWidget.py new file mode 100644 index 0000000..99d6356 --- /dev/null +++ b/src/silx/gui/plot3d/tools/PositionInfoWidget.py @@ -0,0 +1,225 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2018-2021 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""This module provides a widget that displays data values of a SceneWidget. +""" + +from __future__ import absolute_import + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "01/10/2018" + + +import logging +import weakref + +from ... import qt +from .. import actions +from .. import items +from ..items import volume +from ..SceneWidget import SceneWidget + + +_logger = logging.getLogger(__name__) + + +class PositionInfoWidget(qt.QWidget): + """Widget displaying information about picked position + + :param QWidget parent: See :class:`QWidget` + """ + + def __init__(self, parent=None): + super(PositionInfoWidget, self).__init__(parent) + self._sceneWidgetRef = None + + self.setToolTip("Double-click on a data point to show its value") + layout = qt.QBoxLayout(qt.QBoxLayout.LeftToRight, self) + + self._xLabel = self._addInfoField('X') + self._yLabel = self._addInfoField('Y') + self._zLabel = self._addInfoField('Z') + self._dataLabel = self._addInfoField('Data') + self._itemLabel = self._addInfoField('Item') + + layout.addStretch(1) + + self._action = actions.mode.PickingModeAction(parent=self) + self._action.setText('Selection') + self._action.setToolTip( + 'Toggle selection information update with left button click') + self._action.sigSceneClicked.connect(self.pick) + self._action.changed.connect(self.__actionChanged) + self._action.setChecked(False) # Disabled by default + self.__actionChanged() # Sync action/widget + + def __actionChanged(self): + """Handle toggle action change signal""" + if self.toggleAction().isChecked() != self.isEnabled(): + self.setEnabled(self.toggleAction().isChecked()) + + def toggleAction(self): + """The action to toggle the picking mode. + + :rtype: QAction + """ + return self._action + + def _addInfoField(self, label): + """Add a description: info widget to this widget + + :param str label: Description label + :return: The QLabel used to display the info + :rtype: QLabel + """ + subLayout = qt.QHBoxLayout() + subLayout.setContentsMargins(0, 0, 0, 0) + + subLayout.addWidget(qt.QLabel(label + ':')) + + widget = qt.QLabel('-') + widget.setAlignment(qt.Qt.AlignLeft | qt.Qt.AlignVCenter) + widget.setTextInteractionFlags(qt.Qt.TextSelectableByMouse) + + metrics = widget.fontMetrics() + if qt.BINDING in ('PySide2', 'PyQt5'): + width = metrics.width("#######") + else: # Qt6 + width = metrics.horizontalAdvance("#######") + widget.setMinimumWidth(width) + subLayout.addWidget(widget) + + subLayout.addStretch(1) + + layout = self.layout() + layout.addLayout(subLayout) + return widget + + def getSceneWidget(self): + """Returns the associated :class:`SceneWidget` or None. + + :rtype: Union[None,~silx.gui.plot3d.SceneWidget.SceneWidget] + """ + if self._sceneWidgetRef is None: + return None + else: + return self._sceneWidgetRef() + + def setSceneWidget(self, widget): + """Set the associated :class:`SceneWidget` + + :param ~silx.gui.plot3d.SceneWidget.SceneWidget widget: + 3D scene for which to display information + """ + if widget is not None and not isinstance(widget, SceneWidget): + raise ValueError("widget must be a SceneWidget or None") + + self._sceneWidgetRef = None if widget is None else weakref.ref(widget) + + self.toggleAction().setPlot3DWidget(widget) + + def clear(self): + """Clean-up displayed values""" + for widget in (self._xLabel, self._yLabel, self._zLabel, + self._dataLabel, self._itemLabel): + widget.setText('-') + + _SUPPORTED_ITEMS = (items.Scatter3D, + items.Scatter2D, + items.ImageData, + items.ImageRgba, + items.HeightMapData, + items.HeightMapRGBA, + items.Mesh, + items.Box, + items.Cylinder, + items.Hexagon, + volume.CutPlane, + volume.Isosurface) + """Type of items that are picked""" + + def _isSupportedItem(self, item): + """Returns True if item is of supported type + + :param Item3D item: The Item3D to check + :rtype: bool + """ + return isinstance(item, self._SUPPORTED_ITEMS) + + def pick(self, x, y): + """Pick items in the associated SceneWidget and display result + + Only the closest point is displayed. + + :param int x: X coordinate in pixel in the SceneWidget + :param int y: Y coordinate in pixel in the SceneWidget + """ + self.clear() + + sceneWidget = self.getSceneWidget() + if sceneWidget is None: # No associated widget + _logger.info('Picking without associated SceneWidget') + return + + # Find closest (and latest in the tree) supported item + closestNdcZ = float('inf') + picking = None + for result in sceneWidget.pickItems(x, y, + condition=self._isSupportedItem): + ndcZ = result.getPositions('ndc', copy=False)[0, 2] + if ndcZ <= closestNdcZ: + closestNdcZ = ndcZ + picking = result + + if picking is None: + return # No picked item + + item = picking.getItem() + self._itemLabel.setText(item.getLabel()) + positions = picking.getPositions('scene', copy=False) + x, y, z = positions[0] + self._xLabel.setText("%g" % x) + self._yLabel.setText("%g" % y) + self._zLabel.setText("%g" % z) + + data = picking.getData(copy=False) + if data is not None: + data = data[0] + if hasattr(data, '__len__'): + text = ' '.join(["%.3g"] * len(data)) % tuple(data) + else: + text = "%g" % data + self._dataLabel.setText(text) + + def updateInfo(self): + """Update information according to cursor position""" + widget = self.getSceneWidget() + if widget is None: + _logger.info('Update without associated SceneWidget') + self.clear() + return + + position = widget.mapFromGlobal(qt.QCursor.pos()) + self.pick(position.x(), position.y()) diff --git a/src/silx/gui/plot3d/tools/ViewpointTools.py b/src/silx/gui/plot3d/tools/ViewpointTools.py new file mode 100644 index 0000000..0607382 --- /dev/null +++ b/src/silx/gui/plot3d/tools/ViewpointTools.py @@ -0,0 +1,84 @@ +# coding: utf-8 +# /*########################################################################## +# +# 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 +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""This module provides a toolbar to control Plot3DWidget viewpoint.""" + +from __future__ import absolute_import + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "08/09/2017" + + +import weakref + +from silx.gui import qt +from silx.gui.icons import getQIcon +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` + """ + + def __init__(self, parent=None): + super(ViewpointToolButton, self).__init__(parent) + + self._plot3DRef = None + + menu = qt.QMenu(self) + 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/src/silx/gui/plot3d/tools/__init__.py b/src/silx/gui/plot3d/tools/__init__.py new file mode 100644 index 0000000..c8b8d21 --- /dev/null +++ b/src/silx/gui/plot3d/tools/__init__.py @@ -0,0 +1,34 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2017 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""This module provides tool widgets that can be attached to a plot3DWidget.""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "08/09/2017" + +from .toolbars import InteractiveModeToolBar # noqa +from .toolbars import OutputToolBar # noqa +from .toolbars import ViewpointToolBar # noqa +from .ViewpointTools import ViewpointToolButton # noqa diff --git a/src/silx/gui/plot3d/tools/test/__init__.py b/src/silx/gui/plot3d/tools/test/__init__.py new file mode 100644 index 0000000..86741ed --- /dev/null +++ b/src/silx/gui/plot3d/tools/test/__init__.py @@ -0,0 +1,25 @@ +# 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. +# +# ###########################################################################*/ +"""plot3d tools test suite.""" diff --git a/src/silx/gui/plot3d/tools/test/testPositionInfoWidget.py b/src/silx/gui/plot3d/tools/test/testPositionInfoWidget.py new file mode 100644 index 0000000..17fb3db --- /dev/null +++ b/src/silx/gui/plot3d/tools/test/testPositionInfoWidget.py @@ -0,0 +1,89 @@ +# 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. +# ###########################################################################*/ +"""Test PositionInfoWidget""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "03/10/2018" + + +import unittest + +import numpy + +from silx.gui.utils.testutils import TestCaseQt +from silx.gui import qt + +from silx.gui.plot3d.SceneWidget import SceneWidget +from silx.gui.plot3d.tools.PositionInfoWidget import PositionInfoWidget + + +class TestPositionInfoWidget(TestCaseQt): + """Tests PositionInfoWidget""" + + def setUp(self): + super(TestPositionInfoWidget, self).setUp() + self.sceneWidget = SceneWidget() + self.sceneWidget.resize(300, 300) + self.sceneWidget.show() + + self.positionInfoWidget = PositionInfoWidget() + self.positionInfoWidget.setSceneWidget(self.sceneWidget) + self.positionInfoWidget.show() + self.qWaitForWindowExposed(self.positionInfoWidget) + + # self.qWaitForWindowExposed(self.widget) + + def tearDown(self): + self.qapp.processEvents() + + self.sceneWidget.setAttribute(qt.Qt.WA_DeleteOnClose) + self.sceneWidget.close() + del self.sceneWidget + + self.positionInfoWidget.setAttribute(qt.Qt.WA_DeleteOnClose) + self.positionInfoWidget.close() + del self.positionInfoWidget + super(TestPositionInfoWidget, self).tearDown() + + def test(self): + """Test PositionInfoWidget""" + self.assertIs(self.positionInfoWidget.getSceneWidget(), + self.sceneWidget) + + data = numpy.arange(100) + self.sceneWidget.add2DScatter(x=data, y=data, value=data) + self.sceneWidget.resetZoom('front') + + # Double click at the center + self.mouseDClick(self.sceneWidget, button=qt.Qt.LeftButton) + + # Clear displayed value + self.positionInfoWidget.clear() + + # Update info from API + self.positionInfoWidget.pick(x=10, y=10) + + # Remove SceneWidget + self.positionInfoWidget.setSceneWidget(None) diff --git a/src/silx/gui/plot3d/tools/toolbars.py b/src/silx/gui/plot3d/tools/toolbars.py new file mode 100644 index 0000000..d4f32db --- /dev/null +++ b/src/silx/gui/plot3d/tools/toolbars.py @@ -0,0 +1,209 @@ +# coding: utf-8 +# /*########################################################################## +# +# 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 +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""This module provides toolbars with tools for a Plot3DWidget. + +It provides the following toolbars: + +- :class:`InteractiveModeToolBar` with: + - Set interactive mode to rotation + - Set interactive mode to pan + +- :class:`OutputToolBar` with: + - Copy + - Save + - Video + - Print +""" + +from __future__ import absolute_import + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "06/09/2017" + +import logging +import weakref + +from silx.gui import qt + +from .ViewpointTools import ViewpointToolButton +from .. import actions + +_logger = logging.getLogger(__name__) + + +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=''): + super(Plot3DWidgetToolBar, self).__init__(title, parent) + + self._plot3DRef = None + + def _plot3DWidgetChanged(self, widget): + """Handle change of Plot3DWidget and sync actions + + :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 control + """ + self._plot3DRef = None if widget is None else weakref.ref(widget) + self._plot3DWidgetChanged(widget) + + def getPlot3DWidget(self): + """Return the Plot3DWidget associated to this toolbar. + + If no widget is associated, it returns None. + + :rtype: qt.QWidget + """ + 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 + + :rtype: qt.QAction + """ + return self._rotateAction + + def getPanAction(self): + """Returns the QAction setting pan interaction of the Plot3DWidget + + :rtype: qt.QAction + """ + return self._panAction + + +class OutputToolBar(Plot3DWidgetToolBar): + """Toolbar providing icons to copy, save and print the OpenGL scene + + :param parent: See :class:`QWidget` + :param str title: Title of the toolbar. + """ + + def __init__(self, parent=None, title='Plot3D Output'): + super(OutputToolBar, self).__init__(parent, title) + + self._copyAction = actions.io.CopyAction(parent=self) + self.addAction(self._copyAction) + + self._saveAction = actions.io.SaveAction(parent=self) + self.addAction(self._saveAction) + + self._videoAction = actions.io.VideoAction(parent=self) + self.addAction(self._videoAction) + + self._printAction = actions.io.PrintAction(parent=self) + self.addAction(self._printAction) + + def getCopyAction(self): + """Returns the QAction performing copy to clipboard of the Plot3DWidget + + :rtype: qt.QAction + """ + return self._copyAction + + def getSaveAction(self): + """Returns the QAction performing save to file of the Plot3DWidget + + :rtype: qt.QAction + """ + return self._saveAction + + def getVideoRecordAction(self): + """Returns the QAction performing record video of the Plot3DWidget + + :rtype: qt.QAction + """ + return self._videoAction + + def getPrintAction(self): + """Returns the QAction performing printing of the Plot3DWidget + + :rtype: qt.QAction + """ + return self._printAction + + +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 diff --git a/src/silx/gui/plot3d/utils/__init__.py b/src/silx/gui/plot3d/utils/__init__.py new file mode 100644 index 0000000..99d3e08 --- /dev/null +++ b/src/silx/gui/plot3d/utils/__init__.py @@ -0,0 +1,28 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2016-2017 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "18/10/2016" diff --git a/src/silx/gui/plot3d/utils/mng.py b/src/silx/gui/plot3d/utils/mng.py new file mode 100644 index 0000000..8049a2f --- /dev/null +++ b/src/silx/gui/plot3d/utils/mng.py @@ -0,0 +1,121 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2016-2020 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 basic writing Mulitple-image Network Graphics files. + +It only supports RGB888 images of the same shape stored as +MNG-VLC (very low complexity) format. +""" + +from __future__ import absolute_import + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "15/12/2016" + + +import logging +import struct +import zlib + +import numpy + +_logger = logging.getLogger(__name__) + + +def _png_chunk(name, data): + """Return a PNG chunk + + :param str name: Chunk type + :param byte data: Chunk payload + """ + length = struct.pack('>I', len(data)) + name = [char.encode('ascii') for char in name] + chunk = struct.pack('cccc', *name) + data + crc = struct.pack('>I', zlib.crc32(chunk) & 0xffffffff) + return length + chunk + crc + + +def convert(images, nb_images=0, fps=25): + """Convert RGB images to MNG-VLC format. + + See http://www.libpng.org/pub/mng/spec/ + See http://www.libpng.org/pub/png/book/ + See http://www.libpng.org/pub/png/spec/1.2/ + + :param images: iterator of RGB888 images + :type images: iterator of numpy.ndarray of dimension 3 + :param int nb_images: The number of images indicated in the MNG header + :param int fps: The frame rate indicated in the MNG header + :return: An iterator of MNG chunks as bytes + """ + first_image = True + + for image in images: + if first_image: + first_image = False + + height, width = image.shape[:2] + + # MNG signature + yield b'\x8aMNG\r\n\x1a\n' + + # MHDR chunk: File header + yield _png_chunk('MHDR', struct.pack( + ">IIIIIII", + width, + height, + fps, # ticks + nb_images + 1, # layer count + nb_images, # frame count + nb_images, # play time + 1)) # profile: MNG-VLC no alpha: only least significant bit 1 + + assert image.shape == (height, width, 3) + assert image.dtype == numpy.dtype('uint8') + + # IHDR chunk: Image header + depth = 8 # 8 bit per channel + color_type = 2 # 'truecolor' = RGB + interlace = 0 # No + yield _png_chunk('IHDR', struct.pack(">IIBBBBB", + width, + height, + depth, + color_type, + 0, 0, interlace)) + + # Add filter 'None' before each scanline + prepared_data = b'\x00' + b'\x00'.join( + line.tobytes() for line in image) # TODO optimize that + compressed_data = zlib.compress(prepared_data, 8) + + # IDAT chunk: Payload + yield _png_chunk('IDAT', compressed_data) + + # IEND chunk: Image footer + yield _png_chunk('IEND', b'') + + # MEND chunk: footer + yield _png_chunk('MEND', b'') |