diff options
Diffstat (limited to 'silx/gui/plot3d/_model/items.py')
-rw-r--r-- | silx/gui/plot3d/_model/items.py | 1388 |
1 files changed, 1388 insertions, 0 deletions
diff --git a/silx/gui/plot3d/_model/items.py b/silx/gui/plot3d/_model/items.py new file mode 100644 index 0000000..7009ea1 --- /dev/null +++ b/silx/gui/plot3d/_model/items.py @@ -0,0 +1,1388 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2017-2018 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +""" +This module provides base classes to implement models for 3D scene content +""" + +from __future__ import absolute_import, division + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "11/01/2018" + + +import functools +import weakref + +import numpy + +from silx.third_party import six + +from ..._utils import convertArrayToQImage +from ...plot.Colormap import preferredColormaps +from ... import qt, icons +from .. import items +from ..items.volume import Isosurface, CutPlane + + +from .core import AngleDegreeRow, BaseRow, ColorProxyRow, ProxyRow, StaticRow + + +class _DirectionalLightProxy(qt.QObject): + """Proxy to handle directional light with angles rather than vector. + """ + + sigAzimuthAngleChanged = qt.Signal() + """Signal sent when the azimuth angle has changed.""" + + sigAltitudeAngleChanged = qt.Signal() + """Signal sent when altitude angle has changed.""" + + def __init__(self, light): + super(_DirectionalLightProxy, self).__init__() + self._light = light + light.addListener(self._directionUpdated) + self._azimuth = 0. + self._altitude = 0. + + def getAzimuthAngle(self): + """Returns the signed angle in the horizontal plane. + + Unit: degrees. + The 0 angle corresponds to the axis perpendicular to the screen. + + :rtype: float + """ + return self._azimuth + + def getAltitudeAngle(self): + """Returns the signed vertical angle from the horizontal plane. + + Unit: degrees. + Range: [-90, +90] + + :rtype: float + """ + return self._altitude + + def setAzimuthAngle(self, angle): + """Set the horizontal angle. + + :param float angle: Angle from -z axis in zx plane in degrees. + """ + if angle != self._azimuth: + self._azimuth = angle + self._updateLight() + self.sigAzimuthAngleChanged.emit() + + def setAltitudeAngle(self, angle): + """Set the horizontal angle. + + :param float angle: Angle from -z axis in zy plane in degrees. + """ + if angle != self._altitude: + self._altitude = angle + self._updateLight() + self.sigAltitudeAngleChanged.emit() + + def _directionUpdated(self, *args, **kwargs): + """Handle light direction update in the scene""" + # Invert direction to manipulate the 'source' pointing to + # the center of the viewport + x, y, z = - self._light.direction + + # Horizontal plane is plane xz + azimuth = numpy.degrees(numpy.arctan2(x, z)) + altitude = numpy.degrees(numpy.pi/2. - numpy.arccos(y)) + + if (abs(azimuth - self.getAzimuthAngle()) > 0.01 and + abs(abs(altitude) - 90.) >= 0.001): # Do not update when at zenith + self.setAzimuthAngle(azimuth) + + if abs(altitude - self.getAltitudeAngle()) > 0.01: + self.setAltitudeAngle(altitude) + + def _updateLight(self): + """Update light direction in the scene""" + azimuth = numpy.radians(self._azimuth) + delta = numpy.pi/2. - numpy.radians(self._altitude) + z = - numpy.sin(delta) * numpy.cos(azimuth) + x = - numpy.sin(delta) * numpy.sin(azimuth) + y = - numpy.cos(delta) + self._light.direction = x, y, z + + +class Settings(StaticRow): + """Subtree for :class:`SceneWidget` style parameters. + + :param SceneWidget sceneWidget: The widget to control + """ + + def __init__(self, sceneWidget): + background = ColorProxyRow( + name='Background', + fget=sceneWidget.getBackgroundColor, + fset=sceneWidget.setBackgroundColor, + notify=sceneWidget.sigStyleChanged) + + foreground = ColorProxyRow( + name='Foreground', + fget=sceneWidget.getForegroundColor, + fset=sceneWidget.setForegroundColor, + notify=sceneWidget.sigStyleChanged) + + text = ColorProxyRow( + name='Text', + fget=sceneWidget.getTextColor, + fset=sceneWidget.setTextColor, + notify=sceneWidget.sigStyleChanged) + + highlight = ColorProxyRow( + name='Highlight', + fget=sceneWidget.getHighlightColor, + fset=sceneWidget.setHighlightColor, + notify=sceneWidget.sigStyleChanged) + + axesIndicator = ProxyRow( + name='Axes Indicator', + fget=sceneWidget.isOrientationIndicatorVisible, + fset=sceneWidget.setOrientationIndicatorVisible, + notify=sceneWidget.sigStyleChanged) + + # Light direction + + self._lightProxy = _DirectionalLightProxy(sceneWidget.viewport.light) + + azimuthNode = ProxyRow( + name='Azimuth', + fget=self._lightProxy.getAzimuthAngle, + fset=self._lightProxy.setAzimuthAngle, + notify=self._lightProxy.sigAzimuthAngleChanged, + editorHint=(-90, 90)) + + altitudeNode = ProxyRow( + name='Altitude', + fget=self._lightProxy.getAltitudeAngle, + fset=self._lightProxy.setAltitudeAngle, + notify=self._lightProxy.sigAltitudeAngleChanged, + editorHint=(-90, 90)) + + lightDirection = StaticRow(('Light Direction', None), + children=(azimuthNode, altitudeNode)) + + # Settings row + children = (background, foreground, text, highlight, + axesIndicator, lightDirection) + super(Settings, self).__init__(('Settings', None), children=children) + + +class Item3DRow(StaticRow): + """Represents an :class:`Item3D` with checkable visibility + + :param Item3D item: The scene item to represent. + :param str name: The optional name of the item + """ + + def __init__(self, item, name=None): + if name is None: + name = item.getLabel() + super(Item3DRow, self).__init__((name, None)) + + self.setFlags( + self.flags(0) | qt.Qt.ItemIsUserCheckable | qt.Qt.ItemIsSelectable, + 0) + self.setFlags(self.flags(1) | qt.Qt.ItemIsSelectable, 1) + + self._item = weakref.ref(item) + item.sigItemChanged.connect(self._itemChanged) + + def _itemChanged(self, event): + """Handle visibility change""" + if event == items.ItemChangedType.VISIBLE: + model = self.model() + if model is not None: + index = self.index(column=1) + model.dataChanged.emit(index, index) + + def item(self): + """Returns the :class:`Item3D` item or None""" + return self._item() + + def data(self, column, role): + if column == 0 and role == qt.Qt.CheckStateRole: + item = self.item() + if item is not None and item.isVisible(): + return qt.Qt.Checked + else: + return qt.Qt.Unchecked + elif column == 0 and role == qt.Qt.DecorationRole: + return icons.getQIcon('item-3dim') + else: + return super(Item3DRow, self).data(column, role) + + def setData(self, column, value, role): + if column == 0 and role == qt.Qt.CheckStateRole: + item = self.item() + if item is not None: + item.setVisible(value == qt.Qt.Checked) + return True + else: + return False + return super(Item3DRow, self).setData(column, value, role) + + +class DataItem3DBoundingBoxRow(ProxyRow): + """Represents :class:`DataItem3D` bounding box visibility + + :param DataItem3D item: The item for which to display/control bounding box + """ + + def __init__(self, item): + super(DataItem3DBoundingBoxRow, self).__init__( + name='Bounding box', + fget=item.isBoundingBoxVisible, + fset=item.setBoundingBoxVisible, + notify=item.sigItemChanged) + + +class MatrixProxyRow(ProxyRow): + """Proxy for a row of a DataItem3D 3x3 matrix transform + + :param DataItem3D item: + :param int index: Matrix row index + """ + + def __init__(self, item, index): + self._item = weakref.ref(item) + self._index = index + + super(MatrixProxyRow, self).__init__( + name='', + fget=self._getMatrixRow, + fset=self._setMatrixRow, + notify=item.sigItemChanged) + + def _getMatrixRow(self): + """Returns the matrix row. + + :rtype: QVector3D + """ + item = self._item() + if item is not None: + matrix = item.getMatrix() + return qt.QVector3D(*matrix[self._index, :]) + else: + return None + + def _setMatrixRow(self, row): + """Set the row of the matrix + + :param QVector3D row: Row values to set + """ + item = self._item() + if item is not None: + matrix = item.getMatrix() + matrix[self._index, :] = row.x(), row.y(), row.z() + item.setMatrix(matrix) + + def data(self, column, role): + data = super(MatrixProxyRow, self).data(column, role) + + if column == 1 and role == qt.Qt.DisplayRole: + # Convert QVector3D to text + data = "%g; %g; %g" % (data.x(), data.y(), data.z()) + + return data + + +class DataItem3DTransformRow(StaticRow): + """Represents :class:`DataItem3D` transform parameters + + :param DataItem3D item: The item for which to display/control transform + """ + + _ROTATION_CENTER_OPTIONS = 'Origin', 'Lower', 'Center', 'Upper' + + def __init__(self, item): + super(DataItem3DTransformRow, self).__init__(('Transform', None)) + self._item = weakref.ref(item) + + translation = ProxyRow(name='Translation', + fget=item.getTranslation, + fset=self._setTranslation, + notify=item.sigItemChanged, + toModelData=lambda data: qt.QVector3D(*data)) + self.addRow(translation) + + # Here to keep a reference + self._xSetCenter = functools.partial(self._setCenter, index=0) + self._ySetCenter = functools.partial(self._setCenter, index=1) + self._zSetCenter = functools.partial(self._setCenter, index=2) + + rotateCenter = StaticRow( + ('Center', None), + children=( + ProxyRow(name='X axis', + fget=item.getRotationCenter, + fset=self._xSetCenter, + notify=item.sigItemChanged, + toModelData=functools.partial( + self._centerToModelData, index=0), + editorHint=self._ROTATION_CENTER_OPTIONS), + ProxyRow(name='Y axis', + fget=item.getRotationCenter, + fset=self._ySetCenter, + notify=item.sigItemChanged, + toModelData=functools.partial( + self._centerToModelData, index=1), + editorHint=self._ROTATION_CENTER_OPTIONS), + ProxyRow(name='Z axis', + fget=item.getRotationCenter, + fset=self._zSetCenter, + notify=item.sigItemChanged, + toModelData=functools.partial( + self._centerToModelData, index=2), + editorHint=self._ROTATION_CENTER_OPTIONS), + )) + + rotate = StaticRow( + ('Rotation', None), + children=( + AngleDegreeRow(name='Angle', + fget=item.getRotation, + fset=self._setAngle, + notify=item.sigItemChanged, + toModelData=lambda data: data[0]), + ProxyRow(name='Axis', + fget=item.getRotation, + fset=self._setAxis, + notify=item.sigItemChanged, + toModelData=lambda data: qt.QVector3D(*data[1])), + rotateCenter + )) + self.addRow(rotate) + + scale = ProxyRow(name='Scale', + fget=item.getScale, + fset=self._setScale, + notify=item.sigItemChanged, + toModelData=lambda data: qt.QVector3D(*data)) + self.addRow(scale) + + matrix = StaticRow( + ('Matrix', None), + children=(MatrixProxyRow(item, 0), + MatrixProxyRow(item, 1), + MatrixProxyRow(item, 2))) + self.addRow(matrix) + + def item(self): + """Returns the :class:`Item3D` item or None""" + return self._item() + + @staticmethod + def _centerToModelData(center, index): + """Convert rotation center information from scene to model. + + :param center: The center info from the scene + :param int index: dimension to convert + """ + value = center[index] + if isinstance(value, six.string_types): + return value.title() + elif value == 0.: + return 'Origin' + else: + return six.text_type(value) + + def _setCenter(self, value, index): + """Set one dimension of the rotation center. + + :param value: Value received through the model. + :param int index: dimension to set + """ + item = self.item() + if item is not None: + if value == 'Origin': + value = 0. + elif value not in self._ROTATION_CENTER_OPTIONS: + value = float(value) + else: + value = value.lower() + + center = list(item.getRotationCenter()) + center[index] = value + item.setRotationCenter(*center) + + def _setAngle(self, angle): + """Set rotation angle. + + :param float angle: + """ + item = self.item() + if item is not None: + _, axis = item.getRotation() + item.setRotation(angle, axis) + + def _setAxis(self, axis): + """Set rotation axis. + + :param QVector3D axis: + """ + item = self.item() + if item is not None: + angle, _ = item.getRotation() + item.setRotation(angle, (axis.x(), axis.y(), axis.z())) + + def _setTranslation(self, translation): + """Set translation transform. + + :param QVector3D translation: + """ + item = self.item() + if item is not None: + item.setTranslation(translation.x(), translation.y(), translation.z()) + + def _setScale(self, scale): + """Set scale transform. + + :param QVector3D scale: + """ + item = self.item() + if item is not None: + item.setScale(scale.x(), scale.y(), scale.z()) + + +class GroupItemRow(Item3DRow): + """Represents a :class:`GroupItem` with transforms and children + + :param GroupItem item: The scene group to represent. + :param str name: The optional name of the group + """ + + _CHILDREN_ROW_OFFSET = 2 + """Number of rows for group parameters. Children are added after""" + + def __init__(self, item, name=None): + super(GroupItemRow, self).__init__(item, name) + self.addRow(DataItem3DBoundingBoxRow(item)) + self.addRow(DataItem3DTransformRow(item)) + + item.sigItemAdded.connect(self._itemAdded) + item.sigItemRemoved.connect(self._itemRemoved) + + for child in item.getItems(): + self.addRow(nodeFromItem(child)) + + def _itemAdded(self, item): + """Handle item addition to the group and add it to the model. + + :param Item3D item: added item + """ + group = self.item() + if group is None: + return + + row = group.getItems().index(item) + self.addRow(nodeFromItem(item), row + self._CHILDREN_ROW_OFFSET) + + def _itemRemoved(self, item): + """Handle item removal from the group and remove it from the model. + + :param Item3D item: removed item + """ + group = self.item() + if group is None: + return + + # Find item + for row in self.children(): + if row.item() is item: + self.removeRow(row) + break # Got it + else: + raise RuntimeError("Model does not correspond to scene content") + + +class InterpolationRow(ProxyRow): + """Represents :class:`InterpolationMixIn` property. + + :param Item3D item: Scene item with interpolation property + """ + + def __init__(self, item): + modes = [mode.title() for mode in item.INTERPOLATION_MODES] + super(InterpolationRow, self).__init__( + name='Interpolation', + fget=item.getInterpolation, + fset=item.setInterpolation, + notify=item.sigItemChanged, + toModelData=lambda mode: mode.title(), + fromModelData=lambda mode: mode.lower(), + editorHint=modes) + + +class _ColormapBaseProxyRow(ProxyRow): + """Base class for colormap model row + + This class handle synchronization and signals from the item and the colormap + """ + + _sigColormapChanged = qt.Signal() + """Signal used internally to notify colormap (or data) update""" + + def __init__(self, item, *args, **kwargs): + self._dataRange = None + self._item = weakref.ref(item) + self._colormap = item.getColormap() + + ProxyRow.__init__(self, *args, **kwargs) + + self._colormap.sigChanged.connect(self._colormapChanged) + item.sigItemChanged.connect(self._itemChanged) + self._sigColormapChanged.connect(self._modelUpdated) + + def item(self): + """Returns the :class:`ColormapMixIn` item or None""" + return self._item() + + def _getColormapRange(self): + """Returns the range of the colormap for the current data. + + :return: Colormap range (min, max) + """ + if self._dataRange is None: + item = self.item() + if item is not None and self._colormap is not None: + if hasattr(item, 'getDataRange'): + data = item.getDataRange() + else: + data = item.getData(copy=False) + + self._dataRange = self._colormap.getColormapRange(data) + + else: # Fallback + self._dataRange = 1, 100 + return self._dataRange + + def _modelUpdated(self, *args, **kwargs): + """Emit dataChanged in the model""" + topLeft = self.index(column=0) + bottomRight = self.index(column=1) + model = self.model() + if model is not None: + model.dataChanged.emit(topLeft, bottomRight) + + def _colormapChanged(self): + self._sigColormapChanged.emit() + + def _itemChanged(self, event): + """Handle change of colormap or data in the item. + + :param ItemChangedType event: + """ + if event == items.ItemChangedType.COLORMAP: + self._sigColormapChanged.emit() + if self._colormap is not None: + self._colormap.sigChanged.disconnect(self._colormapChanged) + + item = self.item() + if item is not None: + self._colormap = item.getColormap() + self._colormap.sigChanged.connect(self._colormapChanged) + else: + self._colormap = None + + elif event == items.ItemChangedType.DATA: + self._dataRange = None + self._sigColormapChanged.emit() + + +class _ColormapBoundRow(_ColormapBaseProxyRow): + """ProxyRow for colormap min or max + + :param ColormapMixIn item: The item to handle + :param str name: Name of the raw + :param int index: 0 for Min and 1 of Max + """ + + def __init__(self, item, name, index): + self._index = index + _ColormapBaseProxyRow.__init__( + self, + item, + name=name, + fget=self._getBound, + fset=self._setBound) + + self.setToolTip('Colormap %s bound:\n' + 'Check to set bound manually, ' + 'uncheck for autoscale' % name.lower()) + + def _getRawBound(self): + """Proxy to get raw colormap bound + + :rtype: float or None + """ + if self._colormap is None: + return None + elif self._index == 0: + return self._colormap.getVMin() + else: # self._index == 1 + return self._colormap.getVMax() + + def _getBound(self): + """Proxy to get colormap effective bound value + + :rtype: float + """ + if self._colormap is not None: + bound = self._getRawBound() + + if bound is None: + bound = self._getColormapRange()[self._index] + return bound + else: + return 1. # Fallback + + def _setBound(self, value): + """Proxy to set colormap bound. + + :param float value: + """ + if self._colormap is not None: + if self._index == 0: + min_ = value + max_ = self._colormap.getVMax() + else: # self._index == 1 + min_ = self._colormap.getVMin() + max_ = value + + if max_ is not None and min_ is not None and min_ > max_: + min_, max_ = max_, min_ + self._colormap.setVRange(min_, max_) + + def flags(self, column): + if column == 0: + return qt.Qt.ItemIsEnabled | qt.Qt.ItemIsUserCheckable + + elif column == 1: + if self._getRawBound() is not None: + flags = qt.Qt.ItemIsEditable | qt.Qt.ItemIsEnabled + else: + flags = qt.Qt.NoItemFlags # Disabled if autoscale + return flags + + else: # Never event + return super(_ColormapBoundRow, self).flags(column) + + def data(self, column, role): + if column == 0 and role == qt.Qt.CheckStateRole: + if self._getRawBound() is None: + return qt.Qt.Unchecked + else: + return qt.Qt.Checked + + else: + return super(_ColormapBoundRow, self).data(column, role) + + def setData(self, column, value, role): + if column == 0 and role == qt.Qt.CheckStateRole: + if self._colormap is not None: + bound = self._getBound() if value == qt.Qt.Checked else None + self._setBound(bound) + return True + else: + return False + + return super(_ColormapBoundRow, self).setData(column, value, role) + + +class ColormapRow(_ColormapBaseProxyRow): + """Represents :class:`ColormapMixIn` property. + + :param Item3D item: Scene item with colormap property + """ + + def __init__(self, item): + super(ColormapRow, self).__init__( + item, + name='Colormap', + fget=self._get) + + self._colormapImage = None + + self._colormapsMapping = {} + for cmap in preferredColormaps(): + self._colormapsMapping[cmap.title()] = cmap + + self.addRow(ProxyRow( + name='Name', + fget=self._getName, + fset=self._setName, + notify=self._sigColormapChanged, + editorHint=list(self._colormapsMapping.keys()))) + + norms = [norm.title() for norm in self._colormap.NORMALIZATIONS] + self.addRow(ProxyRow( + name='Normalization', + fget=self._getNormalization, + fset=self._setNormalization, + notify=self._sigColormapChanged, + editorHint=norms)) + + self.addRow(_ColormapBoundRow(item, name='Min.', index=0)) + self.addRow(_ColormapBoundRow(item, name='Max.', index=1)) + + self._sigColormapChanged.connect(self._updateColormapImage) + + def _get(self): + """Getter for ProxyRow subclass""" + return None + + def _getName(self): + """Proxy for :meth:`Colormap.getName`""" + if self._colormap is not None: + return self._colormap.getName().title() + else: + return '' + + def _setName(self, name): + """Proxy for :meth:`Colormap.setName`""" + # Convert back from titled to name if possible + if self._colormap is not None: + name = self._colormapsMapping.get(name, name) + self._colormap.setName(name) + + def _getNormalization(self): + """Proxy for :meth:`Colormap.getNormalization`""" + if self._colormap is not None: + return self._colormap.getNormalization().title() + else: + return '' + + def _setNormalization(self, normalization): + """Proxy for :meth:`Colormap.setNormalization`""" + if self._colormap is not None: + return self._colormap.setNormalization(normalization.lower()) + + def _updateColormapImage(self, *args, **kwargs): + """Notify colormap update to update the image in the tree""" + if self._colormapImage is not None: + self._colormapImage = None + model = self.model() + if model is not None: + index = self.index(column=1) + model.dataChanged.emit(index, index) + + def data(self, column, role): + if column == 1 and role == qt.Qt.DecorationRole: + if self._colormapImage is None: + image = numpy.zeros((16, 130, 3), dtype=numpy.uint8) + image[1:-1, 1:-1] = self._colormap.getNColors(image.shape[1] - 2)[:, :3] + self._colormapImage = convertArrayToQImage(image) + return self._colormapImage + + return super(ColormapRow, self).data(column, role) + + +class SymbolRow(ProxyRow): + """Represents :class:`SymbolMixIn` symbol property. + + :param Item3D item: Scene item with symbol property + """ + + def __init__(self, item): + names = [item.getSymbolName(s) for s in item.getSupportedSymbols()] + super(SymbolRow, self).__init__( + name='Marker', + fget=item.getSymbolName, + fset=item.setSymbol, + notify=item.sigItemChanged, + editorHint=names) + + +class SymbolSizeRow(ProxyRow): + """Represents :class:`SymbolMixIn` symbol size property. + + :param Item3D item: Scene item with symbol size property + """ + + def __init__(self, item): + super(SymbolSizeRow, self).__init__( + name='Marker size', + fget=item.getSymbolSize, + fset=item.setSymbolSize, + notify=item.sigItemChanged, + editorHint=(1, 20)) # TODO link with OpenGL max point size + + +class PlaneRow(ProxyRow): + """Represents :class:`PlaneMixIn` property. + + :param Item3D item: Scene item with plane equation property + """ + + def __init__(self, item): + super(PlaneRow, self).__init__( + name='Equation', + fget=item.getParameters, + fset=item.setParameters, + notify=item.sigItemChanged, + toModelData=lambda data: qt.QVector4D(*data), + fromModelData=lambda data: (data.x(), data.y(), data.z(), data.w())) + self._item = weakref.ref(item) + + def data(self, column, role): + if column == 1 and role == qt.Qt.DisplayRole: + item = self._item() + if item is not None: + params = item.getParameters() + return ('%gx %+gy %+gz %+g = 0' % + (params[0], params[1], params[2], params[3])) + return super(PlaneRow, self).data(column, role) + + +class RemoveIsosurfaceRow(BaseRow): + """Class for Isosurface Delete button + + :param Isosurface isosurface: The isosurface item to attach the button to. + """ + + def __init__(self, isosurface): + super(RemoveIsosurfaceRow, self).__init__() + self._isosurface = weakref.ref(isosurface) + + def createEditor(self): + """Specific editor factory provided to the model""" + editor = qt.QWidget() + layout = qt.QHBoxLayout(editor) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + removeBtn = qt.QToolButton() + removeBtn.setText('Delete') + removeBtn.setToolButtonStyle(qt.Qt.ToolButtonTextOnly) + layout.addWidget(removeBtn) + removeBtn.clicked.connect(self._removeClicked) + + layout.addStretch(1) + return editor + + def isosurface(self): + """Returns the controlled isosurface + + :rtype: Isosurface + """ + return self._isosurface() + + def data(self, column, role): + if column == 0 and role == qt.Qt.UserRole: # editor hint + return self.createEditor + + return super(RemoveIsosurfaceRow, self).data(column, role) + + def flags(self, column): + flags = super(RemoveIsosurfaceRow, self).flags(column) + if column == 0: + flags |= qt.Qt.ItemIsEditable + return flags + + def _removeClicked(self): + """Handle Delete button clicked""" + isosurface = self.isosurface() + if isosurface is not None: + scalarField3D = isosurface.parent() + if scalarField3D is not None: + scalarField3D.removeIsosurface(isosurface) + + +class IsosurfaceRow(Item3DRow): + """Represents an :class:`Isosurface` item. + + :param Isosurface item: Isosurface item + """ + + _LEVEL_SLIDER_RANGE = 0, 1000 + """Range given as editor hint""" + + def __init__(self, item): + super(IsosurfaceRow, self).__init__(item, name=item.getLevel()) + + self.setFlags(self.flags(1) | qt.Qt.ItemIsEditable, 1) + + item.sigItemChanged.connect(self._levelChanged) + + self.addRow(ProxyRow( + name='Level', + fget=self._getValueForLevelSlider, + fset=self._setLevelFromSliderValue, + notify=item.sigItemChanged, + editorHint=self._LEVEL_SLIDER_RANGE)) + + self.addRow(ColorProxyRow( + name='Color', + fget=self._rgbColor, + fset=self._setRgbColor, + notify=item.sigItemChanged)) + + self.addRow(ProxyRow( + name='Opacity', + fget=self._opacity, + fset=self._setOpacity, + notify=item.sigItemChanged, + editorHint=(0, 255))) + + self.addRow(RemoveIsosurfaceRow(item)) + + def _getValueForLevelSlider(self): + """Convert iso level to slider value. + + :rtype: int + """ + item = self.item() + if item is not None: + scalarField3D = item.parent() + if scalarField3D is not None: + dataRange = scalarField3D.getDataRange() + if dataRange is not None: + dataMin, dataMax = dataRange[0], dataRange[-1] + offset = (item.getLevel() - dataMin) / (dataMax - dataMin) + + sliderMin, sliderMax = self._LEVEL_SLIDER_RANGE + value = sliderMin + (sliderMax - sliderMin) * offset + return value + return 0 + + def _setLevelFromSliderValue(self, value): + """Convert slider value to isolevel. + + :param int value: + """ + item = self.item() + if item is not None: + scalarField3D = item.parent() + if scalarField3D is not None: + dataRange = scalarField3D.getDataRange() + if dataRange is not None: + sliderMin, sliderMax = self._LEVEL_SLIDER_RANGE + offset = (value - sliderMin) / (sliderMax - sliderMin) + + dataMin, dataMax = dataRange[0], dataRange[-1] + level = dataMin + (dataMax - dataMin) * offset + item.setLevel(level) + + def _rgbColor(self): + """Proxy to get the isosurface's RGB color without transparency + + :rtype: QColor + """ + item = self.item() + if item is None: + return None + else: + color = item.getColor() + color.setAlpha(255) + return color + + def _setRgbColor(self, color): + """Proxy to set the isosurface's RGB color without transparency + + :param QColor color: + """ + item = self.item() + if item is not None: + color.setAlpha(item.getColor().alpha()) + item.setColor(color) + + def _opacity(self): + """Proxy to get the isosurface's transparency + + :rtype: int + """ + item = self.item() + return 255 if item is None else item.getColor().alpha() + + def _setOpacity(self, opacity): + """Proxy to set the isosurface's transparency. + + :param int opacity: + """ + item = self.item() + if item is not None: + color = item.getColor() + color.setAlpha(opacity) + item.setColor(color) + + def _levelChanged(self, event): + """Handle isosurface level changed and notify model + + :param ItemChangedType event: + """ + if event == items.Item3DChangedType.ISO_LEVEL: + model = self.model() + if model is not None: + index = self.index(column=1) + model.dataChanged.emit(index, index) + + def data(self, column, role): + if column == 0: # Show color as decoration, not text + if role == qt.Qt.DisplayRole: + return None + elif role == qt.Qt.DecorationRole: + return self._rgbColor() + + elif column == 1 and role in (qt.Qt.DisplayRole, qt.Qt.EditRole): + item = self.item() + return None if item is None else item.getLevel() + + return super(IsosurfaceRow, self).data(column, role) + + def setData(self, column, value, role): + if column == 1 and role == qt.Qt.EditRole: + item = self.item() + if item is not None: + item.setLevel(value) + return True + + return super(IsosurfaceRow, self).setData(column, value, role) + + +class AddIsosurfaceRow(BaseRow): + """Class for Isosurface create button + + :param ScalarField3D scalarField3D: + The ScalarField3D item to attach the button to. + """ + + def __init__(self, scalarField3D): + super(AddIsosurfaceRow, self).__init__() + self._scalarField3D = weakref.ref(scalarField3D) + + def createEditor(self): + """Specific editor factory provided to the model""" + editor = qt.QWidget() + layout = qt.QHBoxLayout(editor) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + addBtn = qt.QToolButton() + addBtn.setText('+') + addBtn.setToolButtonStyle(qt.Qt.ToolButtonTextOnly) + layout.addWidget(addBtn) + addBtn.clicked.connect(self._addClicked) + + layout.addStretch(1) + return editor + + def scalarField3D(self): + """Returns the controlled ScalarField3D + + :rtype: ScalarField3D + """ + return self._scalarField3D() + + def data(self, column, role): + if column == 0 and role == qt.Qt.UserRole: # editor hint + return self.createEditor + + return super(AddIsosurfaceRow, self).data(column, role) + + def flags(self, column): + flags = super(AddIsosurfaceRow, self).flags(column) + if column == 0: + flags |= qt.Qt.ItemIsEditable + return flags + + def _addClicked(self): + """Handle Delete button clicked""" + scalarField3D = self.scalarField3D() + if scalarField3D is not None: + dataRange = scalarField3D.getDataRange() + if dataRange is None: + dataRange = 0., 1. + + scalarField3D.addIsosurface( + numpy.mean((dataRange[0], dataRange[-1])), + '#0000FF') + + +class ScalarField3DIsoSurfacesRow(StaticRow): + """Represents :class:`ScalarFieldView`'s isosurfaces + + :param ScalarFieldView scalarField3D: ScalarFieldView to control + """ + + def __init__(self, scalarField3D): + super(ScalarField3DIsoSurfacesRow, self).__init__( + ('Isosurfaces', None)) + self._scalarField3D = weakref.ref(scalarField3D) + + scalarField3D.sigIsosurfaceAdded.connect(self._isosurfaceAdded) + scalarField3D.sigIsosurfaceRemoved.connect(self._isosurfaceRemoved) + + for item in scalarField3D.getIsosurfaces(): + self.addRow(nodeFromItem(item)) + + self.addRow(AddIsosurfaceRow(scalarField3D)) + + def scalarField3D(self): + """Returns the controlled ScalarField3D + + :rtype: ScalarField3D + """ + return self._scalarField3D() + + def _isosurfaceAdded(self, item): + """Handle isosurface addition + + :param Isosurface item: added isosurface + """ + scalarField3D = self.scalarField3D() + if scalarField3D is None: + return + + row = scalarField3D.getIsosurfaces().index(item) + self.addRow(nodeFromItem(item), row) + + def _isosurfaceRemoved(self, item): + """Handle isosurface removal + + :param Isosurface item: removed isosurface + """ + scalarField3D = self.scalarField3D() + if scalarField3D is None: + return + + # Find item + for row in self.children(): + if row.item() is item: + self.removeRow(row) + break # Got it + else: + raise RuntimeError("Model does not correspond to scene content") + + +class Scatter2DPropertyMixInRow(object): + """Mix-in class that enable/disable row according to Scatter2D mode. + + :param Scatter2D item: + :param str propertyName: Name of the Scatter2D property of this row + """ + + def __init__(self, item, propertyName): + assert propertyName in ('lineWidth', 'symbol', 'symbolSize') + self.__propertyName = propertyName + + self.__isEnabled = item.isPropertyEnabled(propertyName) + self.__updateFlags() + + item.sigItemChanged.connect(self.__itemChanged) + + def data(self, column, role): + if column == 1 and not self.__isEnabled: + # Discard data and editorHint if disabled + return None + else: + return super(Scatter2DPropertyMixInRow, self).data(column, role) + + def __updateFlags(self): + """Update model flags""" + if self.__isEnabled: + self.setFlags(qt.Qt.ItemIsEnabled, 0) + self.setFlags(qt.Qt.ItemIsEnabled | qt.Qt.ItemIsEditable, 1) + else: + self.setFlags(qt.Qt.NoItemFlags) + + def __itemChanged(self, event): + """Set flags to enable/disable the row""" + if event == items.ItemChangedType.VISUALIZATION_MODE: + item = self.sender() + if item is not None: # This occurs with PySide/python2.7 + self.__isEnabled = item.isPropertyEnabled(self.__propertyName) + self.__updateFlags() + + # Notify model + model = self.model() + if model is not None: + begin = self.index(column=0) + end = self.index(column=1) + model.dataChanged.emit(begin, end) + + +class Scatter2DSymbolRow(Scatter2DPropertyMixInRow, SymbolRow): + """Specific class for Scatter2D symbol. + + It is enabled/disabled according to visualization mode. + + :param Scatter2D item: + """ + + def __init__(self, item): + SymbolRow.__init__(self, item) + Scatter2DPropertyMixInRow.__init__(self, item, 'symbol') + + +class Scatter2DSymbolSizeRow(Scatter2DPropertyMixInRow, SymbolSizeRow): + """Specific class for Scatter2D symbol size. + + It is enabled/disabled according to visualization mode. + + :param Scatter2D item: + """ + + def __init__(self, item): + SymbolSizeRow.__init__(self, item) + Scatter2DPropertyMixInRow.__init__(self, item, 'symbolSize') + + +class Scatter2DLineWidth(Scatter2DPropertyMixInRow, ProxyRow): + """Specific class for Scatter2D symbol size. + + It is enabled/disabled according to visualization mode. + + :param Scatter2D item: + """ + + def __init__(self, item): + # TODO link editorHint with OpenGL max line width + ProxyRow.__init__(self, + name='Line width', + fget=item.getLineWidth, + fset=item.setLineWidth, + notify=item.sigItemChanged, + editorHint=(1, 10)) + Scatter2DPropertyMixInRow.__init__(self, item, 'lineWidth') + + +def initScatter2DNode(node, item): + """Specific node init for Scatter2D to set order of parameters + + :param Item3DRow node: The model node to setup + :param Scatter2D item: The Scatter2D the node is representing + """ + node.addRow(ProxyRow( + name='Mode', + fget=item.getVisualization, + fset=item.setVisualization, + notify=item.sigItemChanged, + editorHint=[m.title() for m in item.supportedVisualizations()], + toModelData=lambda data: data.title(), + fromModelData=lambda data: data.lower())) + + node.addRow(ProxyRow( + name='Height map', + fget=item.isHeightMap, + fset=item.setHeightMap, + notify=item.sigItemChanged)) + + node.addRow(ColormapRow(item)) + + node.addRow(Scatter2DSymbolRow(item)) + node.addRow(Scatter2DSymbolSizeRow(item)) + + node.addRow(Scatter2DLineWidth(item)) + + +def initScalarField3DNode(node, item): + """Specific node init for ScalarField3D + + :param Item3DRow node: The model node to setup + :param ScalarField3D item: The ScalarField3D the node is representing + """ + node.addRow(nodeFromItem(item.getCutPlanes()[0])) # Add cut plane + node.addRow(ScalarField3DIsoSurfacesRow(item)) + + +def initScalarField3DCutPlaneNode(node, item): + """Specific node init for ScalarField3D CutPlane + + :param Item3DRow node: The model node to setup + :param CutPlane item: The CutPlane the node is representing + """ + node.addRow(PlaneRow(item)) + + node.addRow(ColormapRow(item)) + + node.addRow(ProxyRow( + name='Values<=Min', + fget=item.getDisplayValuesBelowMin, + fset=item.setDisplayValuesBelowMin, + notify=item.sigItemChanged)) + + node.addRow(InterpolationRow(item)) + + +NODE_SPECIFIC_INIT = [ # class, init(node, item) + (items.Scatter2D, initScatter2DNode), + (items.ScalarField3D, initScalarField3DNode), + (CutPlane, initScalarField3DCutPlaneNode), +] +"""List of specific node init for different item class""" + + +def nodeFromItem(item): + """Create :class:`Item3DRow` subclass corresponding to item + + :param Item3D item: The item fow which to create the node + :rtype: Item3DRow + """ + assert isinstance(item, items.Item3D) + + # Item with specific model row class + if isinstance(item, (items.GroupItem, items.GroupWithAxesItem)): + return GroupItemRow(item) + elif isinstance(item, Isosurface): + return IsosurfaceRow(item) + + # Create Item3DRow and populate it + node = Item3DRow(item) + + if isinstance(item, items.DataItem3D): + node.addRow(DataItem3DBoundingBoxRow(item)) + node.addRow(DataItem3DTransformRow(item)) + + # Specific extra init + for cls, specificInit in NODE_SPECIFIC_INIT: + if isinstance(item, cls): + specificInit(node, item) + break + + else: # Generic case: handle mixins + for cls in item.__class__.__mro__: + if cls is items.ColormapMixIn: + node.addRow(ColormapRow(item)) + + elif cls is items.InterpolationMixIn: + node.addRow(InterpolationRow(item)) + + elif cls is items.SymbolMixIn: + node.addRow(SymbolRow(item)) + node.addRow(SymbolSizeRow(item)) + + elif cls is items.PlaneMixIn: + node.addRow(PlaneRow(item)) + + return node |