Source code for silx.gui.plot3d.SceneWidget

# /*##########################################################################
#
# Copyright (c) 2017-2019 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 widget to view data sets in 3D."""

__authors__ = ["T. Vincent"]
__license__ = "MIT"
__date__ = "24/04/2018"

import enum
import weakref

import numpy

from .. import qt
from ..colors import rgba

from .Plot3DWidget import Plot3DWidget
from . import items
from .items.core import RootGroupWithAxesItem
from .scene import interaction
from ._model import SceneModel, visitQAbstractItemModel
from ._model.items import Item3DRow

__all__ = ["items", "SceneWidget"]


class _SceneSelectionHighlightManager(object):
    """Class controlling the highlight of the selection in a SceneWidget

    :param ~silx.gui.plot3d.SceneWidget.SceneSelection:
    """

    def __init__(self, selection):
        assert isinstance(selection, SceneSelection)
        self._sceneWidget = weakref.ref(selection.parent())

        self._enabled = True
        self._previousBBoxState = None

        self.__selectItem(selection.getCurrentItem())
        selection.sigCurrentChanged.connect(self.__currentChanged)

    def isEnabled(self):
        """Returns True if highlight of selection in enabled.

        :rtype: bool
        """
        return self._enabled

    def setEnabled(self, enabled=True):
        """Activate/deactivate selection highlighting

        :param bool enabled: True (default) to enable selection highlighting
        """
        enabled = bool(enabled)
        if enabled != self._enabled:
            self._enabled = enabled

            sceneWidget = self.getSceneWidget()
            if sceneWidget is not None:
                selection = sceneWidget.selection()
                current = selection.getCurrentItem()

                if enabled:
                    self.__selectItem(current)
                    selection.sigCurrentChanged.connect(self.__currentChanged)

                else:  # disabled
                    self.__unselectItem(current)
                    selection.sigCurrentChanged.disconnect(self.__currentChanged)

    def getSceneWidget(self):
        """Returns the SceneWidget this class controls highlight for.

        :rtype: ~silx.gui.plot3d.SceneWidget.SceneWidget
        """
        return self._sceneWidget()

    def __selectItem(self, current):
        """Highlight given item.

        :param ~silx.gui.plot3d.items.Item3D current: New current or None
        """
        if current is None:
            return

        sceneWidget = self.getSceneWidget()
        if sceneWidget is None:
            return

        if isinstance(current, items.DataItem3D):
            self._previousBBoxState = current.isBoundingBoxVisible()
            current.setBoundingBoxVisible(True)
        current._setForegroundColor(sceneWidget.getHighlightColor())
        current.sigItemChanged.connect(self.__selectedChanged)

    def __unselectItem(self, current):
        """Remove highlight of given item.

        :param ~silx.gui.plot3d.items.Item3D current:
            Currently highlighted item
        """
        if current is None:
            return

        sceneWidget = self.getSceneWidget()
        if sceneWidget is None:
            return

        # Restore bbox visibility and color
        current.sigItemChanged.disconnect(self.__selectedChanged)
        if self._previousBBoxState is not None and isinstance(
            current, items.DataItem3D
        ):
            current.setBoundingBoxVisible(self._previousBBoxState)
        current._setForegroundColor(sceneWidget.getForegroundColor())

    def __currentChanged(self, current, previous):
        """Handle change of current item in the selection

        :param ~silx.gui.plot3d.items.Item3D current: New current or None
        :param ~silx.gui.plot3d.items.Item3D previous: Previous current or None
        """
        self.__unselectItem(previous)
        self.__selectItem(current)

    def __selectedChanged(self, event):
        """Handle updates of selected item bbox.

        If bbox gets changed while selected, do not restore state.

        :param event:
        """
        if event == items.Item3DChangedType.BOUNDING_BOX_VISIBLE:
            self._previousBBoxState = None


@enum.unique
class HighlightMode(enum.Enum):
    """:class:`SceneSelection` highlight modes"""

    NONE = "noHighlight"
    """Do not highlight selected item"""

    BOUNDING_BOX = "boundingBox"
    """Highlight selected item bounding box"""


class SceneSelection(qt.QObject):
    """Object managing a :class:`SceneWidget` selection

    :param SceneWidget parent:
    """

    NO_SELECTION = 0
    """Flag for no item selected"""

    sigCurrentChanged = qt.Signal(object, object)
    """This signal is emitted whenever the current item changes.

    It provides the current and previous items.
    Either of those can be :attr:`NO_SELECTION`.
    """

    def __init__(self, parent=None):
        super(SceneSelection, self).__init__(parent)
        self.__current = None  # Store weakref to current item
        self.__selectionModel = None  # Store sync selection model
        self.__syncInProgress = False  # True during model synchronization

        self.__highlightManager = _SceneSelectionHighlightManager(self)

    def getHighlightMode(self):
        """Returns current selection highlight mode.

        Either NONE or BOUNDING_BOX.

        :rtype: HighlightMode
        """
        if self.__highlightManager.isEnabled():
            return HighlightMode.BOUNDING_BOX
        else:
            return HighlightMode.NONE

    def setHighlightMode(self, mode):
        """Set selection highlighting mode

        :param HighlightMode mode: The mode to use
        """
        assert isinstance(mode, HighlightMode)
        self.__highlightManager.setEnabled(mode == HighlightMode.BOUNDING_BOX)

    def getCurrentItem(self):
        """Returns the current item in the scene or None.

        :rtype: Union[~silx.gui.plot3d.items.Item3D, None]
        """
        return None if self.__current is None else self.__current()

    def setCurrentItem(self, item):
        """Set the current item in the scene.

        :param Union[Item3D, None] item:
            The new item to select or None to clear the selection.
        :raise ValueError: If the item is not the widget's scene
        """
        previous = self.getCurrentItem()
        if item is previous:
            return  # Fast path, nothing to do

        if previous is not None:
            previous.sigItemChanged.disconnect(self.__currentChanged)

        if item is None:
            self.__current = None

        elif isinstance(item, items.Item3D):
            parent = self.parent()
            assert isinstance(parent, SceneWidget)

            sceneGroup = parent.getSceneGroup()
            if item is sceneGroup or item.root() is sceneGroup:
                item.sigItemChanged.connect(self.__currentChanged)
                self.__current = weakref.ref(item)
            else:
                raise ValueError("Item is not in this SceneWidget: %s" % str(item))

        else:
            raise ValueError("Not an Item3D: %s" % str(item))

        current = self.getCurrentItem()
        self.sigCurrentChanged.emit(current, previous)
        self.__updateSelectionModel()

    def __currentChanged(self, event):
        """Handle updates of the selected item"""
        if event == items.Item3DChangedType.ROOT_ITEM:
            item = self.sender()

            parent = self.parent()
            assert isinstance(parent, SceneWidget)

            if item.root() != parent.getSceneGroup():
                self.setCurrentItem(None)

    # Synchronization with QItemSelectionModel

    def _getSyncSelectionModel(self):
        """Returns the QItemSelectionModel this selection is synchronized with.

        :rtype: Union[QItemSelectionModel, None]
        """
        return self.__selectionModel

    def _setSyncSelectionModel(self, selectionModel):
        """Synchronizes this selection object with a selection model.

        :param Union[QItemSelectionModel, None] selectionModel:
        :raise ValueError: If the selection model does not correspond
                           to the same :class:`SceneWidget`
        """
        if (
            not isinstance(selectionModel, qt.QItemSelectionModel)
            or not isinstance(selectionModel.model(), SceneModel)
            or selectionModel.model().sceneWidget() is not self.parent()
        ):
            raise ValueError(
                "Expecting a QItemSelectionModel " "attached to the same SceneWidget"
            )

        # Disconnect from previous selection model
        previousSelectionModel = self._getSyncSelectionModel()
        if previousSelectionModel is not None:
            previousSelectionModel.selectionChanged.disconnect(
                self.__selectionModelSelectionChanged
            )

        self.__selectionModel = selectionModel

        if selectionModel is not None:
            # Connect to new selection model
            selectionModel.selectionChanged.connect(
                self.__selectionModelSelectionChanged
            )
            self.__updateSelectionModel()

    def __selectionModelSelectionChanged(self, selected, deselected):
        """Handle QItemSelectionModel selection updates.

        :param QItemSelection selected:
        :param QItemSelection deselected:
        """
        if self.__syncInProgress:
            return

        indices = selected.indexes()
        if not indices:
            item = None

        else:  # Select the first selected item
            index = indices[0]
            itemRow = index.internalPointer()
            if isinstance(itemRow, Item3DRow):
                item = itemRow.item()
            else:
                item = None

        self.setCurrentItem(item)

    def __updateSelectionModel(self):
        """Sync selection model when current item has been updated"""
        selectionModel = self._getSyncSelectionModel()
        if selectionModel is None:
            return

        currentItem = self.getCurrentItem()

        if currentItem is None:
            selectionModel.clear()

        else:
            # visit the model to find selectable index corresponding to item
            model = selectionModel.model()
            for index in visitQAbstractItemModel(model):
                itemRow = index.internalPointer()
                if (
                    isinstance(itemRow, Item3DRow)
                    and itemRow.item() is currentItem
                    and index.flags() & qt.Qt.ItemIsSelectable
                ):
                    # This is the item we are looking for: select it in the model
                    self.__syncInProgress = True
                    selectionModel.select(
                        index,
                        qt.QItemSelectionModel.Clear
                        | qt.QItemSelectionModel.Select
                        | qt.QItemSelectionModel.Current,
                    )
                    self.__syncInProgress = False
                    break


[docs] class SceneWidget(Plot3DWidget): """Widget displaying data sets in 3D""" def __init__(self, parent=None): super(SceneWidget, self).__init__(parent) self._model = None # Store lazy-loaded model self._selection = None # Store lazy-loaded SceneSelection self._items = [] self._textColor = 1.0, 1.0, 1.0, 1.0 self._foregroundColor = 1.0, 1.0, 1.0, 1.0 self._highlightColor = 0.7, 0.7, 0.0, 1.0 self._sceneGroup = RootGroupWithAxesItem(parent=self) self._sceneGroup.setLabel("Data") self.viewport.scene.children.append(self._sceneGroup._getScenePrimitive())
[docs] def model(self): """Returns the model corresponding the scene of this widget :rtype: SceneModel """ if self._model is None: # Lazy-loading of the model self._model = SceneModel(parent=self) return self._model
[docs] def selection(self): """Returns the object managing selection in the scene :rtype: SceneSelection """ if self._selection is None: # Lazy-loading of the SceneSelection self._selection = SceneSelection(parent=self) return self._selection
[docs] def getSceneGroup(self): """Returns the root group of the scene :rtype: GroupItem """ return self._sceneGroup
[docs] def pickItems(self, x, y, condition=None): """Iterator over picked items in the scene at given position. Each picked item yield a :class:`~silx.gui.plot3d.items._pick.PickingResult` object holding the picking information. It traverses the scene tree in a left-to-right top-down way. :param int x: X widget coordinate :param int y: Y widget coordinate :param callable condition: Optional test called for each item checking whether to process it or not. """ if not self.isValid() or not self.isVisible(): return # Empty iterator devicePixelRatio = self.getDevicePixelRatio() for result in self.getSceneGroup().pickItems( x * devicePixelRatio, y * devicePixelRatio, condition ): yield result
# Interactive modes def _handleSelectionChanged(self, current, previous): """Handle change of selection to update interactive mode""" if self.getInteractiveMode() == "panSelectedPlane": if isinstance(current, items.PlaneMixIn): # Update pan plane to use new selected plane self.setInteractiveMode("panSelectedPlane") else: # Switch to rotate scene if new selection is not a plane self.setInteractiveMode("rotate")
[docs] def setInteractiveMode(self, mode): """Set the interactive mode. 'panSelectedPlane' mode set plane panning if a plane is selected, otherwise it fall backs to 'rotate'. :param str mode: The interactive mode: 'rotate', 'pan', 'panSelectedPlane' or None """ if self.getInteractiveMode() == "panSelectedPlane": self.selection().sigCurrentChanged.disconnect(self._handleSelectionChanged) if mode == "panSelectedPlane": selected = self.selection().getCurrentItem() if isinstance(selected, items.PlaneMixIn): mode = interaction.PanPlaneZoomOnWheelControl( self.viewport, selected._getPlane(), mode="position", orbitAroundCenter=False, scaleTransform=self._sceneScale, ) self.selection().sigCurrentChanged.connect(self._handleSelectionChanged) else: # No selected plane, fallback to rotate scene mode = "rotate" super(SceneWidget, self).setInteractiveMode(mode)
[docs] def getInteractiveMode(self): """Returns the interactive mode in use. :rtype: str """ if isinstance(self.eventHandler, interaction.PanPlaneZoomOnWheelControl): return "panSelectedPlane" else: return super(SceneWidget, self).getInteractiveMode()
# Add/remove items
[docs] def addVolume(self, data, copy=True, index=None): """Add 3D data volume of scalar or complex to :class:`SceneWidget` content. Dataset order is zyx (i.e., first dimension is z). :param data: 3D array of complex with shape at least (2, 2, 2) :type data: numpy.ndarray[Union[numpy.complex64,numpy.float32]] :param bool copy: True (default) to make a copy, False to avoid copy (DO NOT MODIFY data afterwards) :param int index: The index at which to place the item. By default it is appended to the end of the list. :return: The newly created 3D volume item :rtype: Union[ScalarField3D,ComplexField3D] """ if data is not None: data = numpy.asarray(data) if numpy.iscomplexobj(data): volume = items.ComplexField3D() else: volume = items.ScalarField3D() volume.setData(data, copy=copy) self.addItem(volume, index) return volume
def add3DScalarField(self, data, copy=True, index=None): # TODO deprecate in the future return self.addVolume(data, copy=copy, index=index)
[docs] def add3DScatter(self, x, y, z, value, copy=True, index=None): """Add 3D scatter data to :class:`SceneWidget` content. :param numpy.ndarray x: Array of X coordinates (single value not accepted) :param y: Points Y coordinate (array-like or single value) :param z: Points Z coordinate (array-like or single value) :param value: Points values (array-like or single value) :param bool copy: True (default) to copy the data, False to use provided data (do not modify!) :param int index: The index at which to place the item. By default it is appended to the end of the list. :return: The newly created 3D scatter item :rtype: ~silx.gui.plot3d.items.scatter.Scatter3D """ scatter3d = items.Scatter3D() scatter3d.setData(x=x, y=y, z=z, value=value, copy=copy) self.addItem(scatter3d, index) return scatter3d
[docs] def add2DScatter(self, x, y, value, copy=True, index=None): """Add 2D scatter data to :class:`SceneWidget` content. Provided arrays must have the same length. :param numpy.ndarray x: X coordinates (array-like) :param numpy.ndarray y: Y coordinates (array-like) :param value: Points value: array-like or single scalar :param bool copy: True (default) to copy the data, False to use as is (do not modify!). :param int index: The index at which to place the item. By default it is appended to the end of the list. :return: The newly created 2D scatter item :rtype: ~silx.gui.plot3d.items.scatter.Scatter2D """ scatter2d = items.Scatter2D() scatter2d.setData(x=x, y=y, value=value, copy=copy) self.addItem(scatter2d, index) return scatter2d
[docs] def addImage(self, data, copy=True, index=None): """Add a 2D data or RGB(A) image to :class:`SceneWidget` content. 2D data is casted to float32. RGBA supported formats are: float32 in [0, 1] and uint8. :param numpy.ndarray data: Image as a 2D data array or RGBA image as a 3D array (height, width, channels) :param bool copy: True (default) to copy the data, False to use as is (do not modify!). :param int index: The index at which to place the item. By default it is appended to the end of the list. :return: The newly created image item :rtype: ~silx.gui.plot3d.items.image.ImageData or ~silx.gui.plot3d.items.image.ImageRgba :raise ValueError: For arrays of unsupported dimensions """ data = numpy.asarray(data) if data.ndim == 2: image = items.ImageData() elif data.ndim == 3: image = items.ImageRgba() else: raise ValueError("Unsupported array dimensions: %d" % data.ndim) image.setData(data, copy=copy) self.addItem(image, index) return image
[docs] def addItem(self, item, index=None): """Add an item to :class:`SceneWidget` content :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 :class:`SceneWidget`. """ return self.getSceneGroup().addItem(item, index)
[docs] def removeItem(self, item): """Remove an item from :class:`SceneWidget` content. :param Item3D item: The item to remove from the scene :raises ValueError: If the item does not belong to the group """ return self.getSceneGroup().removeItem(item)
[docs] def getItems(self): """Returns the list of :class:`SceneWidget` items. Only items in the top-level group are returned. :rtype: tuple """ return self.getSceneGroup().getItems()
[docs] def clearItems(self): """Remove all item from :class:`SceneWidget`.""" return self.getSceneGroup().clearItems()
# Colors
[docs] def getTextColor(self): """Return color used for text :rtype: QColor""" return qt.QColor.fromRgbF(*self._textColor)
[docs] def setTextColor(self, color): """Set the text color. :param color: RGB color: name, #RRGGBB or RGB values :type color: QColor, str or array-like of 3 or 4 float in [0., 1.] or uint8 """ color = rgba(color) if color != self._textColor: self._textColor = color # Update text color # TODO make entry point in Item3D for this bbox = self._sceneGroup._getScenePrimitive() bbox.tickColor = color self.sigStyleChanged.emit("textColor")
[docs] def getForegroundColor(self): """Return color used for bounding box :rtype: QColor """ return qt.QColor.fromRgbF(*self._foregroundColor)
[docs] def setForegroundColor(self, color): """Set the foreground color. :param color: RGB color: name, #RRGGBB or RGB values :type color: QColor, str or array-like of 3 or 4 float in [0., 1.] or uint8 """ color = rgba(color) if color != self._foregroundColor: self._foregroundColor = color # Update scene items selected = self.selection().getCurrentItem() for item in self.getSceneGroup().visit(included=True): if item is not selected: item._setForegroundColor(color) self.sigStyleChanged.emit("foregroundColor")
[docs] def getHighlightColor(self): """Return color used for highlighted item bounding box :rtype: QColor """ return qt.QColor.fromRgbF(*self._highlightColor)
[docs] def setHighlightColor(self, color): """Set highlighted item color. :param color: RGB color: name, #RRGGBB or RGB values :type color: QColor, str or array-like of 3 or 4 float in [0., 1.] or uint8 """ color = rgba(color) if color != self._highlightColor: self._highlightColor = color selected = self.selection().getCurrentItem() if selected is not None: selected._setForegroundColor(color) self.sigStyleChanged.emit("highlightColor")