# /*##########################################################################
#
# 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 the base class for items of the :class:`.SceneWidget`.
"""
__authors__ = ["T. Vincent"]
__license__ = "MIT"
__date__ = "15/11/2017"
from collections import defaultdict
import enum
import numpy
from ... import qt
from ...plot.items import ItemChangedType
from .. import scene
from ..scene import axes, primitives, transform
from ._pick import PickContext
@enum.unique
class Item3DChangedType(enum.Enum):
"""Type of modification provided by :attr:`Item3D.sigItemChanged` signal."""
INTERPOLATION = "interpolationChanged"
"""Item3D image interpolation changed flag."""
TRANSFORM = "transformChanged"
"""Item3D transform changed flag."""
HEIGHT_MAP = "heightMapChanged"
"""Item3D height map changed flag."""
ISO_LEVEL = "isoLevelChanged"
"""Isosurface level changed flag."""
LABEL = "labelChanged"
"""Item's label changed flag."""
BOUNDING_BOX_VISIBLE = "boundingBoxVisibleChanged"
"""Item's bounding box visibility changed"""
ROOT_ITEM = "rootItemChanged"
"""Item's root changed flag."""
[docs]
class Item3D(qt.QObject):
"""Base class representing an item in the scene.
:param parent: The View widget this item belongs to.
:param primitive: An optional primitive to use as scene primitive
"""
_LABEL_INDICES = defaultdict(int)
"""Store per class label indices"""
sigItemChanged = qt.Signal(object)
"""Signal emitted when an item's property has changed.
It provides a flag describing which property of the item has changed.
See :class:`ItemChangedType` and :class:`Item3DChangedType`
for flags description.
"""
def __init__(self, parent, primitive=None):
qt.QObject.__init__(self)
if parent is not None:
self.setParent(parent)
if primitive is None:
primitive = scene.Group()
self._primitive = primitive
self.__syncForegroundColor()
labelIndex = self._LABEL_INDICES[self.__class__]
self._label = str(self.__class__.__name__)
if labelIndex != 0:
self._label += " %d" % labelIndex
self._LABEL_INDICES[self.__class__] += 1
[docs]
def setParent(self, parent):
"""Override set parent to handle root item change"""
previousParent = self.parent()
if isinstance(previousParent, Item3D):
previousParent.sigItemChanged.disconnect(self.__parentItemChanged)
super(Item3D, self).setParent(parent)
if isinstance(parent, Item3D):
parent.sigItemChanged.connect(self.__parentItemChanged)
self._updated(Item3DChangedType.ROOT_ITEM)
def __parentItemChanged(self, event):
"""Handle updates of the parent if it is an Item3D
:param Item3DChangedType event:
"""
if event == Item3DChangedType.ROOT_ITEM:
self._updated(Item3DChangedType.ROOT_ITEM)
[docs]
def root(self):
"""Returns the root of the scene this item belongs to.
The root is the up-most Item3D in the scene tree hierarchy.
:rtype: Union[Item3D, None]
"""
root = None
ancestor = self.parent()
while isinstance(ancestor, Item3D):
root = ancestor
ancestor = ancestor.parent()
return root
def _getScenePrimitive(self):
"""Return the group containing the item rendering"""
return self._primitive
def _updated(self, event=None):
"""Handle MixIn class updates.
:param event: The event to send to :attr:`sigItemChanged` signal.
"""
if event == Item3DChangedType.ROOT_ITEM:
self.__syncForegroundColor()
if event is not None:
self.sigItemChanged.emit(event)
# Label
[docs]
def getLabel(self):
"""Returns the label associated to this item.
:rtype: str
"""
return self._label
[docs]
def setLabel(self, label):
"""Set the label associated to this item.
:param str label:
"""
label = str(label)
if label != self._label:
self._label = label
self._updated(Item3DChangedType.LABEL)
# Visibility
[docs]
def isVisible(self):
"""Returns True if item is visible, else False
:rtype: bool
"""
return self._getScenePrimitive().visible
[docs]
def setVisible(self, visible=True):
"""Set the visibility of the item in the scene.
:param bool visible: True (default) to show the item, False to hide
"""
visible = bool(visible)
primitive = self._getScenePrimitive()
if visible != primitive.visible:
primitive.visible = visible
self._updated(ItemChangedType.VISIBLE)
# Foreground color
def _setForegroundColor(self, color):
"""Set the foreground color of the item.
The default implementation does nothing, override it in subclass.
:param color: RGBA color
:type color: tuple of 4 float in [0., 1.]
"""
if hasattr(super(Item3D, self), "_setForegroundColor"):
super(Item3D, self)._setForegroundColor(color)
def __syncForegroundColor(self):
"""Retrieve foreground color from parent and update this item"""
# Look-up for SceneWidget to get its foreground color
root = self.root()
if root is not None:
widget = root.parent()
if isinstance(widget, qt.QWidget):
self._setForegroundColor(widget.getForegroundColor().getRgbF())
# picking
def _pick(self, context):
"""Implement picking on this item.
:param PickContext context: Current picking context
:return: Data indices at picked position or None
:rtype: Union[None,PickingResult]
"""
if (
self.isVisible()
and context.isEnabled()
and context.isItemPickable(self)
and self._pickFastCheck(context)
):
return self._pickFull(context)
return None
def _pickFastCheck(self, context):
"""Approximate item pick test (e.g., bounding box-based picking).
:param PickContext context: Current picking context
:return: True if item might be picked
:rtype: bool
"""
primitive = self._getScenePrimitive()
positionNdc = context.getNDCPosition()
if positionNdc is None: # No picking outside viewport
return False
bounds = primitive.bounds(transformed=False, dataBounds=False)
if bounds is None: # primitive has no bounds
return False
bounds = primitive.objectToNDCTransform.transformBounds(bounds)
return (
bounds[0, 0] <= positionNdc[0] <= bounds[1, 0]
and bounds[0, 1] <= positionNdc[1] <= bounds[1, 1]
)
def _pickFull(self, context):
"""Perform precise picking in this item at given widget position.
:param PickContext context: Current picking context
:return: Object holding the results or None
:rtype: Union[None,PickingResult]
"""
return None
[docs]
class DataItem3D(Item3D):
"""Base class representing a data item with transform in the scene.
:param parent: The View widget this item belongs to.
:param Union[GroupBBox, None] group:
The scene group to use for rendering
"""
def __init__(self, parent, group=None):
if group is None:
group = primitives.GroupBBox()
# Set-up bounding box
group.boxVisible = False
group.axesVisible = False
else:
assert isinstance(group, primitives.GroupBBox)
Item3D.__init__(self, parent=parent, primitive=group)
# Transformations
self._translate = transform.Translate()
self._rotateForwardTranslation = transform.Translate()
self._rotate = transform.Rotate()
self._rotateBackwardTranslation = transform.Translate()
self._translateFromRotationCenter = transform.Translate()
self._matrix = transform.Matrix()
self._scale = transform.Scale()
# Group transforms to do to data before rotation
# This is useful to handle rotation center relative to bbox
self._transformObjectToRotate = transform.TransformList(
[self._matrix, self._scale]
)
self._transformObjectToRotate.addListener(self._updateRotationCenter)
self._rotationCenter = 0.0, 0.0, 0.0
self.__transforms = transform.TransformList(
[
self._translate,
self._rotateForwardTranslation,
self._rotate,
self._rotateBackwardTranslation,
self._transformObjectToRotate,
]
)
self._getScenePrimitive().transforms = self.__transforms
def _updated(self, event=None):
"""Handle MixIn class updates.
:param event: The event to send to :attr:`sigItemChanged` signal.
"""
if event == ItemChangedType.DATA:
self._updateRotationCenter()
super(DataItem3D, self)._updated(event)
# Transformations
def _getSceneTransforms(self):
"""Return TransformList corresponding to current transforms
:rtype: TransformList
"""
return self.__transforms
[docs]
def setScale(self, sx=1.0, sy=1.0, sz=1.0):
"""Set the scale of the item in the scene.
:param float sx: Scale factor along the X axis
:param float sy: Scale factor along the Y axis
:param float sz: Scale factor along the Z axis
"""
scale = numpy.array((sx, sy, sz), dtype=numpy.float32)
if not numpy.all(numpy.equal(scale, self.getScale())):
self._scale.scale = scale
self._updated(Item3DChangedType.TRANSFORM)
[docs]
def getScale(self):
"""Returns the scales provided by :meth:`setScale`.
:rtype: numpy.ndarray
"""
return self._scale.scale
[docs]
def setTranslation(self, x=0.0, y=0.0, z=0.0):
"""Set the translation of the origin of the item in the scene.
:param float x: Offset of the data origin on the X axis
:param float y: Offset of the data origin on the Y axis
:param float z: Offset of the data origin on the Z axis
"""
translation = numpy.array((x, y, z), dtype=numpy.float32)
if not numpy.all(numpy.equal(translation, self.getTranslation())):
self._translate.translation = translation
self._updated(Item3DChangedType.TRANSFORM)
[docs]
def getTranslation(self):
"""Returns the offset set by :meth:`setTranslation`.
:rtype: numpy.ndarray
"""
return self._translate.translation
_ROTATION_CENTER_TAGS = "lower", "center", "upper"
def _updateRotationCenter(self, *args, **kwargs):
"""Update rotation center relative to bounding box"""
center = []
for index, position in enumerate(self.getRotationCenter()):
# Patch position relative to bounding box
if position in self._ROTATION_CENTER_TAGS:
bounds = self._getScenePrimitive().bounds(
transformed=False, dataBounds=True
)
bounds = self._transformObjectToRotate.transformBounds(bounds)
if bounds is None:
position = 0.0
elif position == "lower":
position = bounds[0, index]
elif position == "center":
position = 0.5 * (bounds[0, index] + bounds[1, index])
elif position == "upper":
position = bounds[1, index]
center.append(position)
if not numpy.all(
numpy.equal(center, self._rotateForwardTranslation.translation)
):
self._rotateForwardTranslation.translation = center
self._rotateBackwardTranslation.translation = (
-self._rotateForwardTranslation.translation
)
self._updated(Item3DChangedType.TRANSFORM)
[docs]
def setRotationCenter(self, x=0.0, y=0.0, z=0.0):
"""Set the center of rotation of the item.
Position of the rotation center is either a float
for an absolute position or one of the following
string to define a position relative to the item's bounding box:
'lower', 'center', 'upper'
:param x: rotation center position on the X axis
:rtype: float or str
:param y: rotation center position on the Y axis
:rtype: float or str
:param z: rotation center position on the Z axis
:rtype: float or str
"""
center = []
for position in (x, y, z):
if isinstance(position, str):
assert position in self._ROTATION_CENTER_TAGS
else:
position = float(position)
center.append(position)
center = tuple(center)
if center != self._rotationCenter:
self._rotationCenter = center
self._updateRotationCenter()
[docs]
def getRotationCenter(self):
"""Returns the rotation center set by :meth:`setRotationCenter`.
:rtype: 3-tuple of float or str
"""
return self._rotationCenter
[docs]
def setRotation(self, angle=0.0, axis=(0.0, 0.0, 1.0)):
"""Set the rotation of the item in the scene
:param float angle: The rotation angle in degrees.
:param axis: The (x, y, z) coordinates of the rotation axis.
"""
axis = numpy.array(axis, dtype=numpy.float32)
assert axis.ndim == 1
assert axis.size == 3
if self._rotate.angle != angle or not numpy.all(
numpy.equal(axis, self._rotate.axis)
):
self._rotate.setAngleAxis(angle, axis)
self._updated(Item3DChangedType.TRANSFORM)
[docs]
def getRotation(self):
"""Returns the rotation set by :meth:`setRotation`.
:return: (angle, axis)
:rtype: 2-tuple (float, numpy.ndarray)
"""
return self._rotate.angle, self._rotate.axis
[docs]
def setMatrix(self, matrix=None):
"""Set the transform matrix
:param numpy.ndarray matrix: 3x3 transform matrix
"""
matrix4x4 = numpy.identity(4, dtype=numpy.float32)
if matrix is not None:
matrix = numpy.array(matrix, dtype=numpy.float32)
assert matrix.shape == (3, 3)
matrix4x4[:3, :3] = matrix
if not numpy.all(numpy.equal(matrix4x4, self._matrix.getMatrix())):
self._matrix.setMatrix(matrix4x4)
self._updated(Item3DChangedType.TRANSFORM)
[docs]
def getMatrix(self):
"""Returns the matrix set by :meth:`setMatrix`
:return: 3x3 matrix
:rtype: numpy.ndarray"""
return self._matrix.getMatrix(copy=True)[:3, :3]
# Bounding box
def _setForegroundColor(self, color):
"""Set the color of the bounding box
:param color: RGBA color as 4 floats in [0, 1]
"""
self._getScenePrimitive().color = color
super(DataItem3D, self)._setForegroundColor(color)
[docs]
def isBoundingBoxVisible(self):
"""Returns item's bounding box visibility.
:rtype: bool
"""
return self._getScenePrimitive().boxVisible
[docs]
def setBoundingBoxVisible(self, visible):
"""Set item's bounding box visibility.
:param bool visible:
True to show the bounding box, False (default) to hide it
"""
visible = bool(visible)
primitive = self._getScenePrimitive()
if visible != primitive.boxVisible:
primitive.boxVisible = visible
self._updated(Item3DChangedType.BOUNDING_BOX_VISIBLE)
class BaseNodeItem(DataItem3D):
"""Base class for data item having children (e.g., group, 3d volume)."""
def __init__(self, parent=None, group=None):
"""Base class representing a group of items in the scene.
:param parent: The View widget this item belongs to.
:param Union[GroupBBox, None] group:
The scene group to use for rendering
"""
DataItem3D.__init__(self, parent=parent, group=group)
def getItems(self):
"""Returns the list of items currently present in the group.
:rtype: tuple
"""
raise NotImplementedError("getItems must be implemented in subclass")
def visit(self, included=True):
"""Generator visiting the group content.
It traverses the group sub-tree in a top-down left-to-right way.
:param bool included: True (default) to include self in visit
"""
if included:
yield self
for child in self.getItems():
yield child
if hasattr(child, "visit"):
for item in child.visit(included=False):
yield item
def pickItems(self, x, y, condition=None):
"""Iterator over picked items in the group at given position.
Each picked item yield a :class:`PickingResult` object
holding the picking information.
It traverses the group sub-tree in a left-to-right top-down way.
:param int x: X widget device pixel coordinate
:param int y: Y widget device pixel coordinate
:param callable condition: Optional test called for each item
checking whether to process it or not.
"""
viewport = self._getScenePrimitive().viewport
if viewport is None:
raise RuntimeError("Cannot perform picking: Item not attached to a widget")
context = PickContext(x, y, viewport, condition)
for result in self._pickItems(context):
yield result
def _pickItems(self, context):
"""Implement :meth:`pickItems`
:param PickContext context: Current picking context
"""
if not self.isVisible() or not context.isEnabled():
return # empty iterator
# Use a copy to discard context changes once this returns
context = context.copy()
if not self._pickFastCheck(context):
return # empty iterator
result = self._pick(context)
if result is not None:
yield result
for child in self.getItems():
if isinstance(child, BaseNodeItem):
for result in child._pickItems(context):
yield result # Flatten result
else:
result = child._pick(context)
if result is not None:
yield result
class _BaseGroupItem(BaseNodeItem):
"""Base class for group of items sharing a common transform."""
sigItemAdded = qt.Signal(object)
"""Signal emitted when a new item is added to the group.
The newly added item is provided by this signal
"""
sigItemRemoved = qt.Signal(object)
"""Signal emitted when an item is removed from the group.
The removed item is provided by this signal.
"""
def __init__(self, parent=None, group=None):
"""Base class representing a group of items in the scene.
:param parent: The View widget this item belongs to.
:param Union[GroupBBox, None] group:
The scene group to use for rendering
"""
BaseNodeItem.__init__(self, parent=parent, group=group)
self._items = []
def _getGroupPrimitive(self):
"""Returns the group for which to handle children.
This allows this group to be different from the primitive.
"""
return self._getScenePrimitive()
def addItem(self, item, index=None):
"""Add an item to the group
:param Item3D item: The item to add
:param int index: The index at which to place the item.
By default it is appended to the end of the list.
:raise ValueError: If the item is already in the group.
"""
assert isinstance(item, Item3D)
assert item.parent() in (None, self)
if item in self.getItems():
raise ValueError("Item3D already in group: %s" % item)
item.setParent(self)
if index is None:
self._getGroupPrimitive().children.append(item._getScenePrimitive())
self._items.append(item)
else:
self._getGroupPrimitive().children.insert(index, item._getScenePrimitive())
self._items.insert(index, item)
self.sigItemAdded.emit(item)
def getItems(self):
"""Returns the list of items currently present in the group.
:rtype: tuple
"""
return tuple(self._items)
def removeItem(self, item):
"""Remove an item from the scene.
:param Item3D item: The item to remove from the scene
:raises ValueError: If the item does not belong to the group
"""
if item not in self.getItems():
raise ValueError("Item3D not in group: %s" % str(item))
self._getGroupPrimitive().children.remove(item._getScenePrimitive())
self._items.remove(item)
item.setParent(None)
self.sigItemRemoved.emit(item)
def clearItems(self):
"""Remove all item from the group."""
for item in self.getItems():
self.removeItem(item)
[docs]
class GroupItem(_BaseGroupItem):
"""Group of items sharing a common transform."""
def __init__(self, parent=None):
super(GroupItem, self).__init__(parent=parent)
class GroupWithAxesItem(_BaseGroupItem):
"""
Group of items sharing a common transform surrounded with labelled axes.
"""
def __init__(self, parent=None):
"""Class representing a group of items in the scene with labelled axes.
:param parent: The View widget this item belongs to.
"""
super(GroupWithAxesItem, self).__init__(
parent=parent, group=axes.LabelledAxes()
)
# Axes labels
def setAxesLabels(self, xlabel=None, ylabel=None, zlabel=None):
"""Set the text labels of the axes.
:param str xlabel: Label of the X axis, None to leave unchanged.
:param str ylabel: Label of the Y axis, None to leave unchanged.
:param str zlabel: Label of the Z axis, None to leave unchanged.
"""
labelledAxes = self._getScenePrimitive()
if xlabel is not None:
labelledAxes.xlabel = xlabel
if ylabel is not None:
labelledAxes.ylabel = ylabel
if zlabel is not None:
labelledAxes.zlabel = zlabel
class _Labels(tuple):
"""Return type of :meth:`getAxesLabels`"""
def getXLabel(self):
"""Label of the X axis (str)"""
return self[0]
def getYLabel(self):
"""Label of the Y axis (str)"""
return self[1]
def getZLabel(self):
"""Label of the Z axis (str)"""
return self[2]
def getAxesLabels(self):
"""Returns the text labels of the axes
>>> group = GroupWithAxesItem()
>>> group.setAxesLabels(xlabel='X')
You can get the labels either as a 3-tuple:
>>> xlabel, ylabel, zlabel = group.getAxesLabels()
Or as an object with methods getXLabel, getYLabel and getZLabel:
>>> labels = group.getAxesLabels()
>>> labels.getXLabel()
... 'X'
:return: object describing the labels
"""
labelledAxes = self._getScenePrimitive()
return self._Labels(
(labelledAxes.xlabel, labelledAxes.ylabel, labelledAxes.zlabel)
)
class RootGroupWithAxesItem(GroupWithAxesItem):
"""Special group with axes item for root of the scene.
Uses 2 groups so that axes take transforms into account.
"""
def __init__(self, parent=None):
super(RootGroupWithAxesItem, self).__init__(parent)
self.__group = scene.Group()
self.__group.transforms = self._getSceneTransforms()
groupWithAxes = self._getScenePrimitive()
groupWithAxes.transforms = [] # Do not apply transforms here
groupWithAxes.children.append(self.__group)
def _getGroupPrimitive(self):
"""Returns the group for which to handle children.
This allows this group to be different from the primitive.
"""
return self.__group