diff options
Diffstat (limited to 'silx/gui/plot3d')
60 files changed, 0 insertions, 24247 deletions
diff --git a/silx/gui/plot3d/ParamTreeView.py b/silx/gui/plot3d/ParamTreeView.py deleted file mode 100644 index 8cf2b90..0000000 --- a/silx/gui/plot3d/ParamTreeView.py +++ /dev/null @@ -1,546 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2017-2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -""" -This module provides a :class:`QTreeView` dedicated to display plot3d models. - -This module contains: -- :class:`ParamTreeView`: A QTreeView specific for plot3d parameters and scene. -- :class:`ParameterTreeDelegate`: The delegate for :class:`ParamTreeView`. -- A set of specific editors used by :class:`ParameterTreeDelegate`: - :class:`FloatEditor`, :class:`Vector3DEditor`, - :class:`Vector4DEditor`, :class:`IntSliderEditor`, :class:`BooleanEditor` -""" - -from __future__ import absolute_import - -__authors__ = ["T. Vincent"] -__license__ = "MIT" -__date__ = "05/12/2017" - - -import numbers -import sys - -import six - -from .. import qt -from ..widgets.FloatEdit import FloatEdit as _FloatEdit -from ._model import visitQAbstractItemModel - - -class FloatEditor(_FloatEdit): - """Editor widget for float. - - :param parent: The widget's parent - :param float value: The initial editor value - """ - - valueChanged = qt.Signal(float) - """Signal emitted when the float value has changed""" - - def __init__(self, parent=None, value=None): - super(FloatEditor, self).__init__(parent, value) - self.setAlignment(qt.Qt.AlignLeft) - self.editingFinished.connect(self._emit) - - def _emit(self): - self.valueChanged.emit(self.value) - - value = qt.Property(float, - fget=_FloatEdit.value, - fset=_FloatEdit.setValue, - user=True, - notify=valueChanged) - """Qt user property of the float value this widget edits""" - - -class Vector3DEditor(qt.QWidget): - """Editor widget for QVector3D. - - :param parent: The widget's parent - :param flags: The widgets's flags - """ - - valueChanged = qt.Signal(qt.QVector3D) - """Signal emitted when the QVector3D value has changed""" - - def __init__(self, parent=None, flags=qt.Qt.Widget): - super(Vector3DEditor, self).__init__(parent, flags) - layout = qt.QHBoxLayout(self) - # layout.setSpacing(0) - layout.setContentsMargins(0, 0, 0, 0) - self.setLayout(layout) - self._xEdit = _FloatEdit(parent=self, value=0.) - self._xEdit.setAlignment(qt.Qt.AlignLeft) - # self._xEdit.editingFinished.connect(self._emit) - self._yEdit = _FloatEdit(parent=self, value=0.) - self._yEdit.setAlignment(qt.Qt.AlignLeft) - # self._yEdit.editingFinished.connect(self._emit) - self._zEdit = _FloatEdit(parent=self, value=0.) - self._zEdit.setAlignment(qt.Qt.AlignLeft) - # self._zEdit.editingFinished.connect(self._emit) - layout.addWidget(qt.QLabel('x:')) - layout.addWidget(self._xEdit) - layout.addWidget(qt.QLabel('y:')) - layout.addWidget(self._yEdit) - layout.addWidget(qt.QLabel('z:')) - layout.addWidget(self._zEdit) - layout.addStretch(1) - - def _emit(self): - vector = self.value - self.valueChanged.emit(vector) - - def getValue(self): - """Returns the QVector3D value of this widget - - :rtype: QVector3D - """ - return qt.QVector3D( - self._xEdit.value(), self._yEdit.value(), self._zEdit.value()) - - def setValue(self, value): - """Set the QVector3D value - - :param QVector3D value: The new value - """ - self._xEdit.setValue(value.x()) - self._yEdit.setValue(value.y()) - self._zEdit.setValue(value.z()) - self.valueChanged.emit(value) - - value = qt.Property(qt.QVector3D, - fget=getValue, - fset=setValue, - user=True, - notify=valueChanged) - """Qt user property of the QVector3D value this widget edits""" - - -class Vector4DEditor(qt.QWidget): - """Editor widget for QVector4D. - - :param parent: The widget's parent - :param flags: The widgets's flags - """ - - valueChanged = qt.Signal(qt.QVector4D) - """Signal emitted when the QVector4D value has changed""" - - def __init__(self, parent=None, flags=qt.Qt.Widget): - super(Vector4DEditor, self).__init__(parent, flags) - layout = qt.QHBoxLayout(self) - # layout.setSpacing(0) - layout.setContentsMargins(0, 0, 0, 0) - self.setLayout(layout) - self._xEdit = _FloatEdit(parent=self, value=0.) - self._xEdit.setAlignment(qt.Qt.AlignLeft) - # self._xEdit.editingFinished.connect(self._emit) - self._yEdit = _FloatEdit(parent=self, value=0.) - self._yEdit.setAlignment(qt.Qt.AlignLeft) - # self._yEdit.editingFinished.connect(self._emit) - self._zEdit = _FloatEdit(parent=self, value=0.) - self._zEdit.setAlignment(qt.Qt.AlignLeft) - # self._zEdit.editingFinished.connect(self._emit) - self._wEdit = _FloatEdit(parent=self, value=0.) - self._wEdit.setAlignment(qt.Qt.AlignLeft) - # self._wEdit.editingFinished.connect(self._emit) - layout.addWidget(qt.QLabel('x:')) - layout.addWidget(self._xEdit) - layout.addWidget(qt.QLabel('y:')) - layout.addWidget(self._yEdit) - layout.addWidget(qt.QLabel('z:')) - layout.addWidget(self._zEdit) - layout.addWidget(qt.QLabel('w:')) - layout.addWidget(self._wEdit) - layout.addStretch(1) - - def _emit(self): - vector = self.value - self.valueChanged.emit(vector) - - def getValue(self): - """Returns the QVector4D value of this widget - - :rtype: QVector4D - """ - return qt.QVector4D(self._xEdit.value(), self._yEdit.value(), - self._zEdit.value(), self._wEdit.value()) - - def setValue(self, value): - """Set the QVector4D value - - :param QVector4D value: The new value - """ - self._xEdit.setValue(value.x()) - self._yEdit.setValue(value.y()) - self._zEdit.setValue(value.z()) - self._wEdit.setValue(value.w()) - self.valueChanged.emit(value) - - value = qt.Property(qt.QVector4D, - fget=getValue, - fset=setValue, - user=True, - notify=valueChanged) - """Qt user property of the QVector4D value this widget edits""" - - -class IntSliderEditor(qt.QSlider): - """Slider editor widget for integer. - - Note: Tracking is disabled. - - :param parent: The widget's parent - """ - - def __init__(self, parent=None): - super(IntSliderEditor, self).__init__(parent) - self.setOrientation(qt.Qt.Horizontal) - self.setSingleStep(1) - self.setRange(0, 255) - self.setValue(0) - - -class BooleanEditor(qt.QCheckBox): - """Checkbox editor for bool. - - This is a QCheckBox with white background. - - :param parent: The widget's parent - """ - - def __init__(self, parent=None): - super(BooleanEditor, self).__init__(parent) - self.setStyleSheet("background: white;") - - -class ParameterTreeDelegate(qt.QStyledItemDelegate): - """TreeView delegate specific to plot3d scene and object parameter tree. - - It provides additional editors. - - :param parent: Delegate's parent - """ - - EDITORS = { - bool: BooleanEditor, - float: FloatEditor, - qt.QVector3D: Vector3DEditor, - qt.QVector4D: Vector4DEditor, - } - """Specific editors for different type of data""" - - def __init__(self, parent=None): - super(ParameterTreeDelegate, self).__init__(parent) - - def _fixVariant(self, data): - """Fix PyQt4 zero vectors being stored as QPyNullVariant. - - :param data: Data retrieved from the model - :return: Corresponding object - """ - if qt.BINDING == 'PyQt4' and isinstance(data, qt.QPyNullVariant): - typeName = data.typeName() - if typeName == 'QVector3D': - data = qt.QVector3D() - elif typeName == 'QVector4D': - data = qt.QVector4D() - return data - - def paint(self, painter, option, index): - """See :meth:`QStyledItemDelegate.paint`""" - data = index.data(qt.Qt.DisplayRole) - data = self._fixVariant(data) - - if isinstance(data, (qt.QVector3D, qt.QVector4D)): - if isinstance(data, qt.QVector3D): - text = '(x: %g; y: %g; z: %g)' % (data.x(), data.y(), data.z()) - elif isinstance(data, qt.QVector4D): - text = '(%g; %g; %g; %g)' % (data.x(), data.y(), data.z(), data.w()) - else: - text = '' - - painter.save() - painter.setRenderHint(qt.QPainter.Antialiasing, True) - - # Select palette color group - colorGroup = qt.QPalette.Inactive - if option.state & qt.QStyle.State_Active: - colorGroup = qt.QPalette.Active - if not option.state & qt.QStyle.State_Enabled: - colorGroup = qt.QPalette.Disabled - - # Draw background if selected - if option.state & qt.QStyle.State_Selected: - brush = option.palette.brush(colorGroup, - qt.QPalette.Highlight) - painter.fillRect(option.rect, brush) - - # Draw text - if option.state & qt.QStyle.State_Selected: - colorRole = qt.QPalette.HighlightedText - else: - colorRole = qt.QPalette.WindowText - color = option.palette.color(colorGroup, colorRole) - painter.setPen(qt.QPen(color)) - painter.drawText(option.rect, qt.Qt.AlignLeft, text) - - painter.restore() - - # The following commented code does the same as QPainter based code - # but it does not work with PySide - # self.initStyleOption(option, index) - # option.text = text - # widget = option.widget - # style = qt.QApplication.style() if not widget else widget.style() - # style.drawControl(qt.QStyle.CE_ItemViewItem, option, painter, widget) - - else: - super(ParameterTreeDelegate, self).paint(painter, option, index) - - def _commit(self, *args): - """Commit data to the model from editors""" - sender = self.sender() - self.commitData.emit(sender) - - def editorEvent(self, event, model, option, index): - """See :meth:`QStyledItemDelegate.editorEvent`""" - if (event.type() == qt.QEvent.MouseButtonPress and - isinstance(index.data(qt.Qt.EditRole), qt.QColor)): - initialColor = index.data(qt.Qt.EditRole) - - def callback(color): - theModel = index.model() - theModel.setData(index, color, qt.Qt.EditRole) - - dialog = qt.QColorDialog(self.parent()) - # dialog.setOption(qt.QColorDialog.ShowAlphaChannel, True) - if sys.platform == 'darwin': - # Use of native color dialog on macos might cause problems - dialog.setOption(qt.QColorDialog.DontUseNativeDialog, True) - dialog.setCurrentColor(initialColor) - dialog.currentColorChanged.connect(callback) - if dialog.exec_() == qt.QDialog.Rejected: - # Reset color - dialog.setCurrentColor(initialColor) - - return True - else: - return super(ParameterTreeDelegate, self).editorEvent( - event, model, option, index) - - def createEditor(self, parent, option, index): - """See :meth:`QStyledItemDelegate.createEditor`""" - data = index.data(qt.Qt.EditRole) - data = self._fixVariant(data) - editorHint = index.data(qt.Qt.UserRole) - - if callable(editorHint): - editor = editorHint() - assert isinstance(editor, qt.QWidget) - editor.setParent(parent) - - elif isinstance(data, 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, six.string_types) and editorHint is not None: - # Use a combo box - editor = qt.QComboBox(parent) - if data not in editorHint: - editor.addItem(data) - editor.addItems(editorHint) - - index = editor.findText(data) - editor.setCurrentIndex(index) - - editor.currentIndexChanged.connect(self._commit) - - else: - # Handle overridden editors from Python - # Mimic Qt C++ implementation - for type_, editorClass in self.EDITORS.items(): - if isinstance(data, type_): - editor = editorClass(parent) - metaObject = editor.metaObject() - userProperty = metaObject.userProperty() - if userProperty.isValid() and userProperty.hasNotifySignal(): - notifySignal = userProperty.notifySignal() - if hasattr(notifySignal, 'signature'): # Qt4 - signature = notifySignal.signature() - else: - signature = 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 - if hasattr(header, 'setSectionResizeMode'): # Qt5 - header.setSectionResizeMode(qt.QHeaderView.ResizeToContents) - else: # Qt4 - header.setResizeMode(qt.QHeaderView.ResizeToContents) - - delegate = ParameterTreeDelegate() - self.setItemDelegate(delegate) - - self.setSelectionBehavior(qt.QAbstractItemView.SelectRows) - self.setSelectionMode(qt.QAbstractItemView.SingleSelection) - - self.expanded.connect(self._expanded) - - self.setEditTriggers(qt.QAbstractItemView.CurrentChanged | - qt.QAbstractItemView.DoubleClicked) - - self.__persistentEditors = set() - - def _openEditorForIndex(self, index): - """Check if it has to open a persistent editor for a specific cell. - - :param QModelIndex index: The cell index - """ - if index.flags() & qt.Qt.ItemIsEditable: - data = index.data(qt.Qt.EditRole) - editorHint = index.data(qt.Qt.UserRole) - if (isinstance(data, bool) or - callable(editorHint) or - (isinstance(data, 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/silx/gui/plot3d/Plot3DWidget.py b/silx/gui/plot3d/Plot3DWidget.py deleted file mode 100644 index f512cd8..0000000 --- a/silx/gui/plot3d/Plot3DWidget.py +++ /dev/null @@ -1,460 +0,0 @@ -# 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 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 - """ - - @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] - self.sigSceneClicked.emit(x, y) - - 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): - xpixel = event.x() * self.getDevicePixelRatio() - ypixel = event.y() * self.getDevicePixelRatio() - if hasattr(event, 'delta'): # Qt4 - angle = event.delta() / 8. - else: # Qt5 - angle = event.angleDelta().y() / 8. - event.accept() - - if 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/silx/gui/plot3d/Plot3DWindow.py b/silx/gui/plot3d/Plot3DWindow.py deleted file mode 100644 index 470b966..0000000 --- a/silx/gui/plot3d/Plot3DWindow.py +++ /dev/null @@ -1,88 +0,0 @@ -# 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/silx/gui/plot3d/SFViewParamTree.py b/silx/gui/plot3d/SFViewParamTree.py deleted file mode 100644 index 4e179fc..0000000 --- a/silx/gui/plot3d/SFViewParamTree.py +++ /dev/null @@ -1,1817 +0,0 @@ -# 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 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() - if hasattr(header, 'setSectionResizeMode'): # Qt5 - header.setSectionResizeMode(qt.QHeaderView.ResizeToContents) - else: # Qt4 - header.setResizeMode(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/silx/gui/plot3d/ScalarFieldView.py b/silx/gui/plot3d/ScalarFieldView.py deleted file mode 100644 index b2bb254..0000000 --- a/silx/gui/plot3d/ScalarFieldView.py +++ /dev/null @@ -1,1552 +0,0 @@ -# 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/silx/gui/plot3d/SceneWidget.py b/silx/gui/plot3d/SceneWidget.py deleted file mode 100644 index 883f5e7..0000000 --- a/silx/gui/plot3d/SceneWidget.py +++ /dev/null @@ -1,687 +0,0 @@ -# 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/silx/gui/plot3d/SceneWindow.py b/silx/gui/plot3d/SceneWindow.py deleted file mode 100644 index 052a4dc..0000000 --- a/silx/gui/plot3d/SceneWindow.py +++ /dev/null @@ -1,219 +0,0 @@ -# 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/silx/gui/plot3d/__init__.py b/silx/gui/plot3d/__init__.py deleted file mode 100644 index af74613..0000000 --- a/silx/gui/plot3d/__init__.py +++ /dev/null @@ -1,40 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2015-2017 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -""" -This 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/silx/gui/plot3d/_model/__init__.py b/silx/gui/plot3d/_model/__init__.py deleted file mode 100644 index 4b16e32..0000000 --- a/silx/gui/plot3d/_model/__init__.py +++ /dev/null @@ -1,35 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2017-2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -""" -This package provides :class:`SceneWidget` content and parameters model. -""" - -from __future__ import absolute_import - -__authors__ = ["T. Vincent"] -__license__ = "MIT" -__date__ = "11/01/2018" - -from .model import SceneModel, visitQAbstractItemModel # noqa diff --git a/silx/gui/plot3d/_model/core.py b/silx/gui/plot3d/_model/core.py deleted file mode 100644 index e8e0820..0000000 --- a/silx/gui/plot3d/_model/core.py +++ /dev/null @@ -1,372 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2017-2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -""" -This module provides base classes to implement models for 3D scene content. -""" - -from __future__ import absolute_import, division - -__authors__ = ["T. Vincent"] -__license__ = "MIT" -__date__ = "11/01/2018" - - -import collections -import weakref - -from ....utils.weakref import WeakMethodProxy -from ... import qt - - -class BaseRow(qt.QObject): - """Base class for rows of the tree model. - - The root node parent MUST be set to the QAbstractItemModel it belongs to. - By default item is enabled. - - :param children: Iterable of BaseRow to start with (not signaled) - """ - - def __init__(self, children=()): - self.__modelRef = None - self.__parentRef = None - super(BaseRow, self).__init__() - self.__children = [] - for row in children: - assert isinstance(row, BaseRow) - row.setParent(self) - self.__children.append(row) - self.__flags = collections.defaultdict(lambda: qt.Qt.ItemIsEnabled) - self.__tooltip = None - - def setParent(self, parent): - """Override :meth:`QObject.setParent` to cache model and parent""" - self.__parentRef = None if parent is None else weakref.ref(parent) - - if isinstance(parent, qt.QAbstractItemModel): - model = parent - elif isinstance(parent, BaseRow): - model = parent.model() - else: - model = None - - self._updateModel(model) - - super(BaseRow, self).setParent(parent) - - def parent(self): - """Override :meth:`QObject.setParent` to use cached parent - - :rtype: Union[QObject, None]""" - return self.__parentRef() if self.__parentRef is not None else None - - def _updateModel(self, model): - """Update the model this row belongs to""" - if model != self.model(): - self.__modelRef = weakref.ref(model) if model is not None else None - for child in self.children(): - child._updateModel(model) - - def model(self): - """Return the model this node belongs to or None if not in a model. - - :rtype: Union[QAbstractItemModel, None] - """ - return self.__modelRef() if self.__modelRef is not None else None - - def index(self, column=0): - """Return corresponding index in the model or None if not in a model. - - :param int column: The column to make the index for - :rtype: Union[QModelIndex, None] - """ - parent = self.parent() - model = self.model() - - if model is None: # Not in a model - return None - elif parent is model: # Root node - return qt.QModelIndex() - else: - index = parent.index() - row = parent.children().index(self) - return model.index(row, column, index) - - def columnCount(self): - """Returns number of columns (default: 2) - - :rtype: int - """ - return 2 - - def children(self): - """Returns the list of children nodes - - :rtype: tuple of Node - """ - return tuple(self.__children) - - def rowCount(self): - """Returns number of rows - - :rtype: int - """ - return len(self.__children) - - def addRow(self, row, index=None): - """Add a node to the children - - :param BaseRow row: The node to add - :param int index: The index at which to insert it or - None to append - """ - if index is None: - index = self.rowCount() - assert index <= self.rowCount() - - model = self.model() - - if model is not None: - parent = self.index() - model.beginInsertRows(parent, index, index) - - self.__children.insert(index, row) - row.setParent(self) - - if model is not None: - model.endInsertRows() - - def removeRow(self, row): - """Remove a row from the children list. - - It removes either a node or a row index. - - :param row: BaseRow object or index of row to remove - :type row: Union[BaseRow, int] - """ - if isinstance(row, BaseRow): - row = self.__children.index(row) - else: - row = int(row) - assert row < self.rowCount() - - model = self.model() - - if model is not None: - index = self.index() - model.beginRemoveRows(index, row, row) - - node = self.__children.pop(row) - node.setParent(None) - - if model is not None: - model.endRemoveRows() - - def data(self, column, role): - """Returns data for given column and role - - :param int column: Column index for this row - :param int role: The role to get - :return: Corresponding data (Default: None) - """ - if role == qt.Qt.ToolTipRole and self.__tooltip is not None: - return self.__tooltip - else: - return None - - def setData(self, column, value, role): - """Set data for given column and role - - :param int column: Column index for this row - :param value: The data to set - :param int role: The role to set - :return: True on success, False on failure - :rtype: bool - """ - return False - - def setToolTip(self, tooltip): - """Set the tooltip of the whole row. - - If None there is no tooltip. - - :param Union[str, None] tooltip: - """ - self.__tooltip = tooltip - - def setFlags(self, flags, column=None): - """Set the static flags to return. - - Default is ItemIsEnabled for all columns. - - :param int column: The column for which to set the flags - :param flags: Item flags - """ - if column is None: - self.__flags = collections.defaultdict(lambda: flags) - else: - self.__flags[column] = flags - - def flags(self, column): - """Returns flags for given column - - :rtype: int - """ - return self.__flags[column] - - -class StaticRow(BaseRow): - """Row with static data. - - :param tuple display: List of data for DisplayRole for each column - :param dict roles: Optional mapping of roles to list of data. - :param children: Iterable of BaseRow to start with (not signaled) - """ - - def __init__(self, display=('', None), roles=None, children=()): - super(StaticRow, self).__init__(children) - self._dataByRoles = {} if roles is None else roles - self._dataByRoles[qt.Qt.DisplayRole] = display - - def data(self, column, role): - if role in self._dataByRoles: - data = self._dataByRoles[role] - if column < len(data): - return data[column] - return super(StaticRow, self).data(column, role) - - def columnCount(self): - return len(self._dataByRoles[qt.Qt.DisplayRole]) - - -class ProxyRow(BaseRow): - """Provides a node to proxy a data accessible through functions. - - Warning: Only weak reference are kept on fget and fset. - - :param str name: The name of this node - :param callable fget: A callable returning the data - :param callable fset: - An optional callable setting the data with data as a single argument. - :param notify: - An optional signal emitted when data has changed. - :param callable toModelData: - An optional callable to convert from fget - callable to data returned by the model. - :param callable fromModelData: - An optional callable converting data provided to the model to - data for fset. - :param editorHint: Data to provide as UserRole for editor selection/setup - """ - - def __init__(self, - name='', - fget=None, - fset=None, - notify=None, - toModelData=None, - fromModelData=None, - editorHint=None): - - super(ProxyRow, self).__init__() - self.__name = name - self.__editorHint = editorHint - - assert fget is not None - self._fget = WeakMethodProxy(fget) - self._fset = WeakMethodProxy(fset) if fset is not None else None - if fset is not None: - self.setFlags(qt.Qt.ItemIsEnabled | qt.Qt.ItemIsEditable, 1) - self._toModelData = toModelData - self._fromModelData = fromModelData - - if notify is not None: - notify.connect(self._notified) # TODO support sigItemChanged flags - - def _notified(self, *args, **kwargs): - """Send update to the model upon signal notifications""" - index = self.index(column=1) - model = self.model() - if model is not None: - model.dataChanged.emit(index, index) - - def data(self, column, role): - if column == 0: - if role == qt.Qt.DisplayRole: - return self.__name - - elif column == 1: - if role == qt.Qt.UserRole: # EditorHint - return self.__editorHint - elif role == qt.Qt.DisplayRole or (role == qt.Qt.EditRole and - self._fset is not None): - data = self._fget() - if self._toModelData is not None: - data = self._toModelData(data) - return data - - return super(ProxyRow, self).data(column, role) - - def setData(self, column, value, role): - if role == qt.Qt.EditRole and self._fset is not None: - if self._fromModelData is not None: - value = self._fromModelData(value) - self._fset(value) - return True - - return super(ProxyRow, self).setData(column, value, role) - - -class ColorProxyRow(ProxyRow): - """Provides a proxy to a QColor property. - - The color is returned through the decorative role. - - See :class:`ProxyRow` - """ - - def data(self, column, role): - if column == 1: # Show color as decoration, not text - if role == qt.Qt.DisplayRole: - return None - if role == qt.Qt.DecorationRole: - role = qt.Qt.DisplayRole - return super(ColorProxyRow, self).data(column, role) - - -class AngleDegreeRow(ProxyRow): - """ProxyRow patching display of column 1 to add degree symbol - - See :class:`ProxyRow` - """ - - def __init__(self, *args, **kwargs): - super(AngleDegreeRow, self).__init__(*args, **kwargs) - - def data(self, column, role): - if column == 1 and role == qt.Qt.DisplayRole: - return u'%g°' % super(AngleDegreeRow, self).data(column, role) - else: - return super(AngleDegreeRow, self).data(column, role) diff --git a/silx/gui/plot3d/_model/items.py b/silx/gui/plot3d/_model/items.py deleted file mode 100644 index be51663..0000000 --- a/silx/gui/plot3d/_model/items.py +++ /dev/null @@ -1,1760 +0,0 @@ -# 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 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 -import six - -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 six.text_type(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, six.string_types): - return value.title() - elif value == 0.: - return 'Origin' - else: - return six.text_type(value) - - def _setCenter(self, value, index): - """Set one dimension of the rotation center. - - :param value: Value received through the model. - :param int index: dimension to set - """ - item = self.item() - if item is not None: - if value == 'Origin': - value = 0. - elif value not in self._ROTATION_CENTER_OPTIONS: - value = float(value) - else: - value = value.lower() - - center = list(item.getRotationCenter()) - center[index] = value - item.setRotationCenter(*center) - - def _setAngle(self, angle): - """Set rotation angle. - - :param float angle: - """ - item = self.item() - if item is not None: - _, axis = item.getRotation() - item.setRotation(angle, axis) - - def _setAxis(self, axis): - """Set rotation axis. - - :param QVector3D axis: - """ - item = self.item() - if item is not None: - angle, _ = item.getRotation() - item.setRotation(angle, (axis.x(), axis.y(), axis.z())) - - def _setTranslation(self, translation): - """Set translation transform. - - :param QVector3D translation: - """ - item = self.item() - if item is not None: - item.setTranslation(translation.x(), translation.y(), translation.z()) - - def _setScale(self, scale): - """Set scale transform. - - :param QVector3D scale: - """ - item = self.item() - if item is not None: - 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/silx/gui/plot3d/_model/model.py b/silx/gui/plot3d/_model/model.py deleted file mode 100644 index 186838f..0000000 --- a/silx/gui/plot3d/_model/model.py +++ /dev/null @@ -1,184 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2017-2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -""" -This module provides the :class:`SceneWidget` content and parameters model. -""" - -from __future__ import absolute_import, division - -__authors__ = ["T. Vincent"] -__license__ = "MIT" -__date__ = "11/01/2018" - - -import weakref - -from ... import qt - -from .core import BaseRow -from .items import Settings, nodeFromItem - - -def visitQAbstractItemModel(model, parent=qt.QModelIndex()): - """Iterate over indices in the model starting from parent - - It iterates column by column and row by row - (i.e., from left to right and from top to bottom). - Parent are returned before their children. - It only iterates through the children for the first column of a row. - - :param QAbstractItemModel model: The model to visit - :param QModelIndex parent: - Index from which to start visiting the model. - Default: start from the root - """ - assert isinstance(model, qt.QAbstractItemModel) - assert isinstance(parent, qt.QModelIndex) - assert parent.model() is model or not parent.isValid() - - for row in range(model.rowCount(parent)): - for column in range(model.columnCount(parent)): - index = model.index(row, column, parent) - yield index - - index = model.index(row, 0, parent) - for index in visitQAbstractItemModel(model, index): - yield index - - -class Root(BaseRow): - """Root node of :class:`SceneWidget` parameters. - - It has two children: - - Settings - - Scene group - """ - - def __init__(self, model, sceneWidget): - super(Root, self).__init__() - self._sceneWidget = weakref.ref(sceneWidget) - self.setParent(model) # Needed for Root - - def children(self): - sceneWidget = self._sceneWidget() - if sceneWidget is None: - return () - else: - return super(Root, self).children() - - -class SceneModel(qt.QAbstractItemModel): - """Model of a :class:`SceneWidget`. - - :param SceneWidget parent: The SceneWidget this model represents. - """ - - def __init__(self, parent): - self._sceneWidget = weakref.ref(parent) - - super(SceneModel, self).__init__(parent) - self._root = Root(self, parent) - self._root.addRow(Settings(parent)) - self._root.addRow(nodeFromItem(parent.getSceneGroup())) - - def sceneWidget(self): - """Returns the :class:`SceneWidget` this model represents. - - In case the widget has already been deleted, it returns None - - :rtype: SceneWidget - """ - return self._sceneWidget() - - def _itemFromIndex(self, index): - """Returns the corresponding :class:`Node` or :class:`Item3D`. - - :param QModelIndex index: - :rtype: Node or Item3D - """ - return index.internalPointer() if index.isValid() else self._root - - def index(self, row, column, parent=qt.QModelIndex()): - """See :meth:`QAbstractItemModel.index`""" - if column >= self.columnCount(parent) or row >= self.rowCount(parent): - return qt.QModelIndex() - - item = self._itemFromIndex(parent) - return self.createIndex(row, column, item.children()[row]) - - def parent(self, index): - """See :meth:`QAbstractItemModel.parent`""" - if not index.isValid(): - return qt.QModelIndex() - - item = self._itemFromIndex(index) - parent = item.parent() - - ancestor = parent.parent() - - if ancestor is not self: # root node - children = ancestor.children() - row = children.index(parent) - return self.createIndex(row, 0, parent) - - return qt.QModelIndex() - - def rowCount(self, parent=qt.QModelIndex()): - """See :meth:`QAbstractItemModel.rowCount`""" - item = self._itemFromIndex(parent) - return item.rowCount() - - def columnCount(self, parent=qt.QModelIndex()): - """See :meth:`QAbstractItemModel.columnCount`""" - item = self._itemFromIndex(parent) - return item.columnCount() - - def data(self, index, role=qt.Qt.DisplayRole): - """See :meth:`QAbstractItemModel.data`""" - item = self._itemFromIndex(index) - column = index.column() - return item.data(column, role) - - def setData(self, index, value, role=qt.Qt.EditRole): - """See :meth:`QAbstractItemModel.setData`""" - item = self._itemFromIndex(index) - column = index.column() - if item.setData(column, value, role): - self.dataChanged.emit(index, index) - return True - return False - - def flags(self, index): - """See :meth:`QAbstractItemModel.flags`""" - item = self._itemFromIndex(index) - column = index.column() - return item.flags(column) - - def headerData(self, section, orientation, role=qt.Qt.DisplayRole): - """See :meth:`QAbstractItemModel.headerData`""" - if orientation == qt.Qt.Horizontal and role == qt.Qt.DisplayRole: - return 'Item' if section == 0 else 'Value' - else: - return None diff --git a/silx/gui/plot3d/actions/Plot3DAction.py b/silx/gui/plot3d/actions/Plot3DAction.py deleted file mode 100644 index 94b9572..0000000 --- a/silx/gui/plot3d/actions/Plot3DAction.py +++ /dev/null @@ -1,71 +0,0 @@ -# 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/silx/gui/plot3d/actions/__init__.py b/silx/gui/plot3d/actions/__init__.py deleted file mode 100644 index 26243cf..0000000 --- a/silx/gui/plot3d/actions/__init__.py +++ /dev/null @@ -1,34 +0,0 @@ -# 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/silx/gui/plot3d/actions/io.py b/silx/gui/plot3d/actions/io.py deleted file mode 100644 index 4020d6f..0000000 --- a/silx/gui/plot3d/actions/io.py +++ /dev/null @@ -1,336 +0,0 @@ -# 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 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 - - if (printer.pageRect().width() < image.width() or - printer.pageRect().height() < image.height()): - # Downscale to page - xScale = printer.pageRect().width() / image.width() - yScale = printer.pageRect().height() / image.height() - scale = min(xScale, yScale) - else: - scale = 1. - - rect = qt.QRectF(0, - 0, - scale * image.width(), - scale * image.height()) - painter.drawImage(rect, image) - painter.end() - - -class VideoAction(Plot3DAction): - """This action triggers the recording of a video of the scene. - - The scene is rotated 360 degrees around a vertical axis. - - :param parent: Action parent see :class:`QAction`. - :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/silx/gui/plot3d/actions/mode.py b/silx/gui/plot3d/actions/mode.py deleted file mode 100644 index ce09b4c..0000000 --- a/silx/gui/plot3d/actions/mode.py +++ /dev/null @@ -1,178 +0,0 @@ -# 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 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 - """ - - 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/silx/gui/plot3d/actions/viewpoint.py b/silx/gui/plot3d/actions/viewpoint.py deleted file mode 100644 index d764c40..0000000 --- a/silx/gui/plot3d/actions/viewpoint.py +++ /dev/null @@ -1,231 +0,0 @@ -# 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/silx/gui/plot3d/items/__init__.py b/silx/gui/plot3d/items/__init__.py deleted file mode 100644 index e7c4af1..0000000 --- a/silx/gui/plot3d/items/__init__.py +++ /dev/null @@ -1,43 +0,0 @@ -# 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/silx/gui/plot3d/items/_pick.py b/silx/gui/plot3d/items/_pick.py deleted file mode 100644 index 0d6a495..0000000 --- a/silx/gui/plot3d/items/_pick.py +++ /dev/null @@ -1,265 +0,0 @@ -# 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/silx/gui/plot3d/items/clipplane.py b/silx/gui/plot3d/items/clipplane.py deleted file mode 100644 index 3e819d0..0000000 --- a/silx/gui/plot3d/items/clipplane.py +++ /dev/null @@ -1,136 +0,0 @@ -# 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/silx/gui/plot3d/items/core.py b/silx/gui/plot3d/items/core.py deleted file mode 100644 index ab2ceb6..0000000 --- a/silx/gui/plot3d/items/core.py +++ /dev/null @@ -1,779 +0,0 @@ -# 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 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 -import six - -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 = six.text_type(self.__class__.__name__) - if labelIndex != 0: - self._label += u' %d' % labelIndex - self._LABEL_INDICES[self.__class__] += 1 - - if isinstance(parent, Item3D): - parent.sigItemChanged.connect(self.__parentItemChanged) - - def setParent(self, parent): - """Override set parent to handle root item change""" - previousParent = self.parent() - if isinstance(previousParent, Item3D): - previousParent.sigItemChanged.disconnect(self.__parentItemChanged) - - super(Item3D, self).setParent(parent) - - if isinstance(parent, Item3D): - parent.sigItemChanged.connect(self.__parentItemChanged) - - self._updated(Item3DChangedType.ROOT_ITEM) - - def __parentItemChanged(self, event): - """Handle updates of the parent if it is an Item3D - - :param Item3DChangedType event: - """ - if event == Item3DChangedType.ROOT_ITEM: - self._updated(Item3DChangedType.ROOT_ITEM) - - def root(self): - """Returns the root of the scene this item belongs to. - - The root is the up-most Item3D in the scene tree hierarchy. - - :rtype: Union[Item3D, None] - """ - root = None - ancestor = self.parent() - while isinstance(ancestor, Item3D): - root = ancestor - ancestor = ancestor.parent() - - return root - - def _getScenePrimitive(self): - """Return the group containing the item rendering""" - return self._primitive - - def _updated(self, event=None): - """Handle MixIn class updates. - - :param event: The event to send to :attr:`sigItemChanged` signal. - """ - if event == Item3DChangedType.ROOT_ITEM: - self.__syncForegroundColor() - - if event is not None: - self.sigItemChanged.emit(event) - - # Label - - def getLabel(self): - """Returns the label associated to this item. - - :rtype: str - """ - return self._label - - def setLabel(self, label): - """Set the label associated to this item. - - :param str label: - """ - label = six.text_type(label) - if label != self._label: - self._label = label - self._updated(Item3DChangedType.LABEL) - - # Visibility - - def isVisible(self): - """Returns True if item is visible, else False - - :rtype: bool - """ - return self._getScenePrimitive().visible - - def setVisible(self, visible=True): - """Set the visibility of the item in the scene. - - :param bool visible: True (default) to show the item, False to hide - """ - visible = bool(visible) - primitive = self._getScenePrimitive() - if visible != primitive.visible: - primitive.visible = visible - self._updated(ItemChangedType.VISIBLE) - - # Foreground color - - def _setForegroundColor(self, color): - """Set the foreground color of the item. - - The default implementation does nothing, override it in subclass. - - :param color: RGBA color - :type color: tuple of 4 float in [0., 1.] - """ - if hasattr(super(Item3D, self), '_setForegroundColor'): - super(Item3D, self)._setForegroundColor(color) - - def __syncForegroundColor(self): - """Retrieve foreground color from parent and update this item""" - # Look-up for SceneWidget to get its foreground color - root = self.root() - if root is not None: - widget = root.parent() - if isinstance(widget, qt.QWidget): - self._setForegroundColor( - widget.getForegroundColor().getRgbF()) - - # 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, six.string_types): - assert position in self._ROTATION_CENTER_TAGS - else: - position = float(position) - center.append(position) - center = tuple(center) - - if center != self._rotationCenter: - self._rotationCenter = center - self._updateRotationCenter() - - def getRotationCenter(self): - """Returns the rotation center set by :meth:`setRotationCenter`. - - :rtype: 3-tuple of float or str - """ - return self._rotationCenter - - def setRotation(self, angle=0., axis=(0., 0., 1.)): - """Set the rotation of the item in the scene - - :param float angle: The rotation angle in degrees. - :param axis: The (x, y, z) coordinates of the rotation axis. - """ - axis = numpy.array(axis, dtype=numpy.float32) - assert axis.ndim == 1 - assert axis.size == 3 - if (self._rotate.angle != angle or - not numpy.all(numpy.equal(axis, self._rotate.axis))): - self._rotate.setAngleAxis(angle, axis) - self._updated(Item3DChangedType.TRANSFORM) - - def getRotation(self): - """Returns the rotation set by :meth:`setRotation`. - - :return: (angle, axis) - :rtype: 2-tuple (float, numpy.ndarray) - """ - return self._rotate.angle, self._rotate.axis - - def setMatrix(self, matrix=None): - """Set the transform matrix - - :param numpy.ndarray matrix: 3x3 transform matrix - """ - matrix4x4 = numpy.identity(4, dtype=numpy.float32) - - if matrix is not None: - matrix = numpy.array(matrix, dtype=numpy.float32) - assert matrix.shape == (3, 3) - matrix4x4[:3, :3] = matrix - - if not numpy.all(numpy.equal(matrix4x4, self._matrix.getMatrix())): - self._matrix.setMatrix(matrix4x4) - self._updated(Item3DChangedType.TRANSFORM) - - def getMatrix(self): - """Returns the matrix set by :meth:`setMatrix` - - :return: 3x3 matrix - :rtype: numpy.ndarray""" - return self._matrix.getMatrix(copy=True)[:3, :3] - - # Bounding box - - def _setForegroundColor(self, color): - """Set the color of the bounding box - - :param color: RGBA color as 4 floats in [0, 1] - """ - self._getScenePrimitive().color = color - super(DataItem3D, self)._setForegroundColor(color) - - def isBoundingBoxVisible(self): - """Returns item's bounding box visibility. - - :rtype: bool - """ - return self._getScenePrimitive().boxVisible - - def setBoundingBoxVisible(self, visible): - """Set item's bounding box visibility. - - :param bool visible: - True to show the bounding box, False (default) to hide it - """ - visible = bool(visible) - primitive = self._getScenePrimitive() - if visible != primitive.boxVisible: - primitive.boxVisible = visible - self._updated(Item3DChangedType.BOUNDING_BOX_VISIBLE) - - -class 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/silx/gui/plot3d/items/image.py b/silx/gui/plot3d/items/image.py deleted file mode 100644 index 4e2b396..0000000 --- a/silx/gui/plot3d/items/image.py +++ /dev/null @@ -1,425 +0,0 @@ -# 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.int), - numpy.floor(x * data.shape[1] / height).astype(numpy.int)] - - 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.int), - numpy.floor(x * rgba.shape[1] / height).astype(numpy.int)] - - 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/silx/gui/plot3d/items/mesh.py b/silx/gui/plot3d/items/mesh.py deleted file mode 100644 index 4e19939..0000000 --- a/silx/gui/plot3d/items/mesh.py +++ /dev/null @@ -1,792 +0,0 @@ -# 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/silx/gui/plot3d/items/mixins.py b/silx/gui/plot3d/items/mixins.py deleted file mode 100644 index f512365..0000000 --- a/silx/gui/plot3d/items/mixins.py +++ /dev/null @@ -1,288 +0,0 @@ -# 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/silx/gui/plot3d/items/scatter.py b/silx/gui/plot3d/items/scatter.py deleted file mode 100644 index 24abaa5..0000000 --- a/silx/gui/plot3d/items/scatter.py +++ /dev/null @@ -1,617 +0,0 @@ -# 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/silx/gui/plot3d/items/volume.py b/silx/gui/plot3d/items/volume.py deleted file mode 100644 index f80fea2..0000000 --- a/silx/gui/plot3d/items/volume.py +++ /dev/null @@ -1,886 +0,0 @@ -# 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/silx/gui/plot3d/scene/__init__.py b/silx/gui/plot3d/scene/__init__.py deleted file mode 100644 index 9671725..0000000 --- a/silx/gui/plot3d/scene/__init__.py +++ /dev/null @@ -1,34 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2015-2017 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -"""This module provides a 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/silx/gui/plot3d/scene/axes.py b/silx/gui/plot3d/scene/axes.py deleted file mode 100644 index e35e5e1..0000000 --- a/silx/gui/plot3d/scene/axes.py +++ /dev/null @@ -1,258 +0,0 @@ -# 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/silx/gui/plot3d/scene/camera.py b/silx/gui/plot3d/scene/camera.py deleted file mode 100644 index 90de7ed..0000000 --- a/silx/gui/plot3d/scene/camera.py +++ /dev/null @@ -1,353 +0,0 @@ -# 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/silx/gui/plot3d/scene/core.py b/silx/gui/plot3d/scene/core.py deleted file mode 100644 index 43838fe..0000000 --- a/silx/gui/plot3d/scene/core.py +++ /dev/null @@ -1,343 +0,0 @@ -# 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/silx/gui/plot3d/scene/cutplane.py b/silx/gui/plot3d/scene/cutplane.py deleted file mode 100644 index 88147df..0000000 --- a/silx/gui/plot3d/scene/cutplane.py +++ /dev/null @@ -1,390 +0,0 @@ -# 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/silx/gui/plot3d/scene/event.py b/silx/gui/plot3d/scene/event.py deleted file mode 100644 index 98f8f8b..0000000 --- a/silx/gui/plot3d/scene/event.py +++ /dev/null @@ -1,225 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2015-2017 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -"""This module provides a 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/silx/gui/plot3d/scene/function.py b/silx/gui/plot3d/scene/function.py deleted file mode 100644 index 2deb785..0000000 --- a/silx/gui/plot3d/scene/function.py +++ /dev/null @@ -1,654 +0,0 @@ -# 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/silx/gui/plot3d/scene/interaction.py b/silx/gui/plot3d/scene/interaction.py deleted file mode 100644 index 14a54dc..0000000 --- a/silx/gui/plot3d/scene/interaction.py +++ /dev/null @@ -1,701 +0,0 @@ -# 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/silx/gui/plot3d/scene/primitives.py b/silx/gui/plot3d/scene/primitives.py deleted file mode 100644 index 7f35c3c..0000000 --- a/silx/gui/plot3d/scene/primitives.py +++ /dev/null @@ -1,2524 +0,0 @@ -# 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( - sce |