# 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)
[docs]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)
[docs] 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)
[docs] 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)
[docs] 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)
[docs] 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)
[docs] 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)
[docs] 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()
[docs] 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