From 4e774db12d5ebe7a20eded6dd434a289e27999e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Picca=20Fr=C3=A9d=C3=A9ric-Emmanuel?= Date: Wed, 2 Feb 2022 14:19:58 +0100 Subject: New upstream version 1.0.0+dfsg --- src/silx/gui/plot3d/_model/__init__.py | 35 + src/silx/gui/plot3d/_model/core.py | 372 +++++++ src/silx/gui/plot3d/_model/items.py | 1759 ++++++++++++++++++++++++++++++++ src/silx/gui/plot3d/_model/model.py | 184 ++++ 4 files changed, 2350 insertions(+) create mode 100644 src/silx/gui/plot3d/_model/__init__.py create mode 100644 src/silx/gui/plot3d/_model/core.py create mode 100644 src/silx/gui/plot3d/_model/items.py create mode 100644 src/silx/gui/plot3d/_model/model.py (limited to 'src/silx/gui/plot3d/_model') diff --git a/src/silx/gui/plot3d/_model/__init__.py b/src/silx/gui/plot3d/_model/__init__.py new file mode 100644 index 0000000..4b16e32 --- /dev/null +++ b/src/silx/gui/plot3d/_model/__init__.py @@ -0,0 +1,35 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2017-2018 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +""" +This package provides :class:`SceneWidget` content and parameters model. +""" + +from __future__ import absolute_import + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "11/01/2018" + +from .model import SceneModel, visitQAbstractItemModel # noqa diff --git a/src/silx/gui/plot3d/_model/core.py b/src/silx/gui/plot3d/_model/core.py new file mode 100644 index 0000000..e8e0820 --- /dev/null +++ b/src/silx/gui/plot3d/_model/core.py @@ -0,0 +1,372 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2017-2018 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +""" +This module provides base classes to implement models for 3D scene content. +""" + +from __future__ import absolute_import, division + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "11/01/2018" + + +import collections +import weakref + +from ....utils.weakref import WeakMethodProxy +from ... import qt + + +class BaseRow(qt.QObject): + """Base class for rows of the tree model. + + The root node parent MUST be set to the QAbstractItemModel it belongs to. + By default item is enabled. + + :param children: Iterable of BaseRow to start with (not signaled) + """ + + def __init__(self, children=()): + self.__modelRef = None + self.__parentRef = None + super(BaseRow, self).__init__() + self.__children = [] + for row in children: + assert isinstance(row, BaseRow) + row.setParent(self) + self.__children.append(row) + self.__flags = collections.defaultdict(lambda: qt.Qt.ItemIsEnabled) + self.__tooltip = None + + def setParent(self, parent): + """Override :meth:`QObject.setParent` to cache model and parent""" + self.__parentRef = None if parent is None else weakref.ref(parent) + + if isinstance(parent, qt.QAbstractItemModel): + model = parent + elif isinstance(parent, BaseRow): + model = parent.model() + else: + model = None + + self._updateModel(model) + + super(BaseRow, self).setParent(parent) + + def parent(self): + """Override :meth:`QObject.setParent` to use cached parent + + :rtype: Union[QObject, None]""" + return self.__parentRef() if self.__parentRef is not None else None + + def _updateModel(self, model): + """Update the model this row belongs to""" + if model != self.model(): + self.__modelRef = weakref.ref(model) if model is not None else None + for child in self.children(): + child._updateModel(model) + + def model(self): + """Return the model this node belongs to or None if not in a model. + + :rtype: Union[QAbstractItemModel, None] + """ + return self.__modelRef() if self.__modelRef is not None else None + + def index(self, column=0): + """Return corresponding index in the model or None if not in a model. + + :param int column: The column to make the index for + :rtype: Union[QModelIndex, None] + """ + parent = self.parent() + model = self.model() + + if model is None: # Not in a model + return None + elif parent is model: # Root node + return qt.QModelIndex() + else: + index = parent.index() + row = parent.children().index(self) + return model.index(row, column, index) + + def columnCount(self): + """Returns number of columns (default: 2) + + :rtype: int + """ + return 2 + + def children(self): + """Returns the list of children nodes + + :rtype: tuple of Node + """ + return tuple(self.__children) + + def rowCount(self): + """Returns number of rows + + :rtype: int + """ + return len(self.__children) + + def addRow(self, row, index=None): + """Add a node to the children + + :param BaseRow row: The node to add + :param int index: The index at which to insert it or + None to append + """ + if index is None: + index = self.rowCount() + assert index <= self.rowCount() + + model = self.model() + + if model is not None: + parent = self.index() + model.beginInsertRows(parent, index, index) + + self.__children.insert(index, row) + row.setParent(self) + + if model is not None: + model.endInsertRows() + + def removeRow(self, row): + """Remove a row from the children list. + + It removes either a node or a row index. + + :param row: BaseRow object or index of row to remove + :type row: Union[BaseRow, int] + """ + if isinstance(row, BaseRow): + row = self.__children.index(row) + else: + row = int(row) + assert row < self.rowCount() + + model = self.model() + + if model is not None: + index = self.index() + model.beginRemoveRows(index, row, row) + + node = self.__children.pop(row) + node.setParent(None) + + if model is not None: + model.endRemoveRows() + + def data(self, column, role): + """Returns data for given column and role + + :param int column: Column index for this row + :param int role: The role to get + :return: Corresponding data (Default: None) + """ + if role == qt.Qt.ToolTipRole and self.__tooltip is not None: + return self.__tooltip + else: + return None + + def setData(self, column, value, role): + """Set data for given column and role + + :param int column: Column index for this row + :param value: The data to set + :param int role: The role to set + :return: True on success, False on failure + :rtype: bool + """ + return False + + def setToolTip(self, tooltip): + """Set the tooltip of the whole row. + + If None there is no tooltip. + + :param Union[str, None] tooltip: + """ + self.__tooltip = tooltip + + def setFlags(self, flags, column=None): + """Set the static flags to return. + + Default is ItemIsEnabled for all columns. + + :param int column: The column for which to set the flags + :param flags: Item flags + """ + if column is None: + self.__flags = collections.defaultdict(lambda: flags) + else: + self.__flags[column] = flags + + def flags(self, column): + """Returns flags for given column + + :rtype: int + """ + return self.__flags[column] + + +class StaticRow(BaseRow): + """Row with static data. + + :param tuple display: List of data for DisplayRole for each column + :param dict roles: Optional mapping of roles to list of data. + :param children: Iterable of BaseRow to start with (not signaled) + """ + + def __init__(self, display=('', None), roles=None, children=()): + super(StaticRow, self).__init__(children) + self._dataByRoles = {} if roles is None else roles + self._dataByRoles[qt.Qt.DisplayRole] = display + + def data(self, column, role): + if role in self._dataByRoles: + data = self._dataByRoles[role] + if column < len(data): + return data[column] + return super(StaticRow, self).data(column, role) + + def columnCount(self): + return len(self._dataByRoles[qt.Qt.DisplayRole]) + + +class ProxyRow(BaseRow): + """Provides a node to proxy a data accessible through functions. + + Warning: Only weak reference are kept on fget and fset. + + :param str name: The name of this node + :param callable fget: A callable returning the data + :param callable fset: + An optional callable setting the data with data as a single argument. + :param notify: + An optional signal emitted when data has changed. + :param callable toModelData: + An optional callable to convert from fget + callable to data returned by the model. + :param callable fromModelData: + An optional callable converting data provided to the model to + data for fset. + :param editorHint: Data to provide as UserRole for editor selection/setup + """ + + def __init__(self, + name='', + fget=None, + fset=None, + notify=None, + toModelData=None, + fromModelData=None, + editorHint=None): + + super(ProxyRow, self).__init__() + self.__name = name + self.__editorHint = editorHint + + assert fget is not None + self._fget = WeakMethodProxy(fget) + self._fset = WeakMethodProxy(fset) if fset is not None else None + if fset is not None: + self.setFlags(qt.Qt.ItemIsEnabled | qt.Qt.ItemIsEditable, 1) + self._toModelData = toModelData + self._fromModelData = fromModelData + + if notify is not None: + notify.connect(self._notified) # TODO support sigItemChanged flags + + def _notified(self, *args, **kwargs): + """Send update to the model upon signal notifications""" + index = self.index(column=1) + model = self.model() + if model is not None: + model.dataChanged.emit(index, index) + + def data(self, column, role): + if column == 0: + if role == qt.Qt.DisplayRole: + return self.__name + + elif column == 1: + if role == qt.Qt.UserRole: # EditorHint + return self.__editorHint + elif role == qt.Qt.DisplayRole or (role == qt.Qt.EditRole and + self._fset is not None): + data = self._fget() + if self._toModelData is not None: + data = self._toModelData(data) + return data + + return super(ProxyRow, self).data(column, role) + + def setData(self, column, value, role): + if role == qt.Qt.EditRole and self._fset is not None: + if self._fromModelData is not None: + value = self._fromModelData(value) + self._fset(value) + return True + + return super(ProxyRow, self).setData(column, value, role) + + +class ColorProxyRow(ProxyRow): + """Provides a proxy to a QColor property. + + The color is returned through the decorative role. + + See :class:`ProxyRow` + """ + + def data(self, column, role): + if column == 1: # Show color as decoration, not text + if role == qt.Qt.DisplayRole: + return None + if role == qt.Qt.DecorationRole: + role = qt.Qt.DisplayRole + return super(ColorProxyRow, self).data(column, role) + + +class AngleDegreeRow(ProxyRow): + """ProxyRow patching display of column 1 to add degree symbol + + See :class:`ProxyRow` + """ + + def __init__(self, *args, **kwargs): + super(AngleDegreeRow, self).__init__(*args, **kwargs) + + def data(self, column, role): + if column == 1 and role == qt.Qt.DisplayRole: + return u'%g°' % super(AngleDegreeRow, self).data(column, role) + else: + return super(AngleDegreeRow, self).data(column, role) diff --git a/src/silx/gui/plot3d/_model/items.py b/src/silx/gui/plot3d/_model/items.py new file mode 100644 index 0000000..492f44b --- /dev/null +++ b/src/silx/gui/plot3d/_model/items.py @@ -0,0 +1,1759 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2017-2021 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +""" +This module provides base classes to implement models for 3D scene content +""" + +from __future__ import absolute_import, division + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "24/04/2018" + + +from collections import OrderedDict +import functools +import logging +import weakref + +import numpy + +from ...utils.image import convertArrayToQImage +from ...colors import preferredColormaps +from ... import qt, icons +from .. import items +from ..items.volume import Isosurface, CutPlane, ComplexIsosurface +from ..Plot3DWidget import Plot3DWidget + + +from .core import AngleDegreeRow, BaseRow, ColorProxyRow, ProxyRow, StaticRow + + +_logger = logging.getLogger(__name__) + + +class ItemProxyRow(ProxyRow): + """Provides a node to proxy a data accessible through functions. + + It listens on sigItemChanged to trigger the update. + + Warning: Only weak reference are kept on fget and fset. + + :param Item3D item: The item to + :param str name: The name of this node + :param callable fget: A callable returning the data + :param callable fset: + An optional callable setting the data with data as a single argument. + :param events: + An optional event kind or list of event kinds to react upon. + :param callable toModelData: + An optional callable to convert from fget + callable to data returned by the model. + :param callable fromModelData: + An optional callable converting data provided to the model to + data for fset. + :param editorHint: Data to provide as UserRole for editor selection/setup + """ + + def __init__(self, + item, + name='', + fget=None, + fset=None, + events=None, + toModelData=None, + fromModelData=None, + editorHint=None): + super(ItemProxyRow, self).__init__( + name=name, + fget=fget, + fset=fset, + notify=None, + toModelData=toModelData, + fromModelData=fromModelData, + editorHint=editorHint) + + if isinstance(events, (items.ItemChangedType, + items.Item3DChangedType)): + events = (events,) + self.__events = events + item.sigItemChanged.connect(self._itemChanged) + + def _itemChanged(self, event): + """Handle item changed + + :param Union[ItemChangedType,Item3DChangedType] event: + """ + if self.__events is None or event in self.__events: + self._notified() + + +class ItemColorProxyRow(ColorProxyRow, ItemProxyRow): + """Combines :class:`ColorProxyRow` and :class:`ItemProxyRow`""" + + def __init__(self, *args, **kwargs): + ItemProxyRow.__init__(self, *args, **kwargs) + + +class ItemAngleDegreeRow(AngleDegreeRow, ItemProxyRow): + """Combines :class:`AngleDegreeRow` and :class:`ItemProxyRow`""" + + def __init__(self, *args, **kwargs): + ItemProxyRow.__init__(self, *args, **kwargs) + + +class _DirectionalLightProxy(qt.QObject): + """Proxy to handle directional light with angles rather than vector. + """ + + sigAzimuthAngleChanged = qt.Signal() + """Signal sent when the azimuth angle has changed.""" + + sigAltitudeAngleChanged = qt.Signal() + """Signal sent when altitude angle has changed.""" + + def __init__(self, light): + super(_DirectionalLightProxy, self).__init__() + self._light = light + light.addListener(self._directionUpdated) + self._azimuth = 0 + self._altitude = 0 + + def getAzimuthAngle(self): + """Returns the signed angle in the horizontal plane. + + Unit: degrees. + The 0 angle corresponds to the axis perpendicular to the screen. + + :rtype: int + """ + return self._azimuth + + def getAltitudeAngle(self): + """Returns the signed vertical angle from the horizontal plane. + + Unit: degrees. + Range: [-90, +90] + + :rtype: int + """ + return self._altitude + + def setAzimuthAngle(self, angle): + """Set the horizontal angle. + + :param int angle: Angle from -z axis in zx plane in degrees. + """ + angle = int(round(angle)) + if angle != self._azimuth: + self._azimuth = angle + self._updateLight() + self.sigAzimuthAngleChanged.emit() + + def setAltitudeAngle(self, angle): + """Set the horizontal angle. + + :param int angle: Angle from -z axis in zy plane in degrees. + """ + angle = int(round(angle)) + if angle != self._altitude: + self._altitude = angle + self._updateLight() + self.sigAltitudeAngleChanged.emit() + + def _directionUpdated(self, *args, **kwargs): + """Handle light direction update in the scene""" + # Invert direction to manipulate the 'source' pointing to + # the center of the viewport + x, y, z = - self._light.direction + + # Horizontal plane is plane xz + azimuth = int(round(numpy.degrees(numpy.arctan2(x, z)))) + altitude = int(round(numpy.degrees(numpy.pi/2. - numpy.arccos(y)))) + + if azimuth != self.getAzimuthAngle(): + self.setAzimuthAngle(azimuth) + + if altitude != self.getAltitudeAngle(): + self.setAltitudeAngle(altitude) + + def _updateLight(self): + """Update light direction in the scene""" + azimuth = numpy.radians(self._azimuth) + delta = numpy.pi/2. - numpy.radians(self._altitude) + if delta == 0.: # Avoids zenith position + delta = 0.0001 + z = - numpy.sin(delta) * numpy.cos(azimuth) + x = - numpy.sin(delta) * numpy.sin(azimuth) + y = - numpy.cos(delta) + self._light.direction = x, y, z + + +class Settings(StaticRow): + """Subtree for :class:`SceneWidget` style parameters. + + :param SceneWidget sceneWidget: The widget to control + """ + + def __init__(self, sceneWidget): + background = ColorProxyRow( + name='Background', + fget=sceneWidget.getBackgroundColor, + fset=sceneWidget.setBackgroundColor, + notify=sceneWidget.sigStyleChanged) + + foreground = ColorProxyRow( + name='Foreground', + fget=sceneWidget.getForegroundColor, + fset=sceneWidget.setForegroundColor, + notify=sceneWidget.sigStyleChanged) + + text = ColorProxyRow( + name='Text', + fget=sceneWidget.getTextColor, + fset=sceneWidget.setTextColor, + notify=sceneWidget.sigStyleChanged) + + highlight = ColorProxyRow( + name='Highlight', + fget=sceneWidget.getHighlightColor, + fset=sceneWidget.setHighlightColor, + notify=sceneWidget.sigStyleChanged) + + axesIndicator = ProxyRow( + name='Axes Indicator', + fget=sceneWidget.isOrientationIndicatorVisible, + fset=sceneWidget.setOrientationIndicatorVisible, + notify=sceneWidget.sigStyleChanged) + + # Light direction + + self._lightProxy = _DirectionalLightProxy(sceneWidget.viewport.light) + + azimuthNode = ProxyRow( + name='Azimuth', + fget=self._lightProxy.getAzimuthAngle, + fset=self._lightProxy.setAzimuthAngle, + notify=self._lightProxy.sigAzimuthAngleChanged, + editorHint=(-90, 90)) + + altitudeNode = ProxyRow( + name='Altitude', + fget=self._lightProxy.getAltitudeAngle, + fset=self._lightProxy.setAltitudeAngle, + notify=self._lightProxy.sigAltitudeAngleChanged, + editorHint=(-90, 90)) + + lightDirection = StaticRow(('Light Direction', None), + children=(azimuthNode, altitudeNode)) + + # Fog + fog = ProxyRow( + name='Fog', + fget=sceneWidget.getFogMode, + fset=sceneWidget.setFogMode, + notify=sceneWidget.sigStyleChanged, + toModelData=lambda mode: mode is Plot3DWidget.FogMode.LINEAR, + fromModelData=lambda mode: Plot3DWidget.FogMode.LINEAR if mode else Plot3DWidget.FogMode.NONE) + + # Settings row + children = (background, foreground, text, highlight, + axesIndicator, lightDirection, fog) + super(Settings, self).__init__(('Settings', None), children=children) + + +class Item3DRow(BaseRow): + """Represents an :class:`Item3D` with checkable visibility + + :param Item3D item: The scene item to represent. + :param str name: The optional name of the item + """ + + _EVENTS = items.ItemChangedType.VISIBLE, items.Item3DChangedType.LABEL + """Events for which to update the first column in the tree""" + + def __init__(self, item, name=None): + self.__name = None if name is None else str(name) + super(Item3DRow, self).__init__() + + self.setFlags( + self.flags(0) | qt.Qt.ItemIsUserCheckable | qt.Qt.ItemIsSelectable, + 0) + self.setFlags(self.flags(1) | qt.Qt.ItemIsSelectable, 1) + + self._item = weakref.ref(item) + item.sigItemChanged.connect(self._itemChanged) + + def _itemChanged(self, event): + """Handle model update upon change""" + if event in self._EVENTS: + model = self.model() + if model is not None: + index = self.index(column=0) + model.dataChanged.emit(index, index) + + def item(self): + """Returns the :class:`Item3D` item or None""" + return self._item() + + def data(self, column, role): + if column == 0: + if role == qt.Qt.CheckStateRole: + item = self.item() + if item is not None and item.isVisible(): + return qt.Qt.Checked + else: + return qt.Qt.Unchecked + + elif role == qt.Qt.DecorationRole: + return icons.getQIcon('item-3dim') + + elif role == qt.Qt.DisplayRole: + if self.__name is None: + item = self.item() + return '' if item is None else item.getLabel() + else: + return self.__name + + return super(Item3DRow, self).data(column, role) + + def setData(self, column, value, role): + if column == 0 and role == qt.Qt.CheckStateRole: + item = self.item() + if item is not None: + item.setVisible(value == qt.Qt.Checked) + return True + else: + return False + return super(Item3DRow, self).setData(column, value, role) + + def columnCount(self): + return 2 + + +class DataItem3DBoundingBoxRow(ItemProxyRow): + """Represents :class:`DataItem3D` bounding box visibility + + :param DataItem3D item: The item for which to display/control bounding box + """ + + def __init__(self, item): + super(DataItem3DBoundingBoxRow, self).__init__( + item=item, + name='Bounding box', + fget=item.isBoundingBoxVisible, + fset=item.setBoundingBoxVisible, + events=items.Item3DChangedType.BOUNDING_BOX_VISIBLE) + + +class MatrixProxyRow(ItemProxyRow): + """Proxy for a row of a DataItem3D 3x3 matrix transform + + :param DataItem3D item: + :param int index: Matrix row index + """ + + def __init__(self, item, index): + self._item = weakref.ref(item) + self._index = index + + super(MatrixProxyRow, self).__init__( + item=item, + name='', + fget=self._getMatrixRow, + fset=self._setMatrixRow, + events=items.Item3DChangedType.TRANSFORM) + + def _getMatrixRow(self): + """Returns the matrix row. + + :rtype: QVector3D + """ + item = self._item() + if item is not None: + matrix = item.getMatrix() + return qt.QVector3D(*matrix[self._index, :]) + else: + return None + + def _setMatrixRow(self, row): + """Set the row of the matrix + + :param QVector3D row: Row values to set + """ + item = self._item() + if item is not None: + matrix = item.getMatrix() + matrix[self._index, :] = row.x(), row.y(), row.z() + item.setMatrix(matrix) + + def data(self, column, role): + data = super(MatrixProxyRow, self).data(column, role) + + if column == 1 and role == qt.Qt.DisplayRole: + # Convert QVector3D to text + data = "%g; %g; %g" % (data.x(), data.y(), data.z()) + + return data + + +class DataItem3DTransformRow(StaticRow): + """Represents :class:`DataItem3D` transform parameters + + :param DataItem3D item: The item for which to display/control transform + """ + + _ROTATION_CENTER_OPTIONS = 'Origin', 'Lower', 'Center', 'Upper' + + def __init__(self, item): + super(DataItem3DTransformRow, self).__init__(('Transform', None)) + self._item = weakref.ref(item) + + translation = ItemProxyRow( + item=item, + name='Translation', + fget=item.getTranslation, + fset=self._setTranslation, + events=items.Item3DChangedType.TRANSFORM, + toModelData=lambda data: qt.QVector3D(*data)) + self.addRow(translation) + + # Here to keep a reference + self._xSetCenter = functools.partial(self._setCenter, index=0) + self._ySetCenter = functools.partial(self._setCenter, index=1) + self._zSetCenter = functools.partial(self._setCenter, index=2) + + rotateCenter = StaticRow( + ('Center', None), + children=( + ItemProxyRow(item=item, + name='X axis', + fget=item.getRotationCenter, + fset=self._xSetCenter, + events=items.Item3DChangedType.TRANSFORM, + toModelData=functools.partial( + self._centerToModelData, index=0), + editorHint=self._ROTATION_CENTER_OPTIONS), + ItemProxyRow(item=item, + name='Y axis', + fget=item.getRotationCenter, + fset=self._ySetCenter, + events=items.Item3DChangedType.TRANSFORM, + toModelData=functools.partial( + self._centerToModelData, index=1), + editorHint=self._ROTATION_CENTER_OPTIONS), + ItemProxyRow(item=item, + name='Z axis', + fget=item.getRotationCenter, + fset=self._zSetCenter, + events=items.Item3DChangedType.TRANSFORM, + toModelData=functools.partial( + self._centerToModelData, index=2), + editorHint=self._ROTATION_CENTER_OPTIONS), + )) + + rotate = StaticRow( + ('Rotation', None), + children=( + ItemAngleDegreeRow( + item=item, + name='Angle', + fget=item.getRotation, + fset=self._setAngle, + events=items.Item3DChangedType.TRANSFORM, + toModelData=lambda data: data[0]), + ItemProxyRow( + item=item, + name='Axis', + fget=item.getRotation, + fset=self._setAxis, + events=items.Item3DChangedType.TRANSFORM, + toModelData=lambda data: qt.QVector3D(*data[1])), + rotateCenter + )) + self.addRow(rotate) + + scale = ItemProxyRow( + item=item, + name='Scale', + fget=item.getScale, + fset=self._setScale, + events=items.Item3DChangedType.TRANSFORM, + toModelData=lambda data: qt.QVector3D(*data)) + self.addRow(scale) + + matrix = StaticRow( + ('Matrix', None), + children=(MatrixProxyRow(item, 0), + MatrixProxyRow(item, 1), + MatrixProxyRow(item, 2))) + self.addRow(matrix) + + def item(self): + """Returns the :class:`Item3D` item or None""" + return self._item() + + @staticmethod + def _centerToModelData(center, index): + """Convert rotation center information from scene to model. + + :param center: The center info from the scene + :param int index: dimension to convert + """ + value = center[index] + if isinstance(value, str): + return value.title() + elif value == 0.: + return 'Origin' + else: + return str(value) + + def _setCenter(self, value, index): + """Set one dimension of the rotation center. + + :param value: Value received through the model. + :param int index: dimension to set + """ + item = self.item() + if item is not None: + if value == 'Origin': + value = 0. + elif value not in self._ROTATION_CENTER_OPTIONS: + value = float(value) + else: + value = value.lower() + + center = list(item.getRotationCenter()) + center[index] = value + item.setRotationCenter(*center) + + def _setAngle(self, angle): + """Set rotation angle. + + :param float angle: + """ + item = self.item() + if item is not None: + _, axis = item.getRotation() + item.setRotation(angle, axis) + + def _setAxis(self, axis): + """Set rotation axis. + + :param QVector3D axis: + """ + item = self.item() + if item is not None: + angle, _ = item.getRotation() + item.setRotation(angle, (axis.x(), axis.y(), axis.z())) + + def _setTranslation(self, translation): + """Set translation transform. + + :param QVector3D translation: + """ + item = self.item() + if item is not None: + item.setTranslation(translation.x(), translation.y(), translation.z()) + + def _setScale(self, scale): + """Set scale transform. + + :param QVector3D scale: + """ + item = self.item() + if item is not None: + sx, sy, sz = scale.x(), scale.y(), scale.z() + if sx == 0. or sy == 0. or sz == 0.: + _logger.warning('Cannot set scale to 0: ignored') + else: + item.setScale(scale.x(), scale.y(), scale.z()) + + +class GroupItemRow(Item3DRow): + """Represents a :class:`GroupItem` with transforms and children + + :param GroupItem item: The scene group to represent. + :param str name: The optional name of the group + """ + + _CHILDREN_ROW_OFFSET = 2 + """Number of rows for group parameters. Children are added after""" + + def __init__(self, item, name=None): + super(GroupItemRow, self).__init__(item, name) + self.addRow(DataItem3DBoundingBoxRow(item)) + self.addRow(DataItem3DTransformRow(item)) + + item.sigItemAdded.connect(self._itemAdded) + item.sigItemRemoved.connect(self._itemRemoved) + + for child in item.getItems(): + self.addRow(nodeFromItem(child)) + + def _itemAdded(self, item): + """Handle item addition to the group and add it to the model. + + :param Item3D item: added item + """ + group = self.item() + if group is None: + return + + row = group.getItems().index(item) + self.addRow(nodeFromItem(item), row + self._CHILDREN_ROW_OFFSET) + + def _itemRemoved(self, item): + """Handle item removal from the group and remove it from the model. + + :param Item3D item: removed item + """ + group = self.item() + if group is None: + return + + # Find item + for row in self.children(): + if isinstance(row, Item3DRow) and row.item() is item: + self.removeRow(row) + break # Got it + else: + raise RuntimeError("Model does not correspond to scene content") + + +class InterpolationRow(ItemProxyRow): + """Represents :class:`InterpolationMixIn` property. + + :param Item3D item: Scene item with interpolation property + """ + + def __init__(self, item): + modes = [mode.title() for mode in item.INTERPOLATION_MODES] + super(InterpolationRow, self).__init__( + item=item, + name='Interpolation', + fget=item.getInterpolation, + fset=item.setInterpolation, + events=items.Item3DChangedType.INTERPOLATION, + toModelData=lambda mode: mode.title(), + fromModelData=lambda mode: mode.lower(), + editorHint=modes) + + +class _ColormapBaseProxyRow(ProxyRow): + """Base class for colormap model row + + This class handle synchronization and signals from the item and the colormap + """ + + _sigColormapChanged = qt.Signal() + """Signal used internally to notify colormap (or data) update""" + + def __init__(self, item, *args, **kwargs): + self._item = weakref.ref(item) + self._colormap = item.getColormap() + + ProxyRow.__init__(self, *args, **kwargs) + + self._colormap.sigChanged.connect(self._colormapChanged) + item.sigItemChanged.connect(self._itemChanged) + self._sigColormapChanged.connect(self._modelUpdated) + + def item(self): + """Returns the :class:`ColormapMixIn` item or None""" + return self._item() + + def _getColormapRange(self): + """Returns the range of the colormap for the current data. + + :return: Colormap range (min, max) + """ + item = self.item() + if item is not None and self._colormap is not None: + return self._colormap.getColormapRange(item) + else: + return 1, 100 # Fallback + + def _modelUpdated(self, *args, **kwargs): + """Emit dataChanged in the model""" + topLeft = self.index(column=0) + bottomRight = self.index(column=1) + model = self.model() + if model is not None: + model.dataChanged.emit(topLeft, bottomRight) + + def _colormapChanged(self): + self._sigColormapChanged.emit() + + def _itemChanged(self, event): + """Handle change of colormap or data in the item. + + :param ItemChangedType event: + """ + if event == items.ItemChangedType.COLORMAP: + self._sigColormapChanged.emit() + if self._colormap is not None: + self._colormap.sigChanged.disconnect(self._colormapChanged) + + item = self.item() + if item is not None: + self._colormap = item.getColormap() + self._colormap.sigChanged.connect(self._colormapChanged) + else: + self._colormap = None + + elif event == items.ItemChangedType.DATA: + self._sigColormapChanged.emit() + + +class _ColormapBoundRow(_ColormapBaseProxyRow): + """ProxyRow for colormap min or max + + :param ColormapMixIn item: The item to handle + :param str name: Name of the raw + :param int index: 0 for Min and 1 of Max + """ + + def __init__(self, item, name, index): + self._index = index + _ColormapBaseProxyRow.__init__( + self, + item, + name=name, + fget=self._getBound, + fset=self._setBound) + + self.setToolTip('Colormap %s bound:\n' + 'Check to set bound manually, ' + 'uncheck for autoscale' % name.lower()) + + def _getRawBound(self): + """Proxy to get raw colormap bound + + :rtype: float or None + """ + if self._colormap is None: + return None + elif self._index == 0: + return self._colormap.getVMin() + else: # self._index == 1 + return self._colormap.getVMax() + + def _getBound(self): + """Proxy to get colormap effective bound value + + :rtype: float + """ + if self._colormap is not None: + bound = self._getRawBound() + + if bound is None: + bound = self._getColormapRange()[self._index] + return bound + else: + return 1. # Fallback + + def _setBound(self, value): + """Proxy to set colormap bound. + + :param float value: + """ + if self._colormap is not None: + if self._index == 0: + min_ = value + max_ = self._colormap.getVMax() + else: # self._index == 1 + min_ = self._colormap.getVMin() + max_ = value + + if max_ is not None and min_ is not None and min_ > max_: + min_, max_ = max_, min_ + self._colormap.setVRange(min_, max_) + + def flags(self, column): + if column == 0: + return qt.Qt.ItemIsEnabled | qt.Qt.ItemIsUserCheckable + + elif column == 1: + if self._getRawBound() is not None: + flags = qt.Qt.ItemIsEditable | qt.Qt.ItemIsEnabled + else: + flags = qt.Qt.NoItemFlags # Disabled if autoscale + return flags + + else: # Never event + return super(_ColormapBoundRow, self).flags(column) + + def data(self, column, role): + if column == 0 and role == qt.Qt.CheckStateRole: + if self._getRawBound() is None: + return qt.Qt.Unchecked + else: + return qt.Qt.Checked + + else: + return super(_ColormapBoundRow, self).data(column, role) + + def setData(self, column, value, role): + if column == 0 and role == qt.Qt.CheckStateRole: + if self._colormap is not None: + bound = self._getBound() if value == qt.Qt.Checked else None + self._setBound(bound) + return True + else: + return False + + return super(_ColormapBoundRow, self).setData(column, value, role) + + +class _ColormapGammaRow(_ColormapBaseProxyRow): + """ProxyRow for colormap gamma normalization parameter + + :param ColormapMixIn item: The item to handle + :param str name: Name of the raw + """ + + def __init__(self, item): + _ColormapBaseProxyRow.__init__( + self, + item, + name="Gamma", + fget=self._getGammaNormalizationParameter, + fset=self._setGammaNormalizationParameter) + + self.setToolTip('Colormap gamma correction parameter:\n' + 'Only meaningful for gamma normalization.') + + def _getGammaNormalizationParameter(self): + """Proxy for :meth:`Colormap.getGammaNormalizationParameter`""" + if self._colormap is not None: + return self._colormap.getGammaNormalizationParameter() + else: + return 0.0 + + def _setGammaNormalizationParameter(self, gamma): + """Proxy for :meth:`Colormap.setGammaNormalizationParameter`""" + if self._colormap is not None: + return self._colormap.setGammaNormalizationParameter(gamma) + + def _getNormalization(self): + """Proxy for :meth:`Colormap.getNormalization`""" + if self._colormap is not None: + return self._colormap.getNormalization() + else: + return '' + + def flags(self, column): + if column in (0, 1): + if self._getNormalization() == 'gamma': + flags = qt.Qt.ItemIsEditable | qt.Qt.ItemIsEnabled + else: + flags = qt.Qt.NoItemFlags # Disabled if not gamma correction + return flags + + else: # Never event + return super(_ColormapGammaRow, self).flags(column) + + +class ColormapRow(_ColormapBaseProxyRow): + """Represents :class:`ColormapMixIn` property. + + :param Item3D item: Scene item with colormap property + """ + + def __init__(self, item): + super(ColormapRow, self).__init__( + item, + name='Colormap', + fget=self._get) + + self._colormapImage = None + + self._colormapsMapping = {} + for cmap in preferredColormaps(): + self._colormapsMapping[cmap.title()] = cmap + + self.addRow(ProxyRow( + name='Name', + fget=self._getName, + fset=self._setName, + notify=self._sigColormapChanged, + editorHint=list(self._colormapsMapping.keys()))) + + norms = [norm.title() for norm in self._colormap.NORMALIZATIONS] + self.addRow(ProxyRow( + name='Normalization', + fget=self._getNormalization, + fset=self._setNormalization, + notify=self._sigColormapChanged, + editorHint=norms)) + + self.addRow(_ColormapGammaRow(item)) + + modes = [mode.title() for mode in self._colormap.AUTOSCALE_MODES] + self.addRow(ProxyRow( + name='Autoscale Mode', + fget=self._getAutoscaleMode, + fset=self._setAutoscaleMode, + notify=self._sigColormapChanged, + editorHint=modes)) + + self.addRow(_ColormapBoundRow(item, name='Min.', index=0)) + self.addRow(_ColormapBoundRow(item, name='Max.', index=1)) + + self._sigColormapChanged.connect(self._updateColormapImage) + + def getColormapImage(self): + """Returns image representing the colormap or None + + :rtype: Union[QImage,None] + """ + if self._colormapImage is None and self._colormap is not None: + image = numpy.zeros((16, 130, 3), dtype=numpy.uint8) + image[1:-1, 1:-1] = self._colormap.getNColors(image.shape[1] - 2)[:, :3] + self._colormapImage = convertArrayToQImage(image) + return self._colormapImage + + def _get(self): + """Getter for ProxyRow subclass""" + return None + + def _getName(self): + """Proxy for :meth:`Colormap.getName`""" + if self._colormap is not None and self._colormap.getName() is not None: + return self._colormap.getName().title() + else: + return '' + + def _setName(self, name): + """Proxy for :meth:`Colormap.setName`""" + # Convert back from titled to name if possible + if self._colormap is not None: + name = self._colormapsMapping.get(name, name) + self._colormap.setName(name) + + def _getNormalization(self): + """Proxy for :meth:`Colormap.getNormalization`""" + if self._colormap is not None: + return self._colormap.getNormalization().title() + else: + return '' + + def _setNormalization(self, normalization): + """Proxy for :meth:`Colormap.setNormalization`""" + if self._colormap is not None: + return self._colormap.setNormalization(normalization.lower()) + + def _getAutoscaleMode(self): + """Proxy for :meth:`Colormap.getAutoscaleMode`""" + if self._colormap is not None: + return self._colormap.getAutoscaleMode().title() + else: + return '' + + def _setAutoscaleMode(self, mode): + """Proxy for :meth:`Colormap.setAutoscaleMode`""" + if self._colormap is not None: + return self._colormap.setAutoscaleMode(mode.lower()) + + def _updateColormapImage(self, *args, **kwargs): + """Notify colormap update to update the image in the tree""" + if self._colormapImage is not None: + self._colormapImage = None + model = self.model() + if model is not None: + index = self.index(column=1) + model.dataChanged.emit(index, index) + + def data(self, column, role): + if column == 1 and role == qt.Qt.DecorationRole: + return self.getColormapImage() + else: + return super(ColormapRow, self).data(column, role) + + +class SymbolRow(ItemProxyRow): + """Represents :class:`SymbolMixIn` symbol property. + + :param Item3D item: Scene item with symbol property + """ + + def __init__(self, item): + names = [item.getSymbolName(s) for s in item.getSupportedSymbols()] + super(SymbolRow, self).__init__( + item=item, + name='Marker', + fget=item.getSymbolName, + fset=item.setSymbol, + events=items.ItemChangedType.SYMBOL, + editorHint=names) + + +class SymbolSizeRow(ItemProxyRow): + """Represents :class:`SymbolMixIn` symbol size property. + + :param Item3D item: Scene item with symbol size property + """ + + def __init__(self, item): + super(SymbolSizeRow, self).__init__( + item=item, + name='Marker size', + fget=item.getSymbolSize, + fset=item.setSymbolSize, + events=items.ItemChangedType.SYMBOL_SIZE, + editorHint=(1, 20)) # TODO link with OpenGL max point size + + +class PlaneEquationRow(ItemProxyRow): + """Represents :class:`PlaneMixIn` as plane equation. + + :param Item3D item: Scene item with plane equation property + """ + + def __init__(self, item): + super(PlaneEquationRow, self).__init__( + item=item, + name='Equation', + fget=item.getParameters, + fset=item.setParameters, + events=items.ItemChangedType.POSITION, + toModelData=lambda data: qt.QVector4D(*data), + fromModelData=lambda data: (data.x(), data.y(), data.z(), data.w())) + self._item = weakref.ref(item) + + def data(self, column, role): + if column == 1 and role == qt.Qt.DisplayRole: + item = self._item() + if item is not None: + params = item.getParameters() + return ('%gx %+gy %+gz %+g = 0' % + (params[0], params[1], params[2], params[3])) + return super(PlaneEquationRow, self).data(column, role) + + +class PlaneRow(ItemProxyRow): + """Represents :class:`PlaneMixIn` property. + + :param Item3D item: Scene item with plane equation property + """ + + _PLANES = OrderedDict((('Plane 0', (1., 0., 0.)), + ('Plane 1', (0., 1., 0.)), + ('Plane 2', (0., 0., 1.)), + ('-', None))) + """Mapping of plane names to normals""" + + _PLANE_ICONS = {'Plane 0': '3d-plane-normal-x', + 'Plane 1': '3d-plane-normal-y', + 'Plane 2': '3d-plane-normal-z', + '-': '3d-plane'} + """Mapping of plane names to normals""" + + def __init__(self, item): + super(PlaneRow, self).__init__( + item=item, + name='Plane', + fget=self.__getPlaneName, + fset=self.__setPlaneName, + events=items.ItemChangedType.POSITION, + editorHint=tuple(self._PLANES.keys())) + self._item = weakref.ref(item) + self._lastName = None + + self.addRow(PlaneEquationRow(item)) + + def _notified(self, *args, **kwargs): + """Handle notification of modification + + Here only send if plane name actually changed + """ + if self._lastName != self.__getPlaneName(): + super(PlaneRow, self)._notified() + + def __getPlaneName(self): + """Returns name of plane // to axes or '-' + + :rtype: str + """ + item = self._item() + planeNormal = item.getNormal() if item is not None else None + + for name, normal in self._PLANES.items(): + if numpy.array_equal(planeNormal, normal): + return name + return '-' + + def __setPlaneName(self, data): + """Set plane normal according to given plane name + + :param str data: Selected plane name + """ + item = self._item() + if item is not None: + for name, normal in self._PLANES.items(): + if data == name and normal is not None: + item.setNormal(normal) + + def data(self, column, role): + if column == 1 and role == qt.Qt.DecorationRole: + return icons.getQIcon(self._PLANE_ICONS[self.__getPlaneName()]) + data = super(PlaneRow, self).data(column, role) + if column == 1 and role == qt.Qt.DisplayRole: + self._lastName = data + return data + + +class ComplexModeRow(ItemProxyRow): + """Represents :class:`items.ComplexMixIn` symbol property. + + :param Item3D item: Scene item with symbol property + """ + + def __init__(self, item, name='Mode'): + names = [m.value.replace('_', ' ').title() + for m in item.supportedComplexModes()] + super(ComplexModeRow, self).__init__( + item=item, + name=name, + fget=item.getComplexMode, + fset=item.setComplexMode, + events=items.ItemChangedType.COMPLEX_MODE, + toModelData=lambda data: data.value.replace('_', ' ').title(), + fromModelData=lambda data: data.lower().replace(' ', '_'), + editorHint=names) + + +class RemoveIsosurfaceRow(BaseRow): + """Class for Isosurface Delete button + + :param Isosurface isosurface: The isosurface item to attach the button to. + """ + + def __init__(self, isosurface): + super(RemoveIsosurfaceRow, self).__init__() + self._isosurface = weakref.ref(isosurface) + + def createEditor(self): + """Specific editor factory provided to the model""" + editor = qt.QWidget() + layout = qt.QHBoxLayout(editor) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + removeBtn = qt.QToolButton() + removeBtn.setText('Delete') + removeBtn.setToolButtonStyle(qt.Qt.ToolButtonTextOnly) + layout.addWidget(removeBtn) + removeBtn.clicked.connect(self._removeClicked) + + layout.addStretch(1) + return editor + + def isosurface(self): + """Returns the controlled isosurface + + :rtype: Isosurface + """ + return self._isosurface() + + def data(self, column, role): + if column == 0 and role == qt.Qt.UserRole: # editor hint + return self.createEditor + + return super(RemoveIsosurfaceRow, self).data(column, role) + + def flags(self, column): + flags = super(RemoveIsosurfaceRow, self).flags(column) + if column == 0: + flags |= qt.Qt.ItemIsEditable + return flags + + def _removeClicked(self): + """Handle Delete button clicked""" + isosurface = self.isosurface() + if isosurface is not None: + volume = isosurface.parent() + if volume is not None: + volume.removeIsosurface(isosurface) + + +class IsosurfaceRow(Item3DRow): + """Represents an :class:`Isosurface` item. + + :param Isosurface item: Isosurface item + """ + + _LEVEL_SLIDER_RANGE = 0, 1000 + """Range given as editor hint""" + + _EVENTS = items.ItemChangedType.VISIBLE, items.ItemChangedType.COLOR + """Events for which to update the first column in the tree""" + + def __init__(self, item): + super(IsosurfaceRow, self).__init__(item, name=item.getLevel()) + + self.setFlags(self.flags(1) | qt.Qt.ItemIsEditable, 1) + + item.sigItemChanged.connect(self._levelChanged) + + self.addRow(ItemProxyRow( + item=item, + name='Level', + fget=self._getValueForLevelSlider, + fset=self._setLevelFromSliderValue, + events=items.Item3DChangedType.ISO_LEVEL, + editorHint=self._LEVEL_SLIDER_RANGE)) + + self.addRow(ItemColorProxyRow( + item=item, + name='Color', + fget=self._rgbColor, + fset=self._setRgbColor, + events=items.ItemChangedType.COLOR)) + + self.addRow(ItemProxyRow( + item=item, + name='Opacity', + fget=self._opacity, + fset=self._setOpacity, + events=items.ItemChangedType.COLOR, + editorHint=(0, 255))) + + self.addRow(RemoveIsosurfaceRow(item)) + + def _getValueForLevelSlider(self): + """Convert iso level to slider value. + + :rtype: int + """ + item = self.item() + if item is not None: + volume = item.parent() + if volume is not None: + dataRange = volume.getDataRange() + if dataRange is not None: + dataMin, dataMax = dataRange[0], dataRange[-1] + if dataMax != dataMin: + offset = (item.getLevel() - dataMin) / (dataMax - dataMin) + else: + offset = 0. + + sliderMin, sliderMax = self._LEVEL_SLIDER_RANGE + value = sliderMin + (sliderMax - sliderMin) * offset + return value + return 0 + + def _setLevelFromSliderValue(self, value): + """Convert slider value to isolevel. + + :param int value: + """ + item = self.item() + if item is not None: + volume = item.parent() + if volume is not None: + dataRange = volume.getDataRange() + if dataRange is not None: + sliderMin, sliderMax = self._LEVEL_SLIDER_RANGE + offset = (value - sliderMin) / (sliderMax - sliderMin) + + dataMin, dataMax = dataRange[0], dataRange[-1] + level = dataMin + (dataMax - dataMin) * offset + item.setLevel(level) + + def _rgbColor(self): + """Proxy to get the isosurface's RGB color without transparency + + :rtype: QColor + """ + item = self.item() + if item is None: + return None + else: + color = item.getColor() + color.setAlpha(255) + return color + + def _setRgbColor(self, color): + """Proxy to set the isosurface's RGB color without transparency + + :param QColor color: + """ + item = self.item() + if item is not None: + color.setAlpha(item.getColor().alpha()) + item.setColor(color) + + def _opacity(self): + """Proxy to get the isosurface's transparency + + :rtype: int + """ + item = self.item() + return 255 if item is None else item.getColor().alpha() + + def _setOpacity(self, opacity): + """Proxy to set the isosurface's transparency. + + :param int opacity: + """ + item = self.item() + if item is not None: + color = item.getColor() + color.setAlpha(opacity) + item.setColor(color) + + def _levelChanged(self, event): + """Handle isosurface level changed and notify model + + :param ItemChangedType event: + """ + if event == items.Item3DChangedType.ISO_LEVEL: + model = self.model() + if model is not None: + index = self.index(column=1) + model.dataChanged.emit(index, index) + + def data(self, column, role): + if column == 0: # Show color as decoration, not text + if role == qt.Qt.DisplayRole: + return None + elif role == qt.Qt.DecorationRole: + return self._rgbColor() + + elif column == 1 and role in (qt.Qt.DisplayRole, qt.Qt.EditRole): + item = self.item() + return None if item is None else item.getLevel() + + return super(IsosurfaceRow, self).data(column, role) + + def setData(self, column, value, role): + if column == 1 and role == qt.Qt.EditRole: + item = self.item() + if item is not None: + item.setLevel(value) + return True + + return super(IsosurfaceRow, self).setData(column, value, role) + + +class ComplexIsosurfaceRow(IsosurfaceRow): + """Represents an :class:`ComplexIsosurface` item. + + :param ComplexIsosurface item: + """ + + _EVENTS = (items.ItemChangedType.VISIBLE, + items.ItemChangedType.COLOR, + items.ItemChangedType.COMPLEX_MODE) + """Events for which to update the first column in the tree""" + + def __init__(self, item): + super(ComplexIsosurfaceRow, self).__init__(item) + + self.addRow(ComplexModeRow(item, "Color Complex Mode"), index=1) + for row in self.children(): + if isinstance(row, ColorProxyRow): + self._colorRow = row + break + else: + raise RuntimeError("Cannot retrieve Color tree row") + self._colormapRow = ColormapRow(item) + + self.__updateRowsForItem(item) + item.sigItemChanged.connect(self._itemChanged) + + def _itemChanged(self, event): + """Update enabled/disabled rows""" + if event == items.ItemChangedType.COMPLEX_MODE: + item = self.sender() + self.__updateRowsForItem(item) + + def __updateRowsForItem(self, item): + """Update rows for item + + :param item: + """ + if not isinstance(item, ComplexIsosurface): + return + + if item.getComplexMode() == items.ComplexMixIn.ComplexMode.NONE: + removed = self._colormapRow + added = self._colorRow + else: + removed = self._colorRow + added = self._colormapRow + + # Remove unwanted rows + if removed in self.children(): + self.removeRow(removed) + + # Add required rows + if added not in self.children(): + self.addRow(added, index=2) + + def data(self, column, role): + if column == 0 and role == qt.Qt.DecorationRole: + item = self.item() + if (item is not None and + item.getComplexMode() != items.ComplexMixIn.ComplexMode.NONE): + return self._colormapRow.getColormapImage() + + return super(ComplexIsosurfaceRow, self).data(column, role) + + +class AddIsosurfaceRow(BaseRow): + """Class for Isosurface create button + + :param Union[ScalarField3D,ComplexField3D] volume: + The volume item to attach the button to. + """ + + def __init__(self, volume): + super(AddIsosurfaceRow, self).__init__() + self._volume = weakref.ref(volume) + + def createEditor(self): + """Specific editor factory provided to the model""" + editor = qt.QWidget() + layout = qt.QHBoxLayout(editor) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + addBtn = qt.QToolButton() + addBtn.setText('+') + addBtn.setToolButtonStyle(qt.Qt.ToolButtonTextOnly) + layout.addWidget(addBtn) + addBtn.clicked.connect(self._addClicked) + + layout.addStretch(1) + return editor + + def volume(self): + """Returns the controlled volume item + + :rtype: Union[ScalarField3D,ComplexField3D] + """ + return self._volume() + + def data(self, column, role): + if column == 0 and role == qt.Qt.UserRole: # editor hint + return self.createEditor + + return super(AddIsosurfaceRow, self).data(column, role) + + def flags(self, column): + flags = super(AddIsosurfaceRow, self).flags(column) + if column == 0: + flags |= qt.Qt.ItemIsEditable + return flags + + def _addClicked(self): + """Handle Delete button clicked""" + volume = self.volume() + if volume is not None: + dataRange = volume.getDataRange() + if dataRange is None: + dataRange = 0., 1. + + volume.addIsosurface( + numpy.mean((dataRange[0], dataRange[-1])), + '#0000FF') + + +class VolumeIsoSurfacesRow(StaticRow): + """Represents :class:`ScalarFieldView`'s isosurfaces + + :param Union[ScalarField3D,ComplexField3D] volume: + Volume item to control + """ + + def __init__(self, volume): + super(VolumeIsoSurfacesRow, self).__init__( + ('Isosurfaces', None)) + self._volume = weakref.ref(volume) + + volume.sigIsosurfaceAdded.connect(self._isosurfaceAdded) + volume.sigIsosurfaceRemoved.connect(self._isosurfaceRemoved) + + if isinstance(volume, items.ComplexMixIn): + self.addRow(ComplexModeRow(volume, "Complex Mode")) + + for item in volume.getIsosurfaces(): + self.addRow(nodeFromItem(item)) + + self.addRow(AddIsosurfaceRow(volume)) + + def volume(self): + """Returns the controlled volume item + + :rtype: Union[ScalarField3D,ComplexField3D] + """ + return self._volume() + + def _isosurfaceAdded(self, item): + """Handle isosurface addition + + :param Isosurface item: added isosurface + """ + volume = self.volume() + if volume is None: + return + + row = volume.getIsosurfaces().index(item) + if isinstance(volume, items.ComplexMixIn): + row += 1 # Offset for the ComplexModeRow + self.addRow(nodeFromItem(item), row) + + def _isosurfaceRemoved(self, item): + """Handle isosurface removal + + :param Isosurface item: removed isosurface + """ + volume = self.volume() + if volume is None: + return + + # Find item + for row in self.children(): + if isinstance(row, IsosurfaceRow) and row.item() is item: + self.removeRow(row) + break # Got it + else: + raise RuntimeError("Model does not correspond to scene content") + + +class Scatter2DPropertyMixInRow(object): + """Mix-in class that enable/disable row according to Scatter2D mode. + + :param Scatter2D item: + :param str propertyName: Name of the Scatter2D property of this row + """ + + def __init__(self, item, propertyName): + assert propertyName in ('lineWidth', 'symbol', 'symbolSize') + self.__propertyName = propertyName + + self.__isEnabled = item.isPropertyEnabled(propertyName) + self.__updateFlags() + + item.sigItemChanged.connect(self._itemChanged) + + def data(self, column, role): + if column == 1 and not self.__isEnabled: + # Discard data and editorHint if disabled + return None + else: + return super(Scatter2DPropertyMixInRow, self).data(column, role) + + def __updateFlags(self): + """Update model flags""" + if self.__isEnabled: + self.setFlags(qt.Qt.ItemIsEnabled, 0) + self.setFlags(qt.Qt.ItemIsEnabled | qt.Qt.ItemIsEditable, 1) + else: + self.setFlags(qt.Qt.NoItemFlags) + + def _itemChanged(self, event): + """Set flags to enable/disable the row""" + if event == items.ItemChangedType.VISUALIZATION_MODE: + item = self.sender() + if item is not None: # This occurs with PySide/python2.7 + self.__isEnabled = item.isPropertyEnabled(self.__propertyName) + self.__updateFlags() + + # Notify model + model = self.model() + if model is not None: + begin = self.index(column=0) + end = self.index(column=1) + model.dataChanged.emit(begin, end) + + +class Scatter2DSymbolRow(Scatter2DPropertyMixInRow, SymbolRow): + """Specific class for Scatter2D symbol. + + It is enabled/disabled according to visualization mode. + + :param Scatter2D item: + """ + + def __init__(self, item): + SymbolRow.__init__(self, item) + Scatter2DPropertyMixInRow.__init__(self, item, 'symbol') + + +class Scatter2DSymbolSizeRow(Scatter2DPropertyMixInRow, SymbolSizeRow): + """Specific class for Scatter2D symbol size. + + It is enabled/disabled according to visualization mode. + + :param Scatter2D item: + """ + + def __init__(self, item): + SymbolSizeRow.__init__(self, item) + Scatter2DPropertyMixInRow.__init__(self, item, 'symbolSize') + + +class Scatter2DLineWidth(Scatter2DPropertyMixInRow, ItemProxyRow): + """Specific class for Scatter2D symbol size. + + It is enabled/disabled according to visualization mode. + + :param Scatter2D item: + """ + + def __init__(self, item): + # TODO link editorHint with OpenGL max line width + ItemProxyRow.__init__(self, + item=item, + name='Line width', + fget=item.getLineWidth, + fset=item.setLineWidth, + events=items.ItemChangedType.LINE_WIDTH, + editorHint=(1, 10)) + Scatter2DPropertyMixInRow.__init__(self, item, 'lineWidth') + + +def initScatter2DNode(node, item): + """Specific node init for Scatter2D to set order of parameters + + :param Item3DRow node: The model node to setup + :param Scatter2D item: The Scatter2D the node is representing + """ + node.addRow(ItemProxyRow( + item=item, + name='Mode', + fget=item.getVisualization, + fset=item.setVisualization, + events=items.ItemChangedType.VISUALIZATION_MODE, + editorHint=[m.value.title() for m in item.supportedVisualizations()], + toModelData=lambda data: data.value.title(), + fromModelData=lambda data: data.lower())) + + node.addRow(ItemProxyRow( + item=item, + name='Height map', + fget=item.isHeightMap, + fset=item.setHeightMap, + events=items.Item3DChangedType.HEIGHT_MAP)) + + node.addRow(ColormapRow(item)) + + node.addRow(Scatter2DSymbolRow(item)) + node.addRow(Scatter2DSymbolSizeRow(item)) + + node.addRow(Scatter2DLineWidth(item)) + + +def initVolumeNode(node, item): + """Specific node init for volume items + + :param Item3DRow node: The model node to setup + :param Union[ScalarField3D,ComplexField3D] item: + The volume item represented by the node + """ + node.addRow(nodeFromItem(item.getCutPlanes()[0])) # Add cut plane + node.addRow(VolumeIsoSurfacesRow(item)) + + +def initVolumeCutPlaneNode(node, item): + """Specific node init for volume CutPlane + + :param Item3DRow node: The model node to setup + :param CutPlane item: The CutPlane the node is representing + """ + if isinstance(item, items.ComplexMixIn): + node.addRow(ComplexModeRow(item)) + + node.addRow(PlaneRow(item)) + + node.addRow(ColormapRow(item)) + + node.addRow(ItemProxyRow( + item=item, + name='Show <=Min', + fget=item.getDisplayValuesBelowMin, + fset=item.setDisplayValuesBelowMin, + events=items.ItemChangedType.ALPHA)) + + node.addRow(InterpolationRow(item)) + + +NODE_SPECIFIC_INIT = [ # class, init(node, item) + (items.Scatter2D, initScatter2DNode), + (items.ScalarField3D, initVolumeNode), + (CutPlane, initVolumeCutPlaneNode), +] +"""List of specific node init for different item class""" + + +def nodeFromItem(item): + """Create :class:`Item3DRow` subclass corresponding to item + + :param Item3D item: The item fow which to create the node + :rtype: Item3DRow + """ + assert isinstance(item, items.Item3D) + + # Item with specific model row class + if isinstance(item, (items.GroupItem, items.GroupWithAxesItem)): + return GroupItemRow(item) + elif isinstance(item, ComplexIsosurface): + return ComplexIsosurfaceRow(item) + elif isinstance(item, Isosurface): + return IsosurfaceRow(item) + + # Create Item3DRow and populate it + node = Item3DRow(item) + + if isinstance(item, items.DataItem3D): + node.addRow(DataItem3DBoundingBoxRow(item)) + node.addRow(DataItem3DTransformRow(item)) + + # Specific extra init + for cls, specificInit in NODE_SPECIFIC_INIT: + if isinstance(item, cls): + specificInit(node, item) + break + + else: # Generic case: handle mixins + for cls in item.__class__.__mro__: + if cls is items.ColormapMixIn: + node.addRow(ColormapRow(item)) + + elif cls is items.InterpolationMixIn: + node.addRow(InterpolationRow(item)) + + elif cls is items.SymbolMixIn: + node.addRow(SymbolRow(item)) + node.addRow(SymbolSizeRow(item)) + + elif cls is items.PlaneMixIn: + node.addRow(PlaneRow(item)) + + return node diff --git a/src/silx/gui/plot3d/_model/model.py b/src/silx/gui/plot3d/_model/model.py new file mode 100644 index 0000000..186838f --- /dev/null +++ b/src/silx/gui/plot3d/_model/model.py @@ -0,0 +1,184 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2017-2018 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +""" +This module provides the :class:`SceneWidget` content and parameters model. +""" + +from __future__ import absolute_import, division + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "11/01/2018" + + +import weakref + +from ... import qt + +from .core import BaseRow +from .items import Settings, nodeFromItem + + +def visitQAbstractItemModel(model, parent=qt.QModelIndex()): + """Iterate over indices in the model starting from parent + + It iterates column by column and row by row + (i.e., from left to right and from top to bottom). + Parent are returned before their children. + It only iterates through the children for the first column of a row. + + :param QAbstractItemModel model: The model to visit + :param QModelIndex parent: + Index from which to start visiting the model. + Default: start from the root + """ + assert isinstance(model, qt.QAbstractItemModel) + assert isinstance(parent, qt.QModelIndex) + assert parent.model() is model or not parent.isValid() + + for row in range(model.rowCount(parent)): + for column in range(model.columnCount(parent)): + index = model.index(row, column, parent) + yield index + + index = model.index(row, 0, parent) + for index in visitQAbstractItemModel(model, index): + yield index + + +class Root(BaseRow): + """Root node of :class:`SceneWidget` parameters. + + It has two children: + - Settings + - Scene group + """ + + def __init__(self, model, sceneWidget): + super(Root, self).__init__() + self._sceneWidget = weakref.ref(sceneWidget) + self.setParent(model) # Needed for Root + + def children(self): + sceneWidget = self._sceneWidget() + if sceneWidget is None: + return () + else: + return super(Root, self).children() + + +class SceneModel(qt.QAbstractItemModel): + """Model of a :class:`SceneWidget`. + + :param SceneWidget parent: The SceneWidget this model represents. + """ + + def __init__(self, parent): + self._sceneWidget = weakref.ref(parent) + + super(SceneModel, self).__init__(parent) + self._root = Root(self, parent) + self._root.addRow(Settings(parent)) + self._root.addRow(nodeFromItem(parent.getSceneGroup())) + + def sceneWidget(self): + """Returns the :class:`SceneWidget` this model represents. + + In case the widget has already been deleted, it returns None + + :rtype: SceneWidget + """ + return self._sceneWidget() + + def _itemFromIndex(self, index): + """Returns the corresponding :class:`Node` or :class:`Item3D`. + + :param QModelIndex index: + :rtype: Node or Item3D + """ + return index.internalPointer() if index.isValid() else self._root + + def index(self, row, column, parent=qt.QModelIndex()): + """See :meth:`QAbstractItemModel.index`""" + if column >= self.columnCount(parent) or row >= self.rowCount(parent): + return qt.QModelIndex() + + item = self._itemFromIndex(parent) + return self.createIndex(row, column, item.children()[row]) + + def parent(self, index): + """See :meth:`QAbstractItemModel.parent`""" + if not index.isValid(): + return qt.QModelIndex() + + item = self._itemFromIndex(index) + parent = item.parent() + + ancestor = parent.parent() + + if ancestor is not self: # root node + children = ancestor.children() + row = children.index(parent) + return self.createIndex(row, 0, parent) + + return qt.QModelIndex() + + def rowCount(self, parent=qt.QModelIndex()): + """See :meth:`QAbstractItemModel.rowCount`""" + item = self._itemFromIndex(parent) + return item.rowCount() + + def columnCount(self, parent=qt.QModelIndex()): + """See :meth:`QAbstractItemModel.columnCount`""" + item = self._itemFromIndex(parent) + return item.columnCount() + + def data(self, index, role=qt.Qt.DisplayRole): + """See :meth:`QAbstractItemModel.data`""" + item = self._itemFromIndex(index) + column = index.column() + return item.data(column, role) + + def setData(self, index, value, role=qt.Qt.EditRole): + """See :meth:`QAbstractItemModel.setData`""" + item = self._itemFromIndex(index) + column = index.column() + if item.setData(column, value, role): + self.dataChanged.emit(index, index) + return True + return False + + def flags(self, index): + """See :meth:`QAbstractItemModel.flags`""" + item = self._itemFromIndex(index) + column = index.column() + return item.flags(column) + + def headerData(self, section, orientation, role=qt.Qt.DisplayRole): + """See :meth:`QAbstractItemModel.headerData`""" + if orientation == qt.Qt.Horizontal and role == qt.Qt.DisplayRole: + return 'Item' if section == 0 else 'Value' + else: + return None -- cgit v1.2.3