# coding: utf-8 # /*########################################################################## # # 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.""" from __future__ import absolute_import __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., 1., 1., 1. self._foregroundColor = 1., 1., 1., 1. self._highlightColor = 0.7, 0.7, 0., 1. 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.array(data, copy=False) 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.array(data, copy=False) 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')