Skip to main content
`Ctrl`+`K`

# 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

    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())

        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

        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

        def getSceneGroup(self):
            """Returns the root group of the scene

            :rtype: GroupItem
            """
            return self._sceneGroup

        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")

        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)

        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

        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)

        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

        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

        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

        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)

        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)

        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()

        def clearItems(self):
            """Remove all item from :class:`SceneWidget`."""
            return self.getSceneGroup().clearItems()

        # Colors

        def getTextColor(self):
            """Return color used for text

            :rtype: QColor"""
            return qt.QColor.fromRgbF(*self._textColor)

        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")

        def getForegroundColor(self):
            """Return color used for bounding box

            :rtype: QColor
            """
            return qt.QColor.fromRgbF(*self._foregroundColor)

        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")

        def getHighlightColor(self):
            """Return color used for highlighted item bounding box

            :rtype: QColor
            """
            return qt.QColor.fromRgbF(*self._highlightColor)

        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")