# coding: utf-8 # /*########################################################################## # # Copyright (c) 2015-2020 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in # all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. # # ###########################################################################*/ """ This module provides a tree widget to set/view parameters of a ScalarFieldView. """ from __future__ import absolute_import __authors__ = ["D. N."] __license__ = "MIT" __date__ = "24/04/2018" import logging import sys import weakref import numpy from silx.gui import qt from silx.gui.icons import getQIcon from silx.gui.colors import Colormap from silx.gui.widgets.FloatEdit import FloatEdit from .ScalarFieldView import Isosurface _logger = logging.getLogger(__name__) class ModelColumns(object): NameColumn, ValueColumn, ColumnMax = range(3) ColumnNames = ['Name', 'Value'] class SubjectItem(qt.QStandardItem): """ Base class for observers items. Subclassing: ------------ The following method can/should be reimplemented: - _init - _pullData - _pushData - _setModelData - _subjectChanged - getEditor - getSignals - leftClicked - queryRemove - setEditorData Also the following attributes are available: - editable - persistent :param subject: object that this item will be observing. """ editable = False """ boolean: set to True to make the item editable. """ persistent = False """ boolean: set to True to make the editor persistent. See : Qt.QAbstractItemView.openPersistentEditor """ def __init__(self, subject, *args): super(SubjectItem, self).__init__(*args) self.setEditable(self.editable) self.__subject = None self.subject = subject def setData(self, value, role=qt.Qt.UserRole, pushData=True): """ Overloaded method from QStandardItem. The pushData keyword tells the item to push data to the subject if the role is equal to EditRole. This is useful to let this method know if the setData method was called internally or from the view. :param value: the value ti set to data :param role: role in the item :param pushData: if True push value in the existing data. """ if role == qt.Qt.EditRole and pushData: setValue = self._pushData(value, role) if setValue != value: value = setValue super(SubjectItem, self).setData(value, role) @property def subject(self): """The subject this item is observing""" return None if self.__subject is None else self.__subject() @subject.setter def subject(self, subject): if self.__subject is not None: raise ValueError('Subject already set ' ' (subject change not supported).') if subject is None: self.__subject = None else: self.__subject = weakref.ref(subject) if subject is not None: self._init() self._connectSignals() def _connectSignals(self): """ Connects the signals. Called when the subject is set. """ def gen_slot(_sigIdx): def slotfn(*args, **kwargs): self._subjectChanged(signalIdx=_sigIdx, args=args, kwargs=kwargs) return slotfn if self.__subject is not None: self.__slots = slots = [] signals = self.getSignals() if signals: if not isinstance(signals, (list, tuple)): signals = [signals] for sigIdx, signal in enumerate(signals): slot = gen_slot(sigIdx) signal.connect(slot) slots.append((signal, slot)) def _disconnectSignals(self): """ Disconnects all subject's signal """ if self.__slots: for signal, slot in self.__slots: try: signal.disconnect(slot) except TypeError: pass def _enableRow(self, enable): """ Set the enabled state for this cell, or for the whole row if this item has a parent. :param bool enable: True if we wan't to enable the cell """ parent = self.parent() model = self.model() if model is None or parent is None: # no parent -> no siblings self.setEnabled(enable) return for col in range(model.columnCount()): sibling = parent.child(self.row(), col) sibling.setEnabled(enable) ################################################################# # Overloadable methods ################################################################# def getSignals(self): """ Returns the list of this items subject's signals that this item will be listening to. :return: list. """ return None def _subjectChanged(self, signalIdx=None, args=None, kwargs=None): """ Called when one of the signals is triggered. Default implementation just calls _pullData, compares the result to the current value stored as Qt.EditRole, and stores the new value if it is different. It also stores its str representation as Qt.DisplayRole :param signalIdx: index of the triggered signal. The value passed is the same as the signal position in the list returned by SubjectItem.getSignals. :param args: arguments received from the signal :param kwargs: keyword arguments received from the signal """ data = self._pullData() if data == self.data(qt.Qt.EditRole): return self.setData(data, role=qt.Qt.DisplayRole, pushData=False) self.setData(data, role=qt.Qt.EditRole, pushData=False) def _pullData(self): """ Pulls data from the subject. :return: subject data """ return None def _pushData(self, value, role=qt.Qt.UserRole): """ Pushes data to the subject and returns the actual value that was stored :return: the value that was stored """ return value def _init(self): """ Called when the subject is set. :return: """ self._subjectChanged() def getEditor(self, parent, option, index): """ Returns the editor widget used to edit this item's data. The arguments are the one passed to the QStyledItemDelegate.createEditor method. :param parent: the Qt parent of the editor :param option: :param index: :return: """ return None def setEditorData(self, editor): """ This is called by the View's delegate just before the editor is shown, its purpose it to setup the editors contents. Return False to use the delegate's default behaviour. :param editor: :return: """ return True def _setModelData(self, editor): """ This is called by the View's delegate just before the editor is closed, its allows this item to update itself with data from the editor. :param editor: :return: """ return False def queryRemove(self, view=None): """ This is called by the view to ask this items if it (the view) can remove it. Return True to let the view know that the item can be removed. :param view: :return: """ return False def leftClicked(self): """ This method is called by the view when the item's cell if left clicked. :return: """ pass # View settings ############################################################### class ColorItem(SubjectItem): """color item.""" editable = True persistent = True def getEditor(self, parent, option, index): editor = QColorEditor(parent) editor.color = self.getColor() # Wrapping call in lambda is a workaround for PySide with Python 3 editor.sigColorChanged.connect( lambda color: self._editorSlot(color)) return editor def _editorSlot(self, color): self.setData(color, qt.Qt.EditRole) def _pushData(self, value, role=qt.Qt.UserRole): self.setColor(value) return self.getColor() def _pullData(self): self.getColor() def setColor(self, color): """Override to implement actual color setter""" pass class BackgroundColorItem(ColorItem): itemName = 'Background' def setColor(self, color): self.subject.setBackgroundColor(color) def getColor(self): return self.subject.getBackgroundColor() class ForegroundColorItem(ColorItem): itemName = 'Foreground' def setColor(self, color): self.subject.setForegroundColor(color) def getColor(self): return self.subject.getForegroundColor() class HighlightColorItem(ColorItem): itemName = 'Highlight' def setColor(self, color): self.subject.setHighlightColor(color) def getColor(self): return self.subject.getHighlightColor() class _LightDirectionAngleBaseItem(SubjectItem): """Base class for directional light angle item.""" editable = True persistent = True def _init(self): pass def getSignals(self): """Override to provide signals to listen""" raise NotImplementedError("MUST be implemented in subclass") def _pullData(self): """Override in subclass to get current angle""" raise NotImplementedError("MUST be implemented in subclass") def _pushData(self, value, role=qt.Qt.UserRole): """Override in subclass to set the angle""" raise NotImplementedError("MUST be implemented in subclass") def getEditor(self, parent, option, index): editor = qt.QSlider(parent) editor.setOrientation(qt.Qt.Horizontal) editor.setMinimum(-90) editor.setMaximum(90) editor.setValue(int(self._pullData())) # Wrapping call in lambda is a workaround for PySide with Python 3 editor.valueChanged.connect( lambda value: self._pushData(value)) return editor def setEditorData(self, editor): editor.setValue(int(self._pullData())) return True def _setModelData(self, editor): value = editor.value() self._pushData(value) return True class LightAzimuthAngleItem(_LightDirectionAngleBaseItem): """Light direction azimuth angle item.""" def getSignals(self): return self.subject.sigAzimuthAngleChanged def _pullData(self): return self.subject.getAzimuthAngle() def _pushData(self, value, role=qt.Qt.UserRole): self.subject.setAzimuthAngle(value) class LightAltitudeAngleItem(_LightDirectionAngleBaseItem): """Light direction altitude angle item.""" def getSignals(self): return self.subject.sigAltitudeAngleChanged def _pullData(self): return self.subject.getAltitudeAngle() def _pushData(self, value, role=qt.Qt.UserRole): self.subject.setAltitudeAngle(value) class _DirectionalLightProxy(qt.QObject): """Proxy to handle directional light with angles rather than vector. """ sigAzimuthAngleChanged = qt.Signal() """Signal sent when the azimuth angle has changed.""" sigAltitudeAngleChanged = qt.Signal() """Signal sent when altitude angle has changed.""" def __init__(self, light): super(_DirectionalLightProxy, self).__init__() self._light = light light.addListener(self._directionUpdated) self._azimuth = 0. self._altitude = 0. def getAzimuthAngle(self): """Returns the signed angle in the horizontal plane. Unit: degrees. The 0 angle corresponds to the axis perpendicular to the screen. :rtype: float """ return self._azimuth def getAltitudeAngle(self): """Returns the signed vertical angle from the horizontal plane. Unit: degrees. Range: [-90, +90] :rtype: float """ return self._altitude def setAzimuthAngle(self, angle): """Set the horizontal angle. :param float angle: Angle from -z axis in zx plane in degrees. """ if angle != self._azimuth: self._azimuth = angle self._updateLight() self.sigAzimuthAngleChanged.emit() def setAltitudeAngle(self, angle): """Set the horizontal angle. :param float angle: Angle from -z axis in zy plane in degrees. """ if angle != self._altitude: self._altitude = angle self._updateLight() self.sigAltitudeAngleChanged.emit() def _directionUpdated(self, *args, **kwargs): """Handle light direction update in the scene""" # Invert direction to manipulate the 'source' pointing to # the center of the viewport x, y, z = - self._light.direction # Horizontal plane is plane xz azimuth = numpy.degrees(numpy.arctan2(x, z)) altitude = numpy.degrees(numpy.pi/2. - numpy.arccos(y)) if (abs(azimuth - self.getAzimuthAngle()) > 0.01 and abs(abs(altitude) - 90.) >= 0.001): # Do not update when at zenith self.setAzimuthAngle(azimuth) if abs(altitude - self.getAltitudeAngle()) > 0.01: self.setAltitudeAngle(altitude) def _updateLight(self): """Update light direction in the scene""" azimuth = numpy.radians(self._azimuth) delta = numpy.pi/2. - numpy.radians(self._altitude) z = - numpy.sin(delta) * numpy.cos(azimuth) x = - numpy.sin(delta) * numpy.sin(azimuth) y = - numpy.cos(delta) self._light.direction = x, y, z class DirectionalLightGroup(SubjectItem): """ Root Item for the directional light """ def __init__(self,subject, *args): self._light = _DirectionalLightProxy( subject.getPlot3DWidget().viewport.light) super(DirectionalLightGroup, self).__init__(subject, *args) def _init(self): nameItem = qt.QStandardItem('Azimuth') nameItem.setEditable(False) valueItem = LightAzimuthAngleItem(self._light) self.appendRow([nameItem, valueItem]) nameItem = qt.QStandardItem('Altitude') nameItem.setEditable(False) valueItem = LightAltitudeAngleItem(self._light) self.appendRow([nameItem, valueItem]) class BoundingBoxItem(SubjectItem): """Bounding box, axes labels and grid visibility item. Item is checkable. """ itemName = 'Bounding Box' def _init(self): visible = self.subject.isBoundingBoxVisible() self.setCheckable(True) self.setCheckState(qt.Qt.Checked if visible else qt.Qt.Unchecked) def leftClicked(self): checked = (self.checkState() == qt.Qt.Checked) if checked != self.subject.isBoundingBoxVisible(): self.subject.setBoundingBoxVisible(checked) class OrientationIndicatorItem(SubjectItem): """Orientation indicator visibility item. Item is checkable. """ itemName = 'Axes indicator' def _init(self): plot3d = self.subject.getPlot3DWidget() visible = plot3d.isOrientationIndicatorVisible() self.setCheckable(True) self.setCheckState(qt.Qt.Checked if visible else qt.Qt.Unchecked) def leftClicked(self): plot3d = self.subject.getPlot3DWidget() checked = (self.checkState() == qt.Qt.Checked) if checked != plot3d.isOrientationIndicatorVisible(): plot3d.setOrientationIndicatorVisible(checked) class ViewSettingsItem(qt.QStandardItem): """Viewport settings""" def __init__(self, subject, *args): super(ViewSettingsItem, self).__init__(*args) self.setEditable(False) classes = (BackgroundColorItem, ForegroundColorItem, HighlightColorItem, BoundingBoxItem, OrientationIndicatorItem) for cls in classes: titleItem = qt.QStandardItem(cls.itemName) titleItem.setEditable(False) self.appendRow([titleItem, cls(subject)]) nameItem = DirectionalLightGroup(subject, 'Light Direction') valueItem = qt.QStandardItem() self.appendRow([nameItem, valueItem]) # Data information ############################################################ class DataChangedItem(SubjectItem): """ Base class for items listening to ScalarFieldView.sigDataChanged """ def getSignals(self): subject = self.subject if subject: return subject.sigDataChanged, subject.sigTransformChanged return None def _init(self): self._subjectChanged() class DataTypeItem(DataChangedItem): itemName = 'dtype' def _pullData(self): data = self.subject.getData(copy=False) return ((data is not None) and str(data.dtype)) or 'N/A' class DataShapeItem(DataChangedItem): itemName = 'size' def _pullData(self): data = self.subject.getData(copy=False) if data is None: return 'N/A' else: return str(list(reversed(data.shape))) class OffsetItem(DataChangedItem): itemName = 'offset' def _pullData(self): offset = self.subject.getTranslation() return ((offset is not None) and str(offset)) or 'N/A' class ScaleItem(DataChangedItem): itemName = 'scale' def _pullData(self): scale = self.subject.getScale() return ((scale is not None) and str(scale)) or 'N/A' class MatrixItem(DataChangedItem): def __init__(self, subject, row, *args): self.__row = row super(MatrixItem, self).__init__(subject, *args) def _pullData(self): matrix = self.subject.getTransformMatrix() return str(matrix[self.__row]) class DataSetItem(qt.QStandardItem): def __init__(self, subject, *args): super(DataSetItem, self).__init__(*args) self.setEditable(False) klasses = [DataTypeItem, DataShapeItem, OffsetItem] for klass in klasses: titleItem = qt.QStandardItem(klass.itemName) titleItem.setEditable(False) self.appendRow([titleItem, klass(subject)]) matrixItem = qt.QStandardItem('matrix') matrixItem.setEditable(False) valueItem = qt.QStandardItem() self.appendRow([matrixItem, valueItem]) for row in range(3): titleItem = qt.QStandardItem() titleItem.setEditable(False) valueItem = MatrixItem(subject, row) matrixItem.appendRow([titleItem, valueItem]) titleItem = qt.QStandardItem(ScaleItem.itemName) titleItem.setEditable(False) self.appendRow([titleItem, ScaleItem(subject)]) # Isosurface ################################################################## class IsoSurfaceRootItem(SubjectItem): """ Root (i.e : column index 0) Isosurface item. """ def __init__(self, subject, normalization, *args): self._isoLevelSliderNormalization = normalization super(IsoSurfaceRootItem, self).__init__(subject, *args) def getSignals(self): subject = self.subject return [subject.sigColorChanged, subject.sigVisibilityChanged] def _subjectChanged(self, signalIdx=None, args=None, kwargs=None): if signalIdx == 0: color = self.subject.getColor() self.setData(color, qt.Qt.DecorationRole) elif signalIdx == 1: visible = args[0] self.setCheckState((visible and qt.Qt.Checked) or qt.Qt.Unchecked) def _init(self): self.setCheckable(True) isosurface = self.subject color = isosurface.getColor() visible = isosurface.isVisible() self.setData(color, qt.Qt.DecorationRole) self.setCheckState((visible and qt.Qt.Checked) or qt.Qt.Unchecked) nameItem = qt.QStandardItem('Level') sliderItem = IsoSurfaceLevelSlider(self.subject, self._isoLevelSliderNormalization) self.appendRow([nameItem, sliderItem]) nameItem = qt.QStandardItem('Color') nameItem.setEditable(False) valueItem = IsoSurfaceColorItem(self.subject) self.appendRow([nameItem, valueItem]) nameItem = qt.QStandardItem('Opacity') nameItem.setTextAlignment(qt.Qt.AlignLeft | qt.Qt.AlignTop) nameItem.setEditable(False) valueItem = IsoSurfaceAlphaItem(self.subject) self.appendRow([nameItem, valueItem]) nameItem = qt.QStandardItem() nameItem.setEditable(False) valueItem = IsoSurfaceAlphaLegendItem(self.subject) valueItem.setEditable(False) self.appendRow([nameItem, valueItem]) def queryRemove(self, view=None): buttons = qt.QMessageBox.Ok | qt.QMessageBox.Cancel ans = qt.QMessageBox.question(view, 'Remove isosurface', 'Remove the selected iso-surface?', buttons=buttons) if ans == qt.QMessageBox.Ok: sfview = self.subject.parent() if sfview: sfview.removeIsosurface(self.subject) return False return False def leftClicked(self): checked = (self.checkState() == qt.Qt.Checked) visible = self.subject.isVisible() if checked != visible: self.subject.setVisible(checked) class IsoSurfaceLevelItem(SubjectItem): """ Base class for the isosurface level items. """ editable = True def getSignals(self): subject = self.subject return [subject.sigLevelChanged, subject.sigVisibilityChanged] def getEditor(self, parent, option, index): return FloatEdit(parent) def setEditorData(self, editor): editor.setValue(self._pullData()) return False def _setModelData(self, editor): self._pushData(editor.value()) return True def _pullData(self): return self.subject.getLevel() def _pushData(self, value, role=qt.Qt.UserRole): self.subject.setLevel(value) return self.subject.getLevel() class _IsoLevelSlider(qt.QSlider): """QSlider used for iso-surface level with linear scale""" def __init__(self, parent, subject, normalization): super(_IsoLevelSlider, self).__init__(parent=parent) self.subject = subject if normalization == 'arcsinh': self.__norm = numpy.arcsinh self.__invNorm = numpy.sinh elif normalization == 'linear': self.__norm = lambda x: x self.__invNorm = lambda x: x else: raise ValueError( "Unsupported normalization %s", normalization) self.sliderReleased.connect(self.__sliderReleased) self.subject.sigLevelChanged.connect(self.setLevel) self.subject.parent().sigDataChanged.connect(self.__dataChanged) def setLevel(self, level): """Set slider from iso-surface level""" dataRange = self.subject.parent().getDataRange() if dataRange is not None: min_ = self.__norm(dataRange[0]) max_ = self.__norm(dataRange[-1]) width = max_ - min_ if width > 0: sliderWidth = self.maximum() - self.minimum() sliderPosition = sliderWidth * (self.__norm(level) - min_) / width self.setValue(int(sliderPosition)) def __dataChanged(self): """Handles data update to refresh slider range if needed""" self.setLevel(self.subject.getLevel()) def __sliderReleased(self): value = self.value() dataRange = self.subject.parent().getDataRange() if dataRange is not None: min_ = self.__norm(dataRange[0]) max_ = self.__norm(dataRange[-1]) width = max_ - min_ sliderWidth = self.maximum() - self.minimum() level = min_ + width * value / sliderWidth self.subject.setLevel(self.__invNorm(level)) class IsoSurfaceLevelSlider(IsoSurfaceLevelItem): """ Isosurface level item with a slider editor. """ nTicks = 1000 persistent = True def __init__(self, subject, normalization): self.normalization = normalization super(IsoSurfaceLevelSlider, self).__init__(subject) def getEditor(self, parent, option, index): editor = _IsoLevelSlider(parent, self.subject, self.normalization) editor.setOrientation(qt.Qt.Horizontal) editor.setMinimum(0) editor.setMaximum(self.nTicks) editor.setSingleStep(1) editor.setLevel(self.subject.getLevel()) return editor def setEditorData(self, editor): return True def _setModelData(self, editor): return True class IsoSurfaceColorItem(SubjectItem): """ Isosurface color item. """ editable = True persistent = True def getSignals(self): return self.subject.sigColorChanged def getEditor(self, parent, option, index): editor = QColorEditor(parent) color = self.subject.getColor() color.setAlpha(255) editor.color = color # Wrapping call in lambda is a workaround for PySide with Python 3 editor.sigColorChanged.connect( lambda color: self.__editorChanged(color)) return editor def __editorChanged(self, color): color.setAlpha(self.subject.getColor().alpha()) self.subject.setColor(color) def _pushData(self, value, role=qt.Qt.UserRole): self.subject.setColor(value) return self.subject.getColor() class QColorEditor(qt.QWidget): """ QColor editor. """ sigColorChanged = qt.Signal(object) color = property(lambda self: qt.QColor(self.__color)) @color.setter def color(self, color): self._setColor(color) self.__previousColor = color def __init__(self, *args, **kwargs): super(QColorEditor, self).__init__(*args, **kwargs) layout = qt.QHBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) button = qt.QToolButton() icon = qt.QIcon(qt.QPixmap(32, 32)) button.setIcon(icon) layout.addWidget(button) button.clicked.connect(self.__showColorDialog) layout.addStretch(1) self.__color = None self.__previousColor = None def sizeHint(self): return qt.QSize(0, 0) def _setColor(self, qColor): button = self.findChild(qt.QToolButton) pixmap = qt.QPixmap(32, 32) pixmap.fill(qColor) button.setIcon(qt.QIcon(pixmap)) self.__color = qColor def __showColorDialog(self): dialog = qt.QColorDialog(parent=self) if sys.platform == 'darwin': # Use of native color dialog on macos might cause problems dialog.setOption(qt.QColorDialog.DontUseNativeDialog, True) self.__previousColor = self.__color dialog.setAttribute(qt.Qt.WA_DeleteOnClose) dialog.setModal(True) dialog.currentColorChanged.connect(self.__colorChanged) dialog.finished.connect(self.__dialogClosed) dialog.show() def __colorChanged(self, color): self.__color = color self._setColor(color) self.sigColorChanged.emit(color) def __dialogClosed(self, result): if result == qt.QDialog.Rejected: self.__colorChanged(self.__previousColor) self.__previousColor = None class IsoSurfaceAlphaItem(SubjectItem): """ Isosurface alpha item. """ editable = True persistent = True def _init(self): pass def getSignals(self): return self.subject.sigColorChanged def getEditor(self, parent, option, index): editor = qt.QSlider(parent) editor.setOrientation(qt.Qt.Horizontal) editor.setMinimum(0) editor.setMaximum(255) color = self.subject.getColor() editor.setValue(color.alpha()) # Wrapping call in lambda is a workaround for PySide with Python 3 editor.valueChanged.connect( lambda value: self.__editorChanged(value)) return editor def __editorChanged(self, value): color = self.subject.getColor() color.setAlpha(value) self.subject.setColor(color) def setEditorData(self, editor): return True def _setModelData(self, editor): return True class IsoSurfaceAlphaLegendItem(SubjectItem): """Legend to place under opacity slider""" editable = False persistent = True def getEditor(self, parent, option, index): layout = qt.QHBoxLayout() layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) layout.addWidget(qt.QLabel('0')) layout.addStretch(1) layout.addWidget(qt.QLabel('1')) editor = qt.QWidget(parent) editor.setLayout(layout) return editor class IsoSurfaceCount(SubjectItem): """ Item displaying the number of isosurfaces. """ def getSignals(self): subject = self.subject return [subject.sigIsosurfaceAdded, subject.sigIsosurfaceRemoved] def _pullData(self): return len(self.subject.getIsosurfaces()) class IsoSurfaceAddRemoveWidget(qt.QWidget): sigViewTask = qt.Signal(str) """Signal for the tree view to perform some task""" def __init__(self, parent, item): super(IsoSurfaceAddRemoveWidget, self).__init__(parent) self._item = item layout = qt.QHBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) addBtn = qt.QToolButton(self) addBtn.setText('+') addBtn.setToolButtonStyle(qt.Qt.ToolButtonTextOnly) layout.addWidget(addBtn) addBtn.clicked.connect(self.__addClicked) removeBtn = qt.QToolButton(self) removeBtn.setText('-') removeBtn.setToolButtonStyle(qt.Qt.ToolButtonTextOnly) layout.addWidget(removeBtn) removeBtn.clicked.connect(self.__removeClicked) layout.addStretch(1) def __addClicked(self): sfview = self._item.subject if not sfview: return dataRange = sfview.getDataRange() if dataRange is None: dataRange = [0, 1] sfview.addIsosurface( numpy.mean((dataRange[0], dataRange[-1])), '#0000FF') def __removeClicked(self): self.sigViewTask.emit('remove_iso') class IsoSurfaceAddRemoveItem(SubjectItem): """ Item displaying a simple QToolButton allowing to add an isosurface. """ persistent = True def getEditor(self, parent, option, index): return IsoSurfaceAddRemoveWidget(parent, self) class IsoSurfaceGroup(SubjectItem): """ Root item for the list of isosurface items. """ def __init__(self, subject, normalization, *args): self._isoLevelSliderNormalization = normalization super(IsoSurfaceGroup, self).__init__(subject, *args) def getSignals(self): subject = self.subject return [subject.sigIsosurfaceAdded, subject.sigIsosurfaceRemoved] def _subjectChanged(self, signalIdx=None, args=None, kwargs=None): if signalIdx == 0: if len(args) >= 1: isosurface = args[0] if not isinstance(isosurface, Isosurface): raise ValueError('Expected an isosurface instance.') self.__addIsosurface(isosurface) else: raise ValueError('Expected an isosurface instance.') elif signalIdx == 1: if len(args) >= 1: isosurface = args[0] if not isinstance(isosurface, Isosurface): raise ValueError('Expected an isosurface instance.') self.__removeIsosurface(isosurface) else: raise ValueError('Expected an isosurface instance.') def __addIsosurface(self, isosurface): valueItem = IsoSurfaceRootItem( subject=isosurface, normalization=self._isoLevelSliderNormalization) nameItem = IsoSurfaceLevelItem(subject=isosurface) self.insertRow(max(0, self.rowCount() - 1), [valueItem, nameItem]) def __removeIsosurface(self, isosurface): for row in range(self.rowCount()): child = self.child(row) subject = getattr(child, 'subject', None) if subject == isosurface: self.takeRow(row) break def _init(self): nameItem = IsoSurfaceAddRemoveItem(self.subject) valueItem = qt.QStandardItem() valueItem.setEditable(False) self.appendRow([nameItem, valueItem]) subject = self.subject isosurfaces = subject.getIsosurfaces() for isosurface in isosurfaces: self.__addIsosurface(isosurface) # Cutting Plane ############################################################### class ColormapBase(SubjectItem): """ Mixin class for colormap items. """ def getSignals(self): return [self.subject.getCutPlanes()[0].sigColormapChanged] class PlaneMinRangeItem(ColormapBase): """ colormap minVal item. Editor is a QLineEdit with a QDoubleValidator """ editable = True def _pullData(self): colormap = self.subject.getCutPlanes()[0].getColormap() auto = colormap.isAutoscale() if auto == self.isEnabled(): self._enableRow(not auto) return colormap.getVMin() def _pushData(self, value, role=qt.Qt.UserRole): self._setVMin(value) def _setVMin(self, value): colormap = self.subject.getCutPlanes()[0].getColormap() vMin = value vMax = colormap.getVMax() if vMax is not None and value > vMax: vMin = vMax vMax = value colormap.setVRange(vMin, vMax) def getEditor(self, parent, option, index): return FloatEdit(parent) def setEditorData(self, editor): editor.setValue(self._pullData()) return True def _setModelData(self, editor): value = editor.value() self._setVMin(value) return True class PlaneMaxRangeItem(ColormapBase): """ colormap maxVal item. Editor is a QLineEdit with a QDoubleValidator """ editable = True def _pullData(self): colormap = self.subject.getCutPlanes()[0].getColormap() auto = colormap.isAutoscale() if auto == self.isEnabled(): self._enableRow(not auto) return self.subject.getCutPlanes()[0].getColormap().getVMax() def _setVMax(self, value): colormap = self.subject.getCutPlanes()[0].getColormap() vMin = colormap.getVMin() vMax = value if vMin is not None and value < vMin: vMax = vMin vMin = value colormap.setVRange(vMin, vMax) def getEditor(self, parent, option, index): return FloatEdit(parent) def setEditorData(self, editor): editor.setText(str(self._pullData())) return True def _setModelData(self, editor): value = editor.value() self._setVMax(value) return True class PlaneOrientationItem(SubjectItem): """ Plane orientation item. Editor is a QComboBox. """ editable = True _PLANE_ACTIONS = ( ('3d-plane-normal-x', 'Plane 0', 'Set plane perpendicular to red axis', (1., 0., 0.)), ('3d-plane-normal-y', 'Plane 1', 'Set plane perpendicular to green axis', (0., 1., 0.)), ('3d-plane-normal-z', 'Plane 2', 'Set plane perpendicular to blue axis', (0., 0., 1.)), ) def getSignals(self): return [self.subject.getCutPlanes()[0].sigPlaneChanged] def _pullData(self): currentNormal = self.subject.getCutPlanes()[0].getNormal( coordinates='scene') for _, text, _, normal in self._PLANE_ACTIONS: if numpy.allclose(normal, currentNormal): return text return '' def getEditor(self, parent, option, index): editor = qt.QComboBox(parent) for iconName, text, tooltip, normal in self._PLANE_ACTIONS: editor.addItem(getQIcon(iconName), text) # Wrapping call in lambda is a workaround for PySide with Python 3 editor.currentIndexChanged[int].connect( lambda index: self.__editorChanged(index)) return editor def __editorChanged(self, index): normal = self._PLANE_ACTIONS[index][3] plane = self.subject.getCutPlanes()[0] plane.setNormal(normal, coordinates='scene') plane.moveToCenter() def setEditorData(self, editor): currentText = self._pullData() index = 0 for normIdx, (_, text, _, _) in enumerate(self._PLANE_ACTIONS): if text == currentText: index = normIdx break editor.setCurrentIndex(index) return True def _setModelData(self, editor): return True class PlaneInterpolationItem(SubjectItem): """Toggle cut plane interpolation method: nearest or linear. Item is checkable """ def _init(self): interpolation = self.subject.getCutPlanes()[0].getInterpolation() self.setCheckable(True) self.setCheckState( qt.Qt.Checked if interpolation == 'linear' else qt.Qt.Unchecked) self.setData(self._pullData(), role=qt.Qt.DisplayRole, pushData=False) def getSignals(self): return [self.subject.getCutPlanes()[0].sigInterpolationChanged] def leftClicked(self): checked = self.checkState() == qt.Qt.Checked self._setInterpolation('linear' if checked else 'nearest') def _pullData(self): interpolation = self.subject.getCutPlanes()[0].getInterpolation() self._setInterpolation(interpolation) return interpolation[0].upper() + interpolation[1:] def _setInterpolation(self, interpolation): self.subject.getCutPlanes()[0].setInterpolation(interpolation) class PlaneDisplayBelowMinItem(SubjectItem): """Toggle whether to display or not values <= colormap min of the cut plane Item is checkable """ def _init(self): display = self.subject.getCutPlanes()[0].getDisplayValuesBelowMin() self.setCheckable(True) self.setCheckState( qt.Qt.Checked if display else qt.Qt.Unchecked) self.setData(self._pullData(), role=qt.Qt.DisplayRole, pushData=False) def getSignals(self): return [self.subject.getCutPlanes()[0].sigTransparencyChanged] def leftClicked(self): checked = self.checkState() == qt.Qt.Checked self._setDisplayValuesBelowMin(checked) def _pullData(self): display = self.subject.getCutPlanes()[0].getDisplayValuesBelowMin() self._setDisplayValuesBelowMin(display) return "Displayed" if display else "Hidden" def _setDisplayValuesBelowMin(self, display): self.subject.getCutPlanes()[0].setDisplayValuesBelowMin(display) class PlaneColormapItem(ColormapBase): """ colormap name item. Editor is a QComboBox """ editable = True listValues = ['gray', 'reversed gray', 'temperature', 'red', 'green', 'blue', 'viridis', 'magma', 'inferno', 'plasma'] def getEditor(self, parent, option, index): editor = qt.QComboBox(parent) editor.addItems(self.listValues) # Wrapping call in lambda is a workaround for PySide with Python 3 editor.currentIndexChanged[int].connect( lambda index: self.__editorChanged(index)) return editor def __editorChanged(self, index): colormapName = self.listValues[index] colormap = self.subject.getCutPlanes()[0].getColormap() colormap.setName(colormapName) def setEditorData(self, editor): colormapName = self.subject.getCutPlanes()[0].getColormap().getName() try: index = self.listValues.index(colormapName) except ValueError: _logger.error('Unsupported colormap: %s', colormapName) else: editor.setCurrentIndex(index) return True def _setModelData(self, editor): self.__editorChanged(editor.currentIndex()) return True def _pullData(self): return self.subject.getCutPlanes()[0].getColormap().getName() class PlaneAutoScaleItem(ColormapBase): """ colormap autoscale item. Item is checkable. """ def _init(self): colorMap = self.subject.getCutPlanes()[0].getColormap() self.setCheckable(True) self.setCheckState((colorMap.isAutoscale() and qt.Qt.Checked) or qt.Qt.Unchecked) self.setData(self._pullData(), role=qt.Qt.DisplayRole, pushData=False) def leftClicked(self): checked = (self.checkState() == qt.Qt.Checked) self._setAutoScale(checked) def _setAutoScale(self, auto): view3d = self.subject colormap = view3d.getCutPlanes()[0].getColormap() if auto != colormap.isAutoscale(): if auto: vMin = vMax = None else: dataRange = view3d.getDataRange() if dataRange is None: vMin = vMax = None else: vMin, vMax = dataRange[0], dataRange[-1] colormap.setVRange(vMin, vMax) def _pullData(self): auto = self.subject.getCutPlanes()[0].getColormap().isAutoscale() self._setAutoScale(auto) if auto: data = 'Auto' else: data = 'User' return data class NormalizationNode(ColormapBase): """ colormap normalization item. Item is a QComboBox. """ editable = True listValues = list(Colormap.NORMALIZATIONS) def getEditor(self, parent, option, index): editor = qt.QComboBox(parent) editor.addItems(self.listValues) # Wrapping call in lambda is a workaround for PySide with Python 3 editor.currentIndexChanged[int].connect( lambda index: self.__editorChanged(index)) return editor def __editorChanged(self, index): colorMap = self.subject.getCutPlanes()[0].getColormap() normalization = self.listValues[index] self.subject.getCutPlanes()[0].setColormap(name=colorMap.getName(), norm=normalization, vmin=colorMap.getVMin(), vmax=colorMap.getVMax()) def setEditorData(self, editor): normalization = self.subject.getCutPlanes()[0].getColormap().getNormalization() index = self.listValues.index(normalization) editor.setCurrentIndex(index) return True def _setModelData(self, editor): self.__editorChanged(editor.currentIndex()) return True def _pullData(self): return self.subject.getCutPlanes()[0].getColormap().getNormalization() class PlaneGroup(SubjectItem): """ Root Item for the plane items. """ def _init(self): valueItem = qt.QStandardItem() valueItem.setEditable(False) nameItem = PlaneVisibleItem(self.subject, 'Visible') self.appendRow([nameItem, valueItem]) nameItem = qt.QStandardItem('Colormap') nameItem.setEditable(False) valueItem = PlaneColormapItem(self.subject) self.appendRow([nameItem, valueItem]) nameItem = qt.QStandardItem('Normalization') nameItem.setEditable(False) valueItem = NormalizationNode(self.subject) self.appendRow([nameItem, valueItem]) nameItem = qt.QStandardItem('Orientation') nameItem.setEditable(False) valueItem = PlaneOrientationItem(self.subject) self.appendRow([nameItem, valueItem]) nameItem = qt.QStandardItem('Interpolation') nameItem.setEditable(False) valueItem = PlaneInterpolationItem(self.subject) self.appendRow([nameItem, valueItem]) nameItem = qt.QStandardItem('Autoscale') nameItem.setEditable(False) valueItem = PlaneAutoScaleItem(self.subject) self.appendRow([nameItem, valueItem]) nameItem = qt.QStandardItem('Min') nameItem.setEditable(False) valueItem = PlaneMinRangeItem(self.subject) self.appendRow([nameItem, valueItem]) nameItem = qt.QStandardItem('Max') nameItem.setEditable(False) valueItem = PlaneMaxRangeItem(self.subject) self.appendRow([nameItem, valueItem]) nameItem = qt.QStandardItem('Values<=Min') nameItem.setEditable(False) valueItem = PlaneDisplayBelowMinItem(self.subject) self.appendRow([nameItem, valueItem]) class PlaneVisibleItem(SubjectItem): """ Plane visibility item. Item is checkable. """ def _init(self): plane = self.subject.getCutPlanes()[0] self.setCheckable(True) self.setCheckState((plane.isVisible() and qt.Qt.Checked) or qt.Qt.Unchecked) def leftClicked(self): plane = self.subject.getCutPlanes()[0] checked = (self.checkState() == qt.Qt.Checked) if checked != plane.isVisible(): plane.setVisible(checked) if plane.isVisible(): plane.moveToCenter() # Tree ######################################################################## class ItemDelegate(qt.QStyledItemDelegate): """ Delegate for the QTreeView filled with SubjectItems. """ sigDelegateEvent = qt.Signal(str) def __init__(self, parent=None): super(ItemDelegate, self).__init__(parent) def createEditor(self, parent, option, index): item = index.model().itemFromIndex(index) if item: if isinstance(item, SubjectItem): editor = item.getEditor(parent, option, index) if editor: editor.setAutoFillBackground(True) if hasattr(editor, 'sigViewTask'): editor.sigViewTask.connect(self.__viewTask) return editor editor = super(ItemDelegate, self).createEditor(parent, option, index) return editor def updateEditorGeometry(self, editor, option, index): editor.setGeometry(option.rect) def setEditorData(self, editor, index): item = index.model().itemFromIndex(index) if item: if isinstance(item, SubjectItem) and item.setEditorData(editor): return super(ItemDelegate, self).setEditorData(editor, index) def setModelData(self, editor, model, index): item = index.model().itemFromIndex(index) if isinstance(item, SubjectItem) and item._setModelData(editor): return super(ItemDelegate, self).setModelData(editor, model, index) def __viewTask(self, task): self.sigDelegateEvent.emit(task) class TreeView(qt.QTreeView): """ TreeView displaying the SubjectItems for the ScalarFieldView. """ def __init__(self, parent=None): super(TreeView, self).__init__(parent) self.__openedIndex = None self._isoLevelSliderNormalization = 'linear' self.setIconSize(qt.QSize(16, 16)) header = self.header() if hasattr(header, 'setSectionResizeMode'): # Qt5 header.setSectionResizeMode(qt.QHeaderView.ResizeToContents) else: # Qt4 header.setResizeMode(qt.QHeaderView.ResizeToContents) delegate = ItemDelegate() self.setItemDelegate(delegate) delegate.sigDelegateEvent.connect(self.__delegateEvent) self.setSelectionBehavior(qt.QAbstractItemView.SelectRows) self.setSelectionMode(qt.QAbstractItemView.SingleSelection) self.clicked.connect(self.__clicked) def setSfView(self, sfView): """ Sets the ScalarFieldView this view is controlling. :param sfView: A `ScalarFieldView` """ model = qt.QStandardItemModel() model.setColumnCount(ModelColumns.ColumnMax) model.setHorizontalHeaderLabels(['Name', 'Value']) item = qt.QStandardItem() item.setEditable(False) model.appendRow([ViewSettingsItem(sfView, 'Style'), item]) item = qt.QStandardItem() item.setEditable(False) model.appendRow([DataSetItem(sfView, 'Data'), item]) item = IsoSurfaceCount(sfView) item.setEditable(False) model.appendRow([IsoSurfaceGroup(sfView, self._isoLevelSliderNormalization, 'Isosurfaces'), item]) item = qt.QStandardItem() item.setEditable(False) model.appendRow([PlaneGroup(sfView, 'Cutting Plane'), item]) self.setModel(model) def setModel(self, model): """ Reimplementation of the QTreeView.setModel method. It connects the rowsRemoved signal and opens the persistent editors. :param qt.QStandardItemModel model: the model """ prevModel = self.model() if prevModel: self.__openPersistentEditors(qt.QModelIndex(), False) try: prevModel.rowsRemoved.disconnect(self.rowsRemoved) except TypeError: pass super(TreeView, self).setModel(model) model.rowsRemoved.connect(self.rowsRemoved) self.__openPersistentEditors(qt.QModelIndex()) def __openPersistentEditors(self, parent=None, openEditor=True): """ Opens or closes the items persistent editors. :param qt.QModelIndex parent: starting index, or None if the whole tree is to be considered. :param bool openEditor: True to open the editors, False to close them. """ model = self.model() if not model: return if not parent or not parent.isValid(): parent = self.model().invisibleRootItem().index() if openEditor: meth = self.openPersistentEditor else: meth = self.closePersistentEditor curParent = parent children = [model.index(row, 0, curParent) for row in range(model.rowCount(curParent))] columnCount = model.columnCount() while len(children) > 0: curParent = children.pop(-1) children.extend([model.index(row, 0, curParent) for row in range(model.rowCount(curParent))]) for colIdx in range(columnCount): sibling = model.sibling(curParent.row(), colIdx, curParent) item = model.itemFromIndex(sibling) if isinstance(item, SubjectItem) and item.persistent: meth(sibling) def rowsAboutToBeRemoved(self, parent, start, end): """ Reimplementation of the QTreeView.rowsAboutToBeRemoved. Closes all persistent editors under parent. :param qt.QModelIndex parent: Parent index :param int start: Start index from parent index (inclusive) :param int end: End index from parent index (inclusive) """ self.__openPersistentEditors(parent, False) super(TreeView, self).rowsAboutToBeRemoved(parent, start, end) def rowsRemoved(self, parent, start, end): """ Called when QTreeView.rowsRemoved is emitted. Opens all persistent editors under parent. :param qt.QModelIndex parent: Parent index :param int start: Start index from parent index (inclusive) :param int end: End index from parent index (inclusive) """ super(TreeView, self).rowsRemoved(parent, start, end) self.__openPersistentEditors(parent, True) def rowsInserted(self, parent, start, end): """ Reimplementation of the QTreeView.rowsInserted. Opens all persistent editors under parent. :param qt.QModelIndex parent: Parent index :param int start: Start index from parent index :param int end: End index from parent index """ self.__openPersistentEditors(parent, False) super(TreeView, self).rowsInserted(parent, start, end) self.__openPersistentEditors(parent) def keyReleaseEvent(self, event): """ Reimplementation of the QTreeView.keyReleaseEvent. At the moment only Key_Delete is handled. It calls the selected item's queryRemove method, and deleted the item if needed. :param qt.QKeyEvent event: A key event """ # TODO : better filtering key = event.key() modifiers = event.modifiers() if key == qt.Qt.Key_Delete and modifiers == qt.Qt.NoModifier: self.__removeIsosurfaces() super(TreeView, self).keyReleaseEvent(event) def __removeIsosurfaces(self): model = self.model() selected = self.selectedIndexes() items = [] # WARNING : the selection mode is set to single, so we re not # supposed to have more than one item here. # Multiple selection deletion has not been tested. # Watch out for index invalidation for index in selected: leftIndex = model.sibling(index.row(), 0, index) leftItem = model.itemFromIndex(leftIndex) if isinstance(leftItem, SubjectItem) and leftItem not in items: items.append(leftItem) isos = [item for item in items if isinstance(item, IsoSurfaceRootItem)] if isos: for iso in isos: if iso.queryRemove(self): parentItem = iso.parent() parentItem.removeRow(iso.row()) else: qt.QMessageBox.information( self, 'Remove isosurface', 'Select an iso-surface to remove it') def __clicked(self, index): """ Called when the QTreeView.clicked signal is emitted. Calls the item's leftClick method. :param qt.QIndex index: An index """ item = self.model().itemFromIndex(index) if isinstance(item, SubjectItem): item.leftClicked() def __delegateEvent(self, task): if task == 'remove_iso': self.__removeIsosurfaces() def setIsoLevelSliderNormalization(self, normalization): """Set the normalization for iso level slider This MUST be called *before* :meth:`setSfView` to have an effect. :param str normalization: Either 'linear' or 'arcsinh' """ assert normalization in ('linear', 'arcsinh') self._isoLevelSliderNormalization = normalization