diff options
Diffstat (limited to 'silx/gui/plot3d')
26 files changed, 2032 insertions, 152 deletions
diff --git a/silx/gui/plot3d/ParamTreeView.py b/silx/gui/plot3d/ParamTreeView.py index a352627..ee0c876 100644 --- a/silx/gui/plot3d/ParamTreeView.py +++ b/silx/gui/plot3d/ParamTreeView.py @@ -40,6 +40,7 @@ __license__ = "MIT" __date__ = "05/12/2017" +import numbers import sys from silx.third_party import six @@ -362,7 +363,7 @@ class ParameterTreeDelegate(qt.QStyledItemDelegate): assert isinstance(editor, qt.QWidget) editor.setParent(parent) - elif isinstance(data, (int, float)) and editorHint is not None: + elif isinstance(data, numbers.Number) and editorHint is not None: # Use a slider editor = IntSliderEditor(parent) range_ = editorHint @@ -394,7 +395,11 @@ class ParameterTreeDelegate(qt.QStyledItemDelegate): if hasattr(notifySignal, 'signature'): # Qt4 signature = notifySignal.signature() else: - signature = bytes(notifySignal.methodSignature()) + signature = notifySignal.methodSignature() + if qt.BINDING == 'PySide2': + signature = signature.data() + else: + signature = bytes(signature) if hasattr(signature, 'decode'): # For PySide with python3 signature = signature.decode('ascii') @@ -472,7 +477,7 @@ class ParamTreeView(qt.QTreeView): editorHint = index.data(qt.Qt.UserRole) if (isinstance(data, bool) or callable(editorHint) or - (isinstance(data, (float, int)) and editorHint)): + (isinstance(data, numbers.Number) and editorHint)): self.openPersistentEditor(index) self.__persistentEditors.add(index) diff --git a/silx/gui/plot3d/Plot3DWidget.py b/silx/gui/plot3d/Plot3DWidget.py index 53ff895..eed4438 100644 --- a/silx/gui/plot3d/Plot3DWidget.py +++ b/silx/gui/plot3d/Plot3DWidget.py @@ -36,7 +36,7 @@ import logging from silx.gui import qt from silx.gui.colors import rgba from . import actions -from ..utils._image import convertArrayToQImage +from ..utils.image import convertArrayToQImage from .. import _glutils as glu from .scene import interaction, primitives, transform diff --git a/silx/gui/plot3d/SFViewParamTree.py b/silx/gui/plot3d/SFViewParamTree.py index bb81465..a2b771c 100644 --- a/silx/gui/plot3d/SFViewParamTree.py +++ b/silx/gui/plot3d/SFViewParamTree.py @@ -694,6 +694,10 @@ 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, @@ -717,7 +721,8 @@ class IsoSurfaceRootItem(SubjectItem): self.setCheckState((visible and qt.Qt.Checked) or qt.Qt.Unchecked) nameItem = qt.QStandardItem('Level') - sliderItem = IsoSurfaceLevelSlider(self.subject) + sliderItem = IsoSurfaceLevelSlider(self.subject, + self._isoLevelSliderNormalization) self.appendRow([nameItem, sliderItem]) nameItem = qt.QStandardItem('Color') @@ -788,12 +793,22 @@ class IsoSurfaceLevelItem(SubjectItem): class _IsoLevelSlider(qt.QSlider): - """QSlider used for iso-surface level""" + """QSlider used for iso-surface level with linear scale""" - def __init__(self, parent, subject): + 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) @@ -804,10 +819,13 @@ class _IsoLevelSlider(qt.QSlider): dataRange = self.subject.parent().getDataRange() if dataRange is not None: - width = dataRange[-1] - dataRange[0] + min_ = self.__norm(dataRange[0]) + max_ = self.__norm(dataRange[-1]) + + width = max_ - min_ if width > 0: sliderWidth = self.maximum() - self.minimum() - sliderPosition = sliderWidth * (level - dataRange[0]) / width + sliderPosition = sliderWidth * (self.__norm(level) - min_) / width self.setValue(sliderPosition) def __dataChanged(self): @@ -818,11 +836,12 @@ class _IsoLevelSlider(qt.QSlider): value = self.value() dataRange = self.subject.parent().getDataRange() if dataRange is not None: - min_, _, max_ = dataRange + 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(level) + self.subject.setLevel(self.__invNorm(level)) class IsoSurfaceLevelSlider(IsoSurfaceLevelItem): @@ -832,8 +851,12 @@ class IsoSurfaceLevelSlider(IsoSurfaceLevelItem): 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) + editor = _IsoLevelSlider(parent, self.subject, self.normalization) editor.setOrientation(qt.Qt.Horizontal) editor.setMinimum(0) editor.setMaximum(self.nTicks) @@ -1067,6 +1090,11 @@ 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] @@ -1090,7 +1118,9 @@ class IsoSurfaceGroup(SubjectItem): raise ValueError('Expected an isosurface instance.') def __addIsosurface(self, isosurface): - valueItem = IsoSurfaceRootItem(subject=isosurface) + valueItem = IsoSurfaceRootItem( + subject=isosurface, + normalization=self._isoLevelSliderNormalization) nameItem = IsoSurfaceLevelItem(subject=isosurface) self.insertRow(max(0, self.rowCount() - 1), [valueItem, nameItem]) @@ -1570,6 +1600,7 @@ class TreeView(qt.QTreeView): def __init__(self, parent=None): super(TreeView, self).__init__(parent) self.__openedIndex = None + self._isoLevelSliderNormalization = 'linear' self.setIconSize(qt.QSize(16, 16)) @@ -1607,7 +1638,10 @@ class TreeView(qt.QTreeView): item = IsoSurfaceCount(sfView) item.setEditable(False) - model.appendRow([IsoSurfaceGroup(sfView, 'Isosurfaces'), item]) + model.appendRow([IsoSurfaceGroup(sfView, + self._isoLevelSliderNormalization, + 'Isosurfaces'), + item]) item = qt.QStandardItem() item.setEditable(False) @@ -1771,3 +1805,13 @@ class TreeView(qt.QTreeView): def __delegateEvent(self, task): if task == 'remove_iso': self.__removeIsosurfaces() + + 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 diff --git a/silx/gui/plot3d/SceneWidget.py b/silx/gui/plot3d/SceneWidget.py index f005dec..4a824d7 100644 --- a/silx/gui/plot3d/SceneWidget.py +++ b/silx/gui/plot3d/SceneWidget.py @@ -39,6 +39,7 @@ 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 @@ -363,10 +364,11 @@ class SceneWidget(Plot3DWidget): self._foregroundColor = 1., 1., 1., 1. self._highlightColor = 0.7, 0.7, 0., 1. - self._sceneGroup = items.GroupWithAxesItem(parent=self) + self._sceneGroup = RootGroupWithAxesItem(parent=self) self._sceneGroup.setLabel('Data') - self.viewport.scene.children.append(self._sceneGroup._getScenePrimitive()) + self.viewport.scene.children.append( + self._sceneGroup._getScenePrimitive()) def model(self): """Returns the model corresponding the scene of this widget @@ -395,6 +397,28 @@ class SceneWidget(Plot3DWidget): """ 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): diff --git a/silx/gui/plot3d/_model/items.py b/silx/gui/plot3d/_model/items.py index 02485fe..b09f29a 100644 --- a/silx/gui/plot3d/_model/items.py +++ b/silx/gui/plot3d/_model/items.py @@ -41,7 +41,7 @@ import numpy from silx.third_party import six -from ...utils._image import convertArrayToQImage +from ...utils.image import convertArrayToQImage from ...colors import preferredColormaps from ... import qt, icons from .. import items diff --git a/silx/gui/plot3d/actions/io.py b/silx/gui/plot3d/actions/io.py index f30abeb..4020d6f 100644 --- a/silx/gui/plot3d/actions/io.py +++ b/silx/gui/plot3d/actions/io.py @@ -43,7 +43,7 @@ from silx.gui import qt, printer from silx.gui.icons import getQIcon from .Plot3DAction import Plot3DAction from ..utils import mng -from ...utils._image import convertQImageToArray +from ...utils.image import convertQImageToArray _logger = logging.getLogger(__name__) diff --git a/silx/gui/plot3d/items/_pick.py b/silx/gui/plot3d/items/_pick.py new file mode 100644 index 0000000..b35ef0d --- /dev/null +++ b/silx/gui/plot3d/items/_pick.py @@ -0,0 +1,292 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2018 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 classes supporting item picking. +""" + +from __future__ import absolute_import + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "24/09/2018" + +import logging +import numpy + +from ..scene import Viewport, Base + + +_logger = logging.getLogger(__name__) + + +class PickContext(object): + """Store information related to current picking + + :param int x: Widget coordinate + :param int y: Widget coordinate + :param ~silx.gui.plot3d.scene.Viewport viewport: + Viewport where picking occurs + :param Union[None,callable] condition: + Test whether each item needs to be picked or not. + """ + + def __init__(self, x, y, viewport, condition): + self._widgetPosition = x, y + assert isinstance(viewport, Viewport) + self._viewport = viewport + self._ndcZRange = -1., 1. + self._enabled = True + self._condition = condition + + def copy(self): + """Returns a copy + + :rtype: PickContent + """ + x, y = self.getWidgetPosition() + context = PickContext(x, y, self.getViewport(), self._condition) + context.setNDCZRange(*self._ndcZRange) + context.setEnabled(self.isEnabled()) + return context + + def isItemPickable(self, item): + """Check condition for the given item. + + :param Item3D item: + :return: Whether to process the item (True) or to skip it (False) + :rtype: bool + """ + return self._condition is None or self._condition(item) + + def getViewport(self): + """Returns viewport where picking occurs + + :rtype: ~silx.gui.plot3d.scene.Viewport + """ + return self._viewport + + def getWidgetPosition(self): + """Returns (x, y) position in pixel in the widget + + Origin is at the top-left corner of the widget, + X from left to right, Y goes downward. + + :rtype: List[int] + """ + return self._widgetPosition + + def setEnabled(self, enabled): + """Set whether picking is enabled or not + + :param bool enabled: True to enable picking, False otherwise + """ + self._enabled = bool(enabled) + + def isEnabled(self): + """Returns True if picking is currently enabled, False otherwise. + + :rtype: bool + """ + return self._enabled + + def setNDCZRange(self, near=-1., far=1.): + """Set near and far Z value in normalized device coordinates + + This allows to clip the ray to a subset of the NDC range + + :param float near: Near segment end point Z coordinate + :param float far: Far segment end point Z coordinate + """ + self._ndcZRange = near, far + + def getNDCPosition(self): + """Return Normalized device coordinates of picked point. + + :return: (x, y) in NDC coordinates or None if outside viewport. + :rtype: Union[None,List[float]] + """ + if not self.isEnabled(): + return None + + # Convert x, y from window to NDC + x, y = self.getWidgetPosition() + return self.getViewport().windowToNdc(x, y, checkInside=True) + + def getPickingSegment(self, frame): + """Returns picking segment in requested coordinate frame. + + :param Union[str,Base] frame: + The frame in which to get the picking segment, + either a keyword: 'ndc', 'camera', 'scene' or a scene + :class:`~silx.gui.plot3d.scene.Base` object. + :return: Near and far points of the segment as (x, y, z, w) + or None if picked point is outside viewport + :rtype: Union[None,numpy.ndarray] + """ + assert frame in ('ndc', 'camera', 'scene') or isinstance(frame, Base) + + positionNdc = self.getNDCPosition() + if positionNdc is None: + return None + + near, far = self._ndcZRange + rayNdc = numpy.array((positionNdc + (near, 1.), + positionNdc + (far, 1.)), + dtype=numpy.float64) + if frame == 'ndc': + return rayNdc + + viewport = self.getViewport() + + rayCamera = viewport.camera.intrinsic.transformPoints( + rayNdc, + direct=False, + perspectiveDivide=True) + if frame == 'camera': + return rayCamera + + rayScene = viewport.camera.extrinsic.transformPoints( + rayCamera, direct=False) + if frame == 'scene': + return rayScene + + # frame is a scene Base object + rayObject = frame.objectToSceneTransform.transformPoints( + rayScene, direct=False) + return rayObject + + +class PickingResult(object): + """Class to access picking information in a 3D scene. + """ + + def __init__(self, item, positions, indices=None, fetchdata=None): + """Init + + :param ~silx.gui.plot3d.items.Item3D item: The picked item + :param numpy.ndarray positions: + Nx3 array-like of picked positions (x, y, z) in item coordinates. + :param numpy.ndarray indices: Array-like of indices of picked data. + Either 1D or 2D with dim0: data dimension and dim1: indices. + No copy is made. + :param callable fetchdata: Optional function with a bool copy argument + to provide an alternative function to access item data. + Default is to use `item.getData`. + """ + self._item = item + self._objectPositions = numpy.array( + positions, copy=False, dtype=numpy.float) + + # Store matrices to generate positions on demand + primitive = item._getScenePrimitive() + self._objectToSceneTransform = primitive.objectToSceneTransform + self._objectToNDCTransform = primitive.objectToNDCTransform + self._scenePositions = None + self._ndcPositions = None + + if indices is None: + self._indices = None + else: + self._indices = numpy.array(indices, copy=False, dtype=numpy.int) + + self._fetchdata = fetchdata + + def getItem(self): + """Returns the item this results corresponds to. + + :rtype: ~silx.gui.plot3d.items.Item3D + """ + return self._item + + def getIndices(self, copy=True): + """Returns indices of picked data. + + If data is 1D, it returns a numpy.ndarray, otherwise + it returns a tuple with as many numpy.ndarray as there are + dimensions in the data. + + :param bool copy: True (default) to get a copy, + False to return internal arrays + :rtype: Union[None,numpy.ndarray,List[numpy.ndarray]] + """ + if self._indices is None: + return None + indices = numpy.array(self._indices, copy=copy) + return indices if indices.ndim == 1 else tuple(indices) + + def getData(self, copy=True): + """Returns picked data values + + :param bool copy: True (default) to get a copy, + False to return internal arrays + :rtype: Union[None,numpy.ndarray] + """ + + indices = self.getIndices(copy=False) + if indices is None or len(indices) == 0: + return None + + item = self.getItem() + if self._fetchdata is None: + if hasattr(item, 'getData'): + data = item.getData(copy=False) + else: + return None + else: + data = self._fetchdata(copy=False) + + return numpy.array(data[indices], copy=copy) + + def getPositions(self, frame='scene', copy=True): + """Returns picking positions in item coordinates. + + :param str frame: The frame in which the positions are returned + Either 'scene' for world space, + 'ndc' for normalized device coordinates or 'object' for item frame. + :param bool copy: True (default) to get a copy, + False to return internal arrays + :return: Nx3 array of (x, y, z) coordinates + :rtype: numpy.ndarray + """ + if frame == 'ndc': + if self._ndcPositions is None: # Lazy-loading + self._ndcPositions = self._objectToNDCTransform.transformPoints( + self._objectPositions, perspectiveDivide=True) + + positions = self._ndcPositions + + elif frame == 'scene': + if self._scenePositions is None: # Lazy-loading + self._scenePositions = self._objectToSceneTransform.transformPoints( + self._objectPositions) + + positions = self._scenePositions + + elif frame == 'object': + positions = self._objectPositions + + else: + raise ValueError('Unsupported frame argument: %s' % str(frame)) + + return numpy.array(positions, copy=copy) diff --git a/silx/gui/plot3d/items/clipplane.py b/silx/gui/plot3d/items/clipplane.py index a5ba0e6..3e819d0 100644 --- a/silx/gui/plot3d/items/clipplane.py +++ b/silx/gui/plot3d/items/clipplane.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2017 European Synchrotron Radiation Facility +# Copyright (c) 2017-2018 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 @@ -32,8 +32,11 @@ __license__ = "MIT" __date__ = "15/11/2017" -from ..scene import primitives +import numpy +from ..scene import primitives, utils + +from ._pick import PickingResult from .core import Item3D from .mixins import PlaneMixIn @@ -48,3 +51,86 @@ class ClipPlane(Item3D, PlaneMixIn): plane = primitives.ClipPlane() Item3D.__init__(self, parent=parent, primitive=plane) PlaneMixIn.__init__(self, plane=plane) + + def __pickPreProcessing(self, context): + """Common processing for :meth:`_pickPostProcess` and :meth:`_pickFull` + + :param PickContext context: Current picking context + :return None or (bounds, intersection points, rayObject) + """ + plane = self._getPlane() + planeParent = plane.parent + if planeParent is None: + return None + + rayObject = context.getPickingSegment(frame=plane) + if rayObject is None: + return None + + bounds = planeParent.bounds(dataBounds=True) + rayClip = utils.clipSegmentToBounds(rayObject[:, :3], bounds) + if rayClip is None: + return None # Ray is outside parent's bounding box + + points = utils.segmentPlaneIntersect( + rayObject[0, :3], + rayObject[1, :3], + planeNorm=self.getNormal(), + planePt=self.getPoint()) + + # A single intersection inside bounding box + picked = (len(points) == 1 and + numpy.all(bounds[0] <= points[0]) and + numpy.all(points[0] <= bounds[1])) + + return picked, points, rayObject + + def _pick(self, context): + # Perform picking before modifying context + result = super(ClipPlane, self)._pick(context) + + # Modify context if needed + if self.isVisible() and context.isEnabled(): + info = self.__pickPreProcessing(context) + if info is not None: + picked, points, rayObject = info + plane = self._getPlane() + + if picked: # A single intersection inside bounding box + # Clip NDC z range for following brother items + ndcIntersect = plane.objectToNDCTransform.transformPoint( + points[0], perspectiveDivide=True) + ndcNormal = plane.objectToNDCTransform.transformNormal( + self.getNormal()) + if ndcNormal[2] < 0: + context.setNDCZRange(-1., ndcIntersect[2]) + else: + context.setNDCZRange(ndcIntersect[2], 1.) + + else: + # TODO check this might not be correct + rayObject[:, 3] = 1. # Make sure 4h coordinate is one + if numpy.sum(rayObject[0] * self.getParameters()) < 0.: + # Disable picking for remaining brothers + context.setEnabled(False) + + return result + + def _pickFastCheck(self, context): + return True + + def _pickFull(self, context): + """Perform 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] + """ + info = self.__pickPreProcessing(context) + if info is not None: + picked, points, _ = info + + if picked: + return PickingResult(self, positions=[points[0]]) + + return None diff --git a/silx/gui/plot3d/items/core.py b/silx/gui/plot3d/items/core.py index e549e59..0aefced 100644 --- a/silx/gui/plot3d/items/core.py +++ b/silx/gui/plot3d/items/core.py @@ -41,6 +41,7 @@ from ... import qt from ...plot.items import ItemChangedType from .. import scene from ..scene import axes, primitives, transform +from ._pick import PickContext @enum.unique @@ -219,6 +220,53 @@ class Item3D(qt.QObject): 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 + class DataItem3D(Item3D): """Base class representing a data item with transform in the scene. @@ -256,12 +304,14 @@ class DataItem3D(Item3D): self._rotationCenter = 0., 0., 0. - self._getScenePrimitive().transforms = [ + self.__transforms = transform.TransformList([ self._translate, self._rotateForwardTranslation, self._rotate, self._rotateBackwardTranslation, - self._transformObjectToRotate] + self._transformObjectToRotate]) + + self._getScenePrimitive().transforms = self.__transforms def _updated(self, event=None): """Handle MixIn class updates. @@ -274,6 +324,13 @@ class DataItem3D(Item3D): # Transformations + def _getSceneTransforms(self): + """Return TransformList corresponding to current transforms + + :rtype: TransformList + """ + return self.__transforms + def setScale(self, sx=1., sy=1., sz=1.): """Set the scale of the item in the scene. @@ -452,7 +509,92 @@ class DataItem3D(Item3D): self._updated(Item3DChangedType.BOUNDING_BOX_VISIBLE) -class _BaseGroupItem(DataItem3D): +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) @@ -474,9 +616,16 @@ class _BaseGroupItem(DataItem3D): :param Union[GroupBBox, None] group: The scene group to use for rendering """ - DataItem3D.__init__(self, parent=parent, group=group) + 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 @@ -493,11 +642,11 @@ class _BaseGroupItem(DataItem3D): item.setParent(self) if index is None: - self._getScenePrimitive().children.append( + self._getGroupPrimitive().children.append( item._getScenePrimitive()) self._items.append(item) else: - self._getScenePrimitive().children.insert( + self._getGroupPrimitive().children.insert( index, item._getScenePrimitive()) self._items.insert(index, item) self.sigItemAdded.emit(item) @@ -518,7 +667,7 @@ class _BaseGroupItem(DataItem3D): if item not in self.getItems(): raise ValueError("Item3D not in group: %s" % str(item)) - self._getScenePrimitive().children.remove(item._getScenePrimitive()) + self._getGroupPrimitive().children.remove(item._getScenePrimitive()) self._items.remove(item) item.setParent(None) self.sigItemRemoved.emit(item) @@ -528,21 +677,6 @@ class _BaseGroupItem(DataItem3D): for item in self.getItems(): self.removeItem(item) - 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 - class GroupItem(_BaseGroupItem): """Group of items sharing a common transform.""" @@ -620,3 +754,26 @@ class GroupWithAxesItem(_BaseGroupItem): 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 diff --git a/silx/gui/plot3d/items/image.py b/silx/gui/plot3d/items/image.py index 9e8bf1e..210f2f3 100644 --- a/silx/gui/plot3d/items/image.py +++ b/silx/gui/plot3d/items/image.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2017 European Synchrotron Radiation Facility +# Copyright (c) 2017-2018 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 @@ -33,22 +33,72 @@ __date__ = "15/11/2017" import numpy -from ..scene import primitives +from ..scene import primitives, utils from .core import DataItem3D, ItemChangedType from .mixins import ColormapMixIn, InterpolationMixIn +from ._pick import PickingResult -class ImageData(DataItem3D, ColormapMixIn, InterpolationMixIn): - """Description of a 2D image data. +class _Image(DataItem3D, InterpolationMixIn): + """Base class for images :param parent: The View widget this item belongs to. """ def __init__(self, parent=None): DataItem3D.__init__(self, parent=parent) - ColormapMixIn.__init__(self) InterpolationMixIn.__init__(self) + def _setPrimitive(self, primitive): + InterpolationMixIn._setPrimitive(self, primitive) + + def getData(self, copy=True): + raise NotImplementedError() + + def _pickFull(self, context): + """Perform 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] + """ + rayObject = context.getPickingSegment(frame=self._getScenePrimitive()) + if rayObject is None: + return None + + points = utils.segmentPlaneIntersect( + rayObject[0, :3], + rayObject[1, :3], + planeNorm=numpy.array((0., 0., 1.), dtype=numpy.float64), + planePt=numpy.array((0., 0., 0.), dtype=numpy.float64)) + + if len(points) == 1: # Single intersection + if points[0][0] < 0. or points[0][1] < 0.: + return None # Outside image + row, column = int(points[0][1]), int(points[0][0]) + data = self.getData(copy=False) + height, width = data.shape[:2] + if row < height and column < width: + return PickingResult( + self, + positions=[(points[0][0], points[0][1], 0.)], + indices=([row], [column])) + else: + return None # Outside image + else: # Either no intersection or segment and image are coplanar + return None + + +class ImageData(_Image, ColormapMixIn): + """Description of a 2D image data. + + :param parent: The View widget this item belongs to. + """ + + def __init__(self, parent=None): + _Image.__init__(self, parent=parent) + ColormapMixIn.__init__(self) + self._data = numpy.zeros((0, 0), dtype=numpy.float32) self._image = primitives.ImageData(self._data) @@ -56,7 +106,7 @@ class ImageData(DataItem3D, ColormapMixIn, InterpolationMixIn): # Connect scene primitive to mix-in class ColormapMixIn._setSceneColormap(self, self._image.colormap) - InterpolationMixIn._setPrimitive(self, self._image) + _Image._setPrimitive(self, self._image) def setData(self, data, copy=True): """Set the image data to display. @@ -83,14 +133,14 @@ class ImageData(DataItem3D, ColormapMixIn, InterpolationMixIn): return self._image.getData(copy=copy) -class ImageRgba(DataItem3D, InterpolationMixIn): +class ImageRgba(_Image, InterpolationMixIn): """Description of a 2D data RGB(A) image. :param parent: The View widget this item belongs to. """ def __init__(self, parent=None): - DataItem3D.__init__(self, parent=parent) + _Image.__init__(self, parent=parent) InterpolationMixIn.__init__(self) self._data = numpy.zeros((0, 0, 3), dtype=numpy.float32) @@ -99,7 +149,7 @@ class ImageRgba(DataItem3D, InterpolationMixIn): self._getScenePrimitive().children.append(self._image) # Connect scene primitive to mix-in class - InterpolationMixIn._setPrimitive(self, self._image) + _Image._setPrimitive(self, self._image) def setData(self, data, copy=True): """Set the RGB(A) image data to display. diff --git a/silx/gui/plot3d/items/mesh.py b/silx/gui/plot3d/items/mesh.py index 12a3941..21936ea 100644 --- a/silx/gui/plot3d/items/mesh.py +++ b/silx/gui/plot3d/items/mesh.py @@ -29,13 +29,19 @@ from __future__ import absolute_import __authors__ = ["T. Vincent"] __license__ = "MIT" -__date__ = "15/11/2017" +__date__ = "17/07/2018" + +import logging import numpy -from ..scene import primitives -from .core import DataItem3D, ItemChangedType +from ..scene import primitives, utils from ..scene.transform import Rotate +from .core import DataItem3D, ItemChangedType +from ._pick import PickingResult + + +_logger = logging.getLogger(__name__) class Mesh(DataItem3D): @@ -56,11 +62,7 @@ class Mesh(DataItem3D): copy=True): """Set mesh geometry data. - Supported drawing modes are: - - - For points: 'points' - - For lines: 'lines', 'line_strip', 'loop' - - For triangles: 'triangles', 'triangle_strip', 'fan' + Supported drawing modes are: 'triangles', 'triangle_strip', 'fan' :param numpy.ndarray position: Position (x, y, z) of each vertex as a (N, 3) array @@ -73,7 +75,7 @@ class Mesh(DataItem3D): self._getScenePrimitive().children = [] # Remove any previous mesh if position is None or len(position) == 0: - self._mesh = 0 + self._mesh = None else: self._mesh = primitives.Mesh3D( position, color, normal, mode=mode, copy=copy) @@ -145,6 +147,72 @@ class Mesh(DataItem3D): """ return self._mesh.drawMode + 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] + """ + rayObject = context.getPickingSegment(frame=self._getScenePrimitive()) + if rayObject is None: # No picking outside viewport + return None + rayObject = rayObject[:, :3] + + positions = self.getPositionData(copy=False) + if positions.size == 0: + return None + + mode = self.getDrawMode() + if mode == 'triangles': + triangles = positions.reshape(-1, 3, 3) + + elif mode == 'triangle_strip': + # Expand strip + triangles = numpy.empty((len(positions) - 2, 3, 3), + dtype=positions.dtype) + triangles[:, 0] = positions[:-2] + triangles[:, 1] = positions[1:-1] + triangles[:, 2] = positions[2:] + + elif mode == 'fan': + # Expand fan + triangles = numpy.empty((len(positions) - 2, 3, 3), + dtype=positions.dtype) + triangles[:, 0] = positions[0] + triangles[:, 1] = positions[1:-1] + triangles[:, 2] = positions[2:] + + else: + _logger.warning("Unsupported draw mode: %s" % mode) + return None + + trianglesIndices, t, barycentric = utils.segmentTrianglesIntersection( + rayObject, triangles) + + if len(trianglesIndices) == 0: + return None + + points = t.reshape(-1, 1) * (rayObject[1] - rayObject[0]) + rayObject[0] + + # Get vertex index from triangle index and closest point in triangle + closest = numpy.argmax(barycentric, axis=1) + + if mode == 'triangles': + indices = trianglesIndices * 3 + closest + + elif mode == 'triangle_strip': + indices = trianglesIndices + closest + + elif mode == 'fan': + indices = trianglesIndices + closest # For corners 1 and 2 + indices[closest == 0] = 0 # For first corner (common) + + return PickingResult(self, + positions=points, + indices=indices, + fetchdata=self.getPositionData) + class _CylindricalVolume(DataItem3D): """Class that represents a volume with a rotational symmetry along z @@ -155,6 +223,18 @@ class _CylindricalVolume(DataItem3D): def __init__(self, parent=None): DataItem3D.__init__(self, parent=parent) self._mesh = None + self._nbFaces = 0 + + def getPosition(self, copy=True): + """Get primitive positions. + + :param bool copy: + True (default) to get a copy, + False to get internal representation (do not modify!). + :return: Position of the primitives as a (N, 3) array. + :rtype: numpy.ndarray + """ + raise NotImplementedError("Must be implemented in subclass") def _setData(self, position, radius, height, angles, color, flatFaces, rotation): @@ -173,30 +253,31 @@ class _CylindricalVolume(DataItem3D): self._getScenePrimitive().children = [] # Remove any previous mesh if position is None or len(position) == 0: - self._mesh = 0 + self._mesh = None + self._nbFaces = 0 else: + self._nbFaces = len(angles) - 1 + volume = numpy.empty(shape=(len(angles) - 1, 12, 3), dtype=numpy.float32) normal = numpy.empty(shape=(len(angles) - 1, 12, 3), dtype=numpy.float32) for i in range(0, len(angles) - 1): - """ - c6 - /\ - / \ - / \ - c4|------|c5 - | \ | - | \ | - | \ | - | \ | - c2|------|c3 - \ / - \ / - \/ - c1 - """ + # c6 + # /\ + # / \ + # / \ + # c4|------|c5 + # | \ | + # | \ | + # | \ | + # | \ | + # c2|------|c3 + # \ / + # \ / + # \/ + # c1 c1 = numpy.array([0, 0, -height/2]) c1 = rotation.transformPoint(c1) c2 = numpy.array([radius * numpy.cos(angles[i]), @@ -266,6 +347,49 @@ class _CylindricalVolume(DataItem3D): self.sigItemChanged.emit(ItemChangedType.DATA) + 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] + """ + if self._mesh is None or self._nbFaces == 0: + return None + + rayObject = context.getPickingSegment(frame=self._getScenePrimitive()) + if rayObject is None: # No picking outside viewport + return None + rayObject = rayObject[:, :3] + + positions = self._mesh.getAttribute('position', copy=False) + triangles = positions.reshape(-1, 3, 3) # 'triangle' draw mode + + trianglesIndices, t = utils.segmentTrianglesIntersection( + rayObject, triangles)[:2] + + if len(trianglesIndices) == 0: + return None + + # Get object index from triangle index + indices = trianglesIndices // (4 * self._nbFaces) + + # Select closest intersection point for each primitive + indices, firstIndices = numpy.unique(indices, return_index=True) + t = t[firstIndices] + + # Resort along t as result of numpy.unique is not sorted by t + sortedIndices = numpy.argsort(t) + t = t[sortedIndices] + indices = indices[sortedIndices] + + points = t.reshape(-1, 1) * (rayObject[1] - rayObject[0]) + rayObject[0] + + return PickingResult(self, + positions=points, + indices=indices, + fetchdata=self.getPosition) + class Box(_CylindricalVolume): """Description of a box. diff --git a/silx/gui/plot3d/items/scatter.py b/silx/gui/plot3d/items/scatter.py index 5eea455..a13c3db 100644 --- a/silx/gui/plot3d/items/scatter.py +++ b/silx/gui/plot3d/items/scatter.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2017 European Synchrotron Radiation Facility +# Copyright (c) 2017-2018 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 @@ -40,6 +40,7 @@ from ..scene import function, primitives, utils from .core import DataItem3D, Item3DChangedType, ItemChangedType from .mixins import ColormapMixIn, SymbolMixIn +from ._pick import PickingResult _logger = logging.getLevelName(__name__) @@ -116,7 +117,7 @@ class Scatter3D(DataItem3D, ColormapMixIn, SymbolMixIn): :return: X coordinates :rtype: numpy.ndarray """ - return self._scatter.getAttribute('x', copy=copy) + return self._scatter.getAttribute('x', copy=copy).reshape(-1) def getYData(self, copy=True): """Returns Y data coordinates. @@ -126,7 +127,7 @@ class Scatter3D(DataItem3D, ColormapMixIn, SymbolMixIn): :return: Y coordinates :rtype: numpy.ndarray """ - return self._scatter.getAttribute('y', copy=copy) + return self._scatter.getAttribute('y', copy=copy).reshape(-1) def getZData(self, copy=True): """Returns Z data coordinates. @@ -136,7 +137,7 @@ class Scatter3D(DataItem3D, ColormapMixIn, SymbolMixIn): :return: Z coordinates :rtype: numpy.ndarray """ - return self._scatter.getAttribute('z', copy=copy) + return self._scatter.getAttribute('z', copy=copy).reshape(-1) def getValues(self, copy=True): """Returns data values. @@ -146,7 +147,64 @@ class Scatter3D(DataItem3D, ColormapMixIn, SymbolMixIn): :return: data values :rtype: numpy.ndarray """ - return self._scatter.getAttribute('value', copy=copy) + return self._scatter.getAttribute('value', copy=copy).reshape(-1) + + def _pickFull(self, context, threshold=0., sort='depth'): + """Perform picking in this item at given widget position. + + :param PickContext context: Current picking context + :param float threshold: Picking threshold in pixel. + Perform picking in a square of size threshold x threshold. + :param str sort: How returned indices are sorted: + + - 'index' (default): sort by the value of the indices + - 'depth': Sort by the depth of the points from the current + camera point of view. + :return: Object holding the results or None + :rtype: Union[None,PickingResult] + """ + assert sort in ('index', 'depth') + + rayNdc = context.getPickingSegment(frame='ndc') + if rayNdc is None: # No picking outside viewport + return None + + # Project data to NDC + xData = self.getXData(copy=False) + if len(xData) == 0: # No data in the scatter + return None + + primitive = self._getScenePrimitive() + + dataPoints = numpy.transpose((xData, + self.getYData(copy=False), + self.getZData(copy=False), + numpy.ones_like(xData))) + + pointsNdc = primitive.objectToNDCTransform.transformPoints( + dataPoints, perspectiveDivide=True) + + # Perform picking + distancesNdc = numpy.abs(pointsNdc[:, :2] - rayNdc[0, :2]) + # TODO issue with symbol size: using pixel instead of points + threshold += self.getSymbolSize() + thresholdNdc = 2. * threshold / numpy.array(primitive.viewport.size) + picked = numpy.where(numpy.logical_and( + numpy.all(distancesNdc < thresholdNdc, axis=1), + numpy.logical_and(rayNdc[0, 2] <= pointsNdc[:, 2], + pointsNdc[:, 2] <= rayNdc[1, 2])))[0] + + if sort == 'depth': + # Sort picked points from front to back + picked = picked[numpy.argsort(pointsNdc[picked, 2])] + + if picked.size > 0: + return PickingResult(self, + positions=dataPoints[picked, :3], + indices=picked, + fetchdata=self.getValues) + else: + return None class Scatter2D(DataItem3D, ColormapMixIn, SymbolMixIn): @@ -373,6 +431,120 @@ class Scatter2D(DataItem3D, ColormapMixIn, SymbolMixIn): """ return numpy.array(self._value, copy=copy) + def _pickPoints(self, context, points, threshold=1., sort='depth'): + """Perform picking while in 'points' visualization mode + + :param PickContext context: Current picking context + :param float threshold: Picking threshold in pixel. + Perform picking in a square of size threshold x threshold. + :param str sort: How returned indices are sorted: + + - 'index' (default): sort by the value of the indices + - 'depth': Sort by the depth of the points from the current + camera point of view. + :return: Object holding the results or None + :rtype: Union[None,PickingResult] + """ + assert sort in ('index', 'depth') + + rayNdc = context.getPickingSegment(frame='ndc') + if rayNdc is None: # No picking outside viewport + return None + + # Project data to NDC + primitive = self._getScenePrimitive() + pointsNdc = primitive.objectToNDCTransform.transformPoints( + points, perspectiveDivide=True) + + # Perform picking + distancesNdc = numpy.abs(pointsNdc[:, :2] - rayNdc[0, :2]) + thresholdNdc = threshold / numpy.array(primitive.viewport.size) + picked = numpy.where(numpy.logical_and( + numpy.all(distancesNdc < thresholdNdc, axis=1), + numpy.logical_and(rayNdc[0, 2] <= pointsNdc[:, 2], + pointsNdc[:, 2] <= rayNdc[1, 2])))[0] + + if sort == 'depth': + # Sort picked points from front to back + picked = picked[numpy.argsort(pointsNdc[picked, 2])] + + if picked.size > 0: + return PickingResult(self, + positions=points[picked, :3], + indices=picked, + fetchdata=self.getValues) + else: + return None + + def _pickSolid(self, context, points): + """Perform picking while in 'solid' visualization mode + + :param PickContext context: Current picking context + """ + if self._cachedTrianglesIndices is None: + _logger.info("Picking on Scatter2D before rendering") + return None + + rayObject = context.getPickingSegment(frame=self._getScenePrimitive()) + if rayObject is None: # No picking outside viewport + return None + rayObject = rayObject[:, :3] + + trianglesIndices = self._cachedTrianglesIndices.reshape(-1, 3) + triangles = points[trianglesIndices, :3] + selectedIndices, t, barycentric = utils.segmentTrianglesIntersection( + rayObject, triangles) + closest = numpy.argmax(barycentric, axis=1) + + indices = trianglesIndices.reshape(-1, 3)[selectedIndices, closest] + + if len(indices) == 0: # No point is picked + return None + + # Compute intersection points and get closest data point + positions = t.reshape(-1, 1) * (rayObject[1] - rayObject[0]) + rayObject[0] + + return PickingResult(self, + positions=positions, + indices=indices, + fetchdata=self.getValues) + + def _pickFull(self, context): + """Perform 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] + """ + xData = self.getXData(copy=False) + if len(xData) == 0: # No data in the scatter + return None + + if self.isHeightMap(): + zData = self.getValues(copy=False) + else: + zData = numpy.zeros_like(xData) + + points = numpy.transpose((xData, + self.getYData(copy=False), + zData, + numpy.ones_like(xData))) + + mode = self.getVisualization() + if mode == 'points': + # TODO issue with symbol size: using pixel instead of points + # Get "corrected" symbol size + _, threshold = self._getSceneSymbol() + return self._pickPoints( + context, points, threshold=max(3., threshold)) + + elif mode == 'lines': + # Picking only at point + return self._pickPoints(context, points, threshold=5.) + + else: # mode == 'solid' + return self._pickSolid(context, points) + def _updateScene(self): self._getScenePrimitive().children = [] # Remove previous primitives diff --git a/silx/gui/plot3d/items/volume.py b/silx/gui/plot3d/items/volume.py index a7b5923..ca22f1f 100644 --- a/silx/gui/plot3d/items/volume.py +++ b/silx/gui/plot3d/items/volume.py @@ -41,10 +41,11 @@ from silx.math.marchingcubes import MarchingCubes from ... import qt from ...colors import rgba -from ..scene import cutplane, primitives, transform +from ..scene import cutplane, primitives, transform, utils -from .core import DataItem3D, Item3D, ItemChangedType, Item3DChangedType +from .core import BaseNodeItem, Item3D, ItemChangedType, Item3DChangedType from .mixins import ColormapMixIn, InterpolationMixIn, PlaneMixIn +from ._pick import PickingResult _logger = logging.getLogger(__name__) @@ -77,7 +78,8 @@ class CutPlane(Item3D, ColormapMixIn, InterpolationMixIn, PlaneMixIn): def _parentChanged(self, event): """Handle data change in the parent this plane belongs to""" if event == ItemChangedType.DATA: - self._getPlane().setData(self.sender().getData(), copy=False) + self._getPlane().setData(self.sender().getData(copy=False), + copy=False) # Store data range info as 3-tuple of values self._dataRange = self.sender().getDataRange() @@ -113,6 +115,53 @@ class CutPlane(Item3D, ColormapMixIn, InterpolationMixIn, PlaneMixIn): """ return self._dataRange + def getData(self, copy=True): + """Return 3D dataset. + + :param bool copy: + True (default) to get a copy, + False to get the internal data (DO NOT modify!) + :return: The data set (or None if not set) + """ + parent = self.parent() + return None if parent is None else parent.getData(copy=copy) + + def _pickFull(self, context): + """Perform 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] + """ + rayObject = context.getPickingSegment(frame=self._getScenePrimitive()) + if rayObject is None: + return None + + points = utils.segmentPlaneIntersect( + rayObject[0, :3], + rayObject[1, :3], + planeNorm=self.getNormal(), + planePt=self.getPoint()) + + if len(points) == 1: # Single intersection + if numpy.any(points[0] < 0.): + return None # Outside volume + z, y, x = int(points[0][2]), int(points[0][1]), int(points[0][0]) + + data = self.getData(copy=False) + if data is None: + return None # No dataset + + depth, height, width = data.shape + if z < depth and y < height and x < width: + return PickingResult(self, + positions=[points[0]], + indices=([z], [y], [x])) + else: + return None # Outside image + else: # Either no intersection or segment and image are coplanar + return None + class Isosurface(Item3D): """Class representing an iso-surface in a :class:`ScalarField3D` item. @@ -122,24 +171,28 @@ class Isosurface(Item3D): def __init__(self, parent): Item3D.__init__(self, parent=parent) + assert isinstance(parent, ScalarField3D) + parent.sigItemChanged.connect(self._scalarField3DChanged) self._level = float('nan') self._autoLevelFunction = None self._color = rgba('#FFD700FF') - self._data = None + self._updateScenePrimitive() - # TODO register to ScalarField3D signal instead? - def _setData(self, data, copy=True): - """Set the data set from which to build the iso-surface. + def _scalarField3DChanged(self, event): + """Handle parent's ScalarField3D sigItemChanged""" + if event == ItemChangedType.DATA: + self._updateScenePrimitive() - :param numpy.ndarray data: The 3D data set or None - :param bool copy: True to make a copy, False to use as is if possible - """ - if data is None: - self._data = None - else: - self._data = numpy.array(data, copy=copy, order='C') + def getData(self, copy=True): + """Return 3D dataset. - self._updateScenePrimitive() + :param bool copy: + True (default) to get a copy, + False to get the internal data (DO NOT modify!) + :return: The data set (or None if not set) + """ + parent = self.parent() + return None if parent is None else parent.getData(copy=copy) def getLevel(self): """Return the level of this iso-surface (float)""" @@ -203,7 +256,9 @@ class Isosurface(Item3D): """Update underlying mesh""" self._getScenePrimitive().children = [] - if self._data is None: + data = self.getData(copy=False) + + if data is None: if self.isAutoLevel(): self._level = float('nan') @@ -211,7 +266,7 @@ class Isosurface(Item3D): if self.isAutoLevel(): st = time.time() try: - level = float(self.getAutoLevelFunction()(self._data)) + level = float(self.getAutoLevelFunction()(data)) except Exception: module_ = self.getAutoLevelFunction().__module__ @@ -236,7 +291,7 @@ class Isosurface(Item3D): st = time.time() vertices, normals, indices = MarchingCubes( - self._data, + data, isolevel=self._level) _logger.info('Computed iso-surface in %f s.', time.time() - st) @@ -250,15 +305,73 @@ class Isosurface(Item3D): indices=indices) self._getScenePrimitive().children = [mesh] + def _pickFull(self, context): + """Perform 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] + """ + rayObject = context.getPickingSegment(frame=self._getScenePrimitive()) + if rayObject is None: + return None + rayObject = rayObject[:, :3] + + data = self.getData(copy=False) + bins = utils.segmentVolumeIntersect( + rayObject, numpy.array(data.shape) - 1) + if bins is None: + return None -class ScalarField3D(DataItem3D): + # gather bin data + offsets = [(i, j, k) for i in (0, 1) for j in (0, 1) for k in (0, 1)] + indices = bins[:, numpy.newaxis, :] + offsets + binsData = data[indices[:, :, 0], indices[:, :, 1], indices[:, :, 2]] + # binsData.shape = nbins, 8 + # TODO up-to this point everything can be done once for all isosurfaces + + # check bin candidates + level = self.getLevel() + mask = numpy.logical_and(numpy.nanmin(binsData, axis=1) <= level, + level <= numpy.nanmax(binsData, axis=1)) + bins = bins[mask] + binsData = binsData[mask] + + if len(bins) == 0: + return None # No bin candidate + + # do picking on candidates + intersections = [] + depths = [] + for currentBin, data in zip(bins, binsData): + mc = MarchingCubes(data.reshape(2, 2, 2), isolevel=level) + points = mc.get_vertices() + currentBin + triangles = points[mc.get_indices()] + t = utils.segmentTrianglesIntersection(rayObject, triangles)[1] + t = numpy.unique(t) # Duplicates happen on triangle edges + if len(t) != 0: + # Compute intersection points and get closest data point + points = t.reshape(-1, 1) * (rayObject[1] - rayObject[0]) + rayObject[0] + # Get closest data points by rounding to int + intersections.extend(points) + depths.extend(t) + + if len(intersections) == 0: + return None # No intersected triangles + + intersections = numpy.array(intersections)[numpy.argsort(depths)] + indices = numpy.transpose(numpy.round(intersections).astype(numpy.int)) + return PickingResult(self, positions=intersections, indices=indices) + + +class ScalarField3D(BaseNodeItem): """3D scalar field on a regular grid. :param parent: The View widget this item belongs to. """ def __init__(self, parent=None): - DataItem3D.__init__(self, parent=parent) + BaseNodeItem.__init__(self, parent=parent) # Gives this item the shape of the data, no matter # of the isosurface/cut plane size @@ -327,10 +440,6 @@ class ScalarField3D(DataItem3D): self._boundedGroup.shape = self._data.shape - # Update iso-surfaces - for isosurface in self.getIsosurfaces(): - isosurface._setData(self._data, copy=False) - self._updated(ItemChangedType.DATA) def getData(self, copy=True): @@ -401,7 +510,6 @@ class ScalarField3D(DataItem3D): isosurface.setAutoLevelFunction(level) else: isosurface.setLevel(level) - isosurface._setData(self._data, copy=False) isosurface.sigItemChanged.connect(self._isosurfaceItemChanged) self._isosurfaces.append(isosurface) @@ -448,16 +556,11 @@ class ScalarField3D(DataItem3D): key=lambda isosurface: - isosurface.getLevel()) self._isogroup.children = [iso._getScenePrimitive() for iso in sortedIso] - def visit(self, included=True): - """Generator visiting the ScalarField3D content. + # BaseNodeItem - It first access cut planes and then isosurface + def getItems(self): + """Returns the list of items currently present in the ScalarField3D. - :param bool included: True (default) to include self in visit + :rtype: tuple """ - if included: - yield self - for cutPlane in self.getCutPlanes(): - yield cutPlane - for isosurface in self.getIsosurfaces(): - yield isosurface + return self.getCutPlanes() + self.getIsosurfaces() diff --git a/silx/gui/plot3d/scene/event.py b/silx/gui/plot3d/scene/event.py index 7b85434..98f8f8b 100644 --- a/silx/gui/plot3d/scene/event.py +++ b/silx/gui/plot3d/scene/event.py @@ -28,7 +28,7 @@ from __future__ import absolute_import, division, unicode_literals __authors__ = ["T. Vincent"] __license__ = "MIT" -__date__ = "25/07/2016" +__date__ = "17/07/2018" import logging @@ -66,7 +66,7 @@ class Notifier(object): try: self._listeners.remove(listener) except ValueError: - _logger.warn('Trying to remove a listener that is not registered') + _logger.warning('Trying to remove a listener that is not registered') def notify(self, *args, **kwargs): """Notify all registered listeners with the given parameters. diff --git a/silx/gui/plot3d/scene/function.py b/silx/gui/plot3d/scene/function.py index ba4c4ca..2921d48 100644 --- a/silx/gui/plot3d/scene/function.py +++ b/silx/gui/plot3d/scene/function.py @@ -28,7 +28,7 @@ from __future__ import absolute_import, division, unicode_literals __authors__ = ["T. Vincent"] __license__ = "MIT" -__date__ = "08/11/2016" +__date__ = "17/07/2018" import contextlib @@ -428,7 +428,7 @@ class Colormap(event.Notifier, ProgramFunction): range_ = float(range_[0]), float(range_[1]) if self.norm == 'log' and (range_[0] <= 0. or range_[1] <= 0.): - _logger.warn( + _logger.warning( "Log normalization and negative range: updating range.") minPos = numpy.finfo(numpy.float32).tiny range_ = max(range_[0], minPos), max(range_[1], minPos) diff --git a/silx/gui/plot3d/scene/primitives.py b/silx/gui/plot3d/scene/primitives.py index af00b6d..474581a 100644 --- a/silx/gui/plot3d/scene/primitives.py +++ b/silx/gui/plot3d/scene/primitives.py @@ -201,7 +201,7 @@ class Geometry(core.Elem): array = self._glReadyArray(array, copy=copy) if name not in self._ATTR_INFO: - _logger.info('Not checking attribute %s dimensions', name) + _logger.debug('Not checking attribute %s dimensions', name) else: checks = self._ATTR_INFO[name] diff --git a/silx/gui/plot3d/scene/transform.py b/silx/gui/plot3d/scene/transform.py index 4061e81..1b82397 100644 --- a/silx/gui/plot3d/scene/transform.py +++ b/silx/gui/plot3d/scene/transform.py @@ -305,6 +305,44 @@ class Transform(event.Notifier): # Multiplication with vectors + def transformPoints(self, points, direct=True, perspectiveDivide=False): + """Apply the transform to an array of points. + + :param points: 2D array of N vectors of 3 or 4 coordinates + :param bool direct: Whether to apply the direct (True, the default) + or inverse (False) transform. + :param bool perspectiveDivide: Whether to apply the perspective divide + (True) or not (False, the default). + :return: The transformed points. + :rtype: numpy.ndarray of same shape as points. + """ + if direct: + matrix = self.getMatrix(copy=False) + else: + matrix = self.getInverseMatrix(copy=False) + + points = numpy.array(points, copy=False) + assert points.ndim == 2 + + points = numpy.transpose(points) + + dimension = points.shape[0] + assert dimension in (3, 4) + + if dimension == 3: # Add 4th coordinate + points = numpy.append( + points, + numpy.ones((1, points.shape[1]), dtype=points.dtype), + axis=0) + + result = numpy.transpose(numpy.dot(matrix, points)) + + if perspectiveDivide: + mask = result[:, 3] != 0. + result[mask] /= result[mask, 3][:, numpy.newaxis] + + return result[:, :3] if dimension == 3 else result + @staticmethod def _prepareVector(vector, w): """Add 4th coordinate (w) to vector if missing.""" @@ -317,8 +355,6 @@ class Transform(event.Notifier): def transformPoint(self, point, direct=True, perspectiveDivide=False): """Apply the transform to a point. - If len(point) == 3, apply perspective divide if possible. - :param point: Array-like vector of 3 or 4 coordinates. :param bool direct: Whether to apply the direct (True, the default) or inverse (False) transform. @@ -373,7 +409,7 @@ class Transform(event.Notifier): _CUBE_CORNERS = numpy.array(list(itertools.product((0., 1.), repeat=3)), dtype=numpy.float32) - """Unit cube corners used by :meth:`transformRectangularBox`""" + """Unit cube corners used by :meth:`transformBounds`""" def transformBounds(self, bounds, direct=True): """Apply the transform to an axes-aligned rectangular box. diff --git a/silx/gui/plot3d/scene/utils.py b/silx/gui/plot3d/scene/utils.py index 3752289..1224f5e 100644 --- a/silx/gui/plot3d/scene/utils.py +++ b/silx/gui/plot3d/scene/utils.py @@ -435,6 +435,186 @@ def boxPlaneIntersect(boxVertices, boxLineIndices, planeNorm, planePt): return points +def clipSegmentToBounds(segment, bounds): + """Clip segment to volume aligned with axes. + + :param numpy.ndarray segment: (p0, p1) + :param numpy.ndarray bounds: (lower corner, upper corner) + :return: Either clipped (p0, p1) or None if outside volume + :rtype: Union[None,List[numpy.ndarray]] + """ + segment = numpy.array(segment, copy=False) + bounds = numpy.array(bounds, copy=False) + + p0, p1 = segment + # Get intersection points of ray with volume boundary planes + # Line equation: P = offset * delta + p0 + delta = p1 - p0 + deltaNotZero = numpy.array(delta, copy=True) + deltaNotZero[deltaNotZero == 0] = numpy.nan # Invalidated to avoid division by zero + offsets = ((bounds - p0) / deltaNotZero).reshape(-1) + points = offsets.reshape(-1, 1) * delta + p0 + + # Avoid precision errors by using bounds value + points.shape = 2, 3, 3 # Reshape 1 point per bound value + for dim in range(3): + points[:, dim, dim] = bounds[:, dim] + points.shape = -1, 3 # Set back to 2D array + + # Find intersection points that are included in the volume + mask = numpy.logical_and(numpy.all(bounds[0] <= points, axis=1), + numpy.all(points <= bounds[1], axis=1)) + intersections = numpy.unique(offsets[mask]) + if len(intersections) != 2: + return None + + intersections.sort() + # Do p1 first as p0 is need to compute it + if intersections[1] < 1: # clip p1 + segment[1] = intersections[1] * delta + p0 + if intersections[0] > 0: # clip p0 + segment[0] = intersections[0] * delta + p0 + return segment + + +def segmentVolumeIntersect(segment, nbins): + """Get bin indices intersecting with segment + + It should work with N dimensions. + Coordinate convention (z, y, x) or (x, y, z) should not matter + as long as segment and nbins are consistent. + + :param numpy.ndarray segment: + Segment end points as a 2xN array of coordinates + :param numpy.ndarray nbins: + Shape of the volume with same coordinates order as segment + :return: List of bins indices as a 2D array or None if no bins + :rtype: Union[None,numpy.ndarray] + """ + segment = numpy.asarray(segment) + nbins = numpy.asarray(nbins) + + assert segment.ndim == 2 + assert segment.shape[0] == 2 + assert nbins.ndim == 1 + assert segment.shape[1] == nbins.size + + dim = len(nbins) + + bounds = numpy.array((numpy.zeros_like(nbins), nbins)) + segment = clipSegmentToBounds(segment, bounds) + if segment is None: + return None # Segment outside volume + p0, p1 = segment + + # Get intersections + + # Get coordinates of bin edges crossing the segment + clipped = numpy.ceil(numpy.clip(segment, 0, nbins)) + start = numpy.min(clipped, axis=0) + stop = numpy.max(clipped, axis=0) # stop is NOT included + edgesByDim = [numpy.arange(start[i], stop[i]) for i in range(dim)] + + # Line equation: P = t * delta + p0 + delta = p1 - p0 + + # Get bin edge/line intersections as sorted points along the line + # Get corresponding line parameters + t = [] + if numpy.all(0 <= p0) and numpy.all(p0 <= nbins): + t.append([0.]) # p0 within volume, add it + t += [(edgesByDim[i] - p0[i]) / delta[i] for i in range(dim) if delta[i] != 0] + if numpy.all(0 <= p1) and numpy.all(p1 <= nbins): + t.append([1.]) # p1 within volume, add it + t = numpy.concatenate(t) + t.sort(kind='mergesort') + + # Remove duplicates + unique = numpy.ones((len(t),), dtype=bool) + numpy.not_equal(t[1:], t[:-1], out=unique[1:]) + t = t[unique] + + if len(t) < 2: + return None # Not enough intersection points + + # bin edges/line intersection points + points = t.reshape(-1, 1) * delta + p0 + centers = (points[:-1] + points[1:]) / 2. + bins = numpy.floor(centers).astype(numpy.int) + return bins + + +def segmentTrianglesIntersection(segment, triangles): + """Check for segment/triangles intersection. + + This is based on signed tetrahedron volume comparison. + + See A. Kensler, A., Shirley, P. + Optimizing Ray-Triangle Intersection via Automated Search. + Symposium on Interactive Ray Tracing, vol. 0, p33-38 (2006) + + :param numpy.ndarray segment: + Segment end points as a 2x3 array of coordinates + :param numpy.ndarray triangles: + Nx3x3 array of triangles + :return: (triangle indices, segment parameter, barycentric coord) + Indices of intersected triangles, "depth" along the segment + of the intersection point and barycentric coordinates of intersection + point in the triangle. + :rtype: List[numpy.ndarray] + """ + # TODO triangles from vertices + indices + # TODO early rejection? e.g., check segment bbox vs triangle bbox + segment = numpy.asarray(segment) + assert segment.ndim == 2 + assert segment.shape == (2, 3) + + triangles = numpy.asarray(triangles) + assert triangles.ndim == 3 + assert triangles.shape[1] == 3 + + # Test line/triangles intersection + d = segment[1] - segment[0] + t0s0 = segment[0] - triangles[:, 0, :] + edge01 = triangles[:, 1, :] - triangles[:, 0, :] + edge02 = triangles[:, 2, :] - triangles[:, 0, :] + + dCrossEdge02 = numpy.cross(d, edge02) + t0s0CrossEdge01 = numpy.cross(t0s0, edge01) + volume = numpy.sum(dCrossEdge02 * edge01, axis=1) + del edge01 + subVolumes = numpy.empty((len(triangles), 3), dtype=triangles.dtype) + subVolumes[:, 1] = numpy.sum(dCrossEdge02 * t0s0, axis=1) + del dCrossEdge02 + subVolumes[:, 2] = numpy.sum(t0s0CrossEdge01 * d, axis=1) + subVolumes[:, 0] = volume - subVolumes[:, 1] - subVolumes[:, 2] + intersect = numpy.logical_or( + numpy.all(subVolumes >= 0., axis=1), # All positive + numpy.all(subVolumes <= 0., axis=1)) # All negative + intersect = numpy.where(intersect)[0] # Indices of intersected triangles + + # Get barycentric coordinates + barycentric = subVolumes[intersect] / volume[intersect].reshape(-1, 1) + del subVolumes + + # Test segment/triangles intersection + volAlpha = numpy.sum(t0s0CrossEdge01[intersect] * edge02[intersect], axis=1) + t = volAlpha / volume[intersect] # segment parameter of intersected triangles + del t0s0CrossEdge01 + del edge02 + del volAlpha + del volume + + inSegmentMask = numpy.logical_and(t >= 0., t <= 1.) + intersect = intersect[inSegmentMask] + t = t[inSegmentMask] + barycentric = barycentric[inSegmentMask] + + # Sort intersecting triangles by t + indices = numpy.argsort(t) + return intersect[indices], t[indices], barycentric[indices] + + # Plane ####################################################################### class Plane(event.Notifier): diff --git a/silx/gui/plot3d/setup.py b/silx/gui/plot3d/setup.py index c477919..59c0230 100644 --- a/silx/gui/plot3d/setup.py +++ b/silx/gui/plot3d/setup.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2015-2017 European Synchrotron Radiation Facility +# Copyright (c) 2015-2018 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 @@ -36,7 +36,9 @@ def configuration(parent_package='', top_path=None): config.add_subpackage('actions') config.add_subpackage('items') config.add_subpackage('scene') + config.add_subpackage('scene.test') config.add_subpackage('tools') + config.add_subpackage('tools.test') config.add_subpackage('test') config.add_subpackage('utils') return config diff --git a/silx/gui/plot3d/test/__init__.py b/silx/gui/plot3d/test/__init__.py index bd2f7c3..c58f307 100644 --- a/silx/gui/plot3d/test/__init__.py +++ b/silx/gui/plot3d/test/__init__.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2015-2017 European Synchrotron Radiation Facility +# Copyright (c) 2015-2018 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 @@ -30,7 +30,6 @@ __date__ = "09/11/2017" import logging -import os import unittest from silx.test.utils import test_options @@ -39,7 +38,7 @@ _logger = logging.getLogger(__name__) def suite(): - test_suite = unittest.TestSuite() + testsuite = unittest.TestSuite() if not test_options.WITH_GL_TEST: # Explicitly disabled tests @@ -50,17 +49,21 @@ def suite(): def runTest(self): self.skipTest(test_options.WITH_GL_TEST_REASON) - test_suite.addTest(SkipPlot3DTest()) - return test_suite + testsuite.addTest(SkipPlot3DTest()) + return testsuite # Import here to avoid loading modules if tests are disabled - from ..scene import test as test_scene + from ..scene.test import suite as sceneTestSuite + from ..tools.test import suite as toolsTestSuite from .testGL import suite as testGLSuite from .testScalarFieldView import suite as testScalarFieldViewSuite + from .testSceneWidgetPicking import suite as testSceneWidgetPickingSuite - test_suite = unittest.TestSuite() - test_suite.addTest(testGLSuite()) - test_suite.addTest(test_scene.suite()) - test_suite.addTest(testScalarFieldViewSuite()) - return test_suite + testsuite = unittest.TestSuite() + testsuite.addTest(testGLSuite()) + testsuite.addTest(sceneTestSuite()) + testsuite.addTest(testScalarFieldViewSuite()) + testsuite.addTest(testSceneWidgetPickingSuite()) + testsuite.addTest(toolsTestSuite()) + return testsuite diff --git a/silx/gui/plot3d/test/testGL.py b/silx/gui/plot3d/test/testGL.py index 70f197f..ae167ab 100644 --- a/silx/gui/plot3d/test/testGL.py +++ b/silx/gui/plot3d/test/testGL.py @@ -32,7 +32,7 @@ import logging import unittest from silx.gui._glutils import gl, OpenGLWidget -from silx.gui.test.utils import TestCaseQt +from silx.gui.utils.testutils import TestCaseQt from silx.gui import qt diff --git a/silx/gui/plot3d/test/testScalarFieldView.py b/silx/gui/plot3d/test/testScalarFieldView.py index 43d401f..d9c743b 100644 --- a/silx/gui/plot3d/test/testScalarFieldView.py +++ b/silx/gui/plot3d/test/testScalarFieldView.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2017 European Synchrotron Radiation Facility +# Copyright (c) 2017-2018 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 @@ -34,7 +34,7 @@ import unittest import numpy from silx.utils.testutils import ParametricTestCase -from silx.gui.test.utils import TestCaseQt +from silx.gui.utils.testutils import TestCaseQt from silx.gui import qt from silx.gui.plot3d.ScalarFieldView import ScalarFieldView @@ -52,6 +52,13 @@ class TestScalarFieldView(TestCaseQt, ParametricTestCase): self.widget = ScalarFieldView() self.widget.show() + paramTreeWidget = TreeView() + paramTreeWidget.setSfView(self.widget) + + dock = qt.QDockWidget() + dock.setWidget(paramTreeWidget) + self.widget.addDockWidget(qt.Qt.BottomDockWidgetArea, dock) + # Commented as it slows down the tests # self.qWaitForWindowExposed(self.widget) @@ -102,6 +109,24 @@ class TestScalarFieldView(TestCaseQt, ParametricTestCase): self.widget.setData(data, copy=True) self.qapp.processEvents() + def testIsoSliderNormalization(self): + """Test set TreeView with a different isoslider normalization""" + data = self._buildData(size=32) + + self.widget.setData(data) + self.widget.addIsosurface(0.5, (1., 0., 0., 0.5)) + self.widget.addIsosurface(0.7, qt.QColor('green')) + self.qapp.processEvents() + + # Add a second TreeView + paramTreeWidget = TreeView(self.widget) + paramTreeWidget.setIsoLevelSliderNormalization('arcsinh') + paramTreeWidget.setSfView(self.widget) + + dock = qt.QDockWidget() + dock.setWidget(paramTreeWidget) + self.widget.addDockWidget(qt.Qt.BottomDockWidgetArea, dock) + def suite(): test_suite = unittest.TestSuite() diff --git a/silx/gui/plot3d/test/testSceneWidgetPicking.py b/silx/gui/plot3d/test/testSceneWidgetPicking.py new file mode 100644 index 0000000..d0c6467 --- /dev/null +++ b/silx/gui/plot3d/test/testSceneWidgetPicking.py @@ -0,0 +1,267 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2018 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. +# ###########################################################################*/ +"""Test SceneWidget picking feature""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "03/10/2018" + + +import unittest + +import numpy + +from silx.utils.testutils import ParametricTestCase +from silx.gui.utils.testutils import TestCaseQt +from silx.gui import qt + +from silx.gui.plot3d.SceneWidget import SceneWidget, items + + +class TestSceneWidgetPicking(TestCaseQt, ParametricTestCase): + """Tests SceneWidget picking feature""" + + def setUp(self): + super(TestSceneWidgetPicking, self).setUp() + self.widget = SceneWidget() + self.widget.resize(300, 300) + self.widget.show() + # self.qWaitForWindowExposed(self.widget) + + def tearDown(self): + self.qapp.processEvents() + self.widget.setAttribute(qt.Qt.WA_DeleteOnClose) + self.widget.close() + del self.widget + super(TestSceneWidgetPicking, self).tearDown() + + def _widgetCenter(self): + """Returns widget center""" + size = self.widget.size() + return size.width() // 2, size.height() // 2 + + def testPickImage(self): + """Test picking of ImageData and ImageRgba items""" + imageData = items.ImageData() + imageData.setData(numpy.arange(100).reshape(10, 10)) + + imageRgba = items.ImageRgba() + imageRgba.setData( + numpy.arange(300, dtype=numpy.uint8).reshape(10, 10, 3)) + + for item in (imageData, imageRgba): + with self.subTest(item=item.__class__.__name__): + # Add item + self.widget.clearItems() + self.widget.addItem(item) + self.widget.resetZoom('front') + self.qapp.processEvents() + + # Picking on data (at widget center) + picking = list(self.widget.pickItems(*self._widgetCenter())) + + self.assertEqual(len(picking), 1) + self.assertIs(picking[0].getItem(), item) + self.assertEqual(picking[0].getPositions('ndc').shape, (1, 3)) + data = picking[0].getData() + self.assertEqual(len(data), 1) + self.assertTrue(numpy.array_equal( + data, + item.getData()[picking[0].getIndices()])) + + # Picking outside data + picking = list(self.widget.pickItems(1, 1)) + self.assertEqual(len(picking), 0) + + def testPickScatter(self): + """Test picking of Scatter2D and Scatter3D items""" + data = numpy.arange(100) + + scatter2d = items.Scatter2D() + scatter2d.setData(x=data, y=data, value=data) + + scatter3d = items.Scatter3D() + scatter3d.setData(x=data, y=data, z=data, value=data) + + for item in (scatter2d, scatter3d): + with self.subTest(item=item.__class__.__name__): + # Add item + self.widget.clearItems() + self.widget.addItem(item) + self.widget.resetZoom('front') + self.qapp.processEvents() + + # Picking on data (at widget center) + picking = list(self.widget.pickItems(*self._widgetCenter())) + + self.assertEqual(len(picking), 1) + self.assertIs(picking[0].getItem(), item) + nbPos = len(picking[0].getPositions('ndc')) + data = picking[0].getData() + self.assertEqual(nbPos, len(data)) + self.assertTrue(numpy.array_equal( + data, + item.getValues()[picking[0].getIndices()])) + + # Picking outside data + picking = list(self.widget.pickItems(1, 1)) + self.assertEqual(len(picking), 0) + + def testPickScalarField3D(self): + """Test picking of volume CutPlane and Isosurface items""" + volume = self.widget.add3DScalarField( + numpy.arange(10**3, dtype=numpy.float32).reshape(10, 10, 10)) + self.widget.resetZoom('front') + + cutplane = volume.getCutPlanes()[0] + cutplane.getColormap().setVRange(0, 100) + cutplane.setNormal((0, 0, 1)) + + # Picking on data without anything displayed + cutplane.setVisible(False) + picking = list(self.widget.pickItems(*self._widgetCenter())) + self.assertEqual(len(picking), 0) + + # Picking on data with the cut plane + cutplane.setVisible(True) + picking = list(self.widget.pickItems(*self._widgetCenter())) + + self.assertEqual(len(picking), 1) + self.assertIs(picking[0].getItem(), cutplane) + data = picking[0].getData() + self.assertEqual(len(data), 1) + self.assertEqual(picking[0].getPositions().shape, (1, 3)) + self.assertTrue(numpy.array_equal( + data, + volume.getData(copy=False)[picking[0].getIndices()])) + + # Picking on data with an isosurface + isosurface = volume.addIsosurface(level=500, color=(1., 0., 0., .5)) + picking = list(self.widget.pickItems(*self._widgetCenter())) + self.assertEqual(len(picking), 2) + self.assertIs(picking[0].getItem(), cutplane) + self.assertIs(picking[1].getItem(), isosurface) + self.assertEqual(picking[1].getPositions().shape, (1, 3)) + data = picking[1].getData() + self.assertEqual(len(data), 1) + self.assertTrue(numpy.array_equal( + data, + volume.getData(copy=False)[picking[1].getIndices()])) + + # Picking outside data + picking = list(self.widget.pickItems(1, 1)) + self.assertEqual(len(picking), 0) + + def testPickMesh(self): + """Test picking of Mesh items""" + + triangles = items.Mesh() + triangles.setData( + position=((0, 0, 0), (1, 0, 0), (1, 1, 0), + (0, 0, 0), (1, 1, 0), (0, 1, 0)), + color=(1, 0, 0, 1), + mode='triangles') + triangleStrip = items.Mesh() + triangleStrip.setData( + position=(((1, 0, 0), (0, 0, 0), (1, 1, 0), (0, 1, 0))), + color=(0, 1, 0, 1), + mode='triangle_strip') + triangleFan = items.Mesh() + triangleFan.setData( + position=((0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)), + color=(0, 0, 1, 1), + mode='fan') + + for item in (triangles, triangleStrip, triangleFan): + with self.subTest(mode=item.getDrawMode()): + # Add item + self.widget.clearItems() + self.widget.addItem(item) + self.widget.resetZoom('front') + self.qapp.processEvents() + + # Picking on data (at widget center) + picking = list(self.widget.pickItems(*self._widgetCenter())) + + self.assertEqual(len(picking), 1) + self.assertIs(picking[0].getItem(), item) + nbPos = len(picking[0].getPositions()) + data = picking[0].getData() + self.assertEqual(nbPos, len(data)) + self.assertTrue(numpy.array_equal( + data, + item.getPositionData()[picking[0].getIndices()])) + + # Picking outside data + picking = list(self.widget.pickItems(1, 1)) + self.assertEqual(len(picking), 0) + + def testPickCylindricalMesh(self): + """Test picking of Box, Cylinder and Hexagon items""" + + positions = numpy.array(((0., 0., 0.), (1., 1., 0.), (2., 2., 0.))) + box = items.Box() + box.setData(position=positions) + cylinder = items.Cylinder() + cylinder.setData(position=positions) + hexagon = items.Hexagon() + hexagon.setData(position=positions) + + for item in (box, cylinder, hexagon): + with self.subTest(item=item.__class__.__name__): + # Add item + self.widget.clearItems() + self.widget.addItem(item) + self.widget.resetZoom('front') + self.qapp.processEvents() + + # Picking on data (at widget center) + picking = list(self.widget.pickItems(*self._widgetCenter())) + + self.assertEqual(len(picking), 1) + self.assertIs(picking[0].getItem(), item) + nbPos = len(picking[0].getPositions()) + data = picking[0].getData() + print(item.__class__.__name__, [positions[1]], data) + self.assertTrue(numpy.all(numpy.equal(positions[1], data))) + self.assertEqual(nbPos, len(data)) + self.assertTrue(numpy.array_equal( + data, + item.getPosition()[picking[0].getIndices()])) + + # Picking outside data + picking = list(self.widget.pickItems(1, 1)) + self.assertEqual(len(picking), 0) + + +def suite(): + testsuite = unittest.TestSuite() + testsuite.addTest( + unittest.defaultTestLoader.loadTestsFromTestCase( + TestSceneWidgetPicking)) + return testsuite + + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff --git a/silx/gui/plot3d/tools/PositionInfoWidget.py b/silx/gui/plot3d/tools/PositionInfoWidget.py new file mode 100644 index 0000000..b4d2c05 --- /dev/null +++ b/silx/gui/plot3d/tools/PositionInfoWidget.py @@ -0,0 +1,209 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2018 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 that displays data values of a SceneWidget. +""" + +from __future__ import absolute_import + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "01/10/2018" + + +import logging +import weakref + +from ... import qt +from .. import items +from ..items import volume +from ..SceneWidget import SceneWidget + + +_logger = logging.getLogger(__name__) + + +class PositionInfoWidget(qt.QWidget): + """Widget displaying information about picked position + + :param QWidget parent: See :class:`QWidget` + """ + + def __init__(self, parent=None): + super(PositionInfoWidget, self).__init__(parent) + self._sceneWidgetRef = None + + self.setToolTip("Double-click on a data point to show its value") + layout = qt.QBoxLayout(qt.QBoxLayout.LeftToRight, self) + + self._xLabel = self._addInfoField('X') + self._yLabel = self._addInfoField('Y') + self._zLabel = self._addInfoField('Z') + self._dataLabel = self._addInfoField('Data') + self._itemLabel = self._addInfoField('Item') + + layout.addStretch(1) + + def _addInfoField(self, label): + """Add a description: info widget to this widget + + :param str label: Description label + :return: The QLabel used to display the info + :rtype: QLabel + """ + subLayout = qt.QHBoxLayout() + subLayout.setContentsMargins(0, 0, 0, 0) + + subLayout.addWidget(qt.QLabel(label + ':')) + + widget = qt.QLabel('-') + widget.setAlignment(qt.Qt.AlignLeft | qt.Qt.AlignVCenter) + widget.setTextInteractionFlags(qt.Qt.TextSelectableByMouse) + widget.setMinimumWidth(widget.fontMetrics().width('#######')) + subLayout.addWidget(widget) + + subLayout.addStretch(1) + + layout = self.layout() + layout.addLayout(subLayout) + return widget + + def getSceneWidget(self): + """Returns the associated :class:`SceneWidget` or None. + + :rtype: Union[None,~silx.gui.plot3d.SceneWidget.SceneWidget] + """ + if self._sceneWidgetRef is None: + return None + else: + return self._sceneWidgetRef() + + def setSceneWidget(self, widget): + """Set the associated :class:`SceneWidget` + + :param ~silx.gui.plot3d.SceneWidget.SceneWidget widget: + 3D scene for which to display information + """ + if widget is not None and not isinstance(widget, SceneWidget): + raise ValueError("widget must be a SceneWidget or None") + + previous = self.getSceneWidget() + if previous is not None: + previous.removeEventFilter(self) + + if widget is None: + self._sceneWidgetRef = None + else: + widget.installEventFilter(self) + self._sceneWidgetRef = weakref.ref(widget) + + def eventFilter(self, watched, event): + # Filter events of SceneWidget to react on mouse events. + if (event.type() == qt.QEvent.MouseButtonDblClick and + event.button() == qt.Qt.LeftButton): + self.pick(event.x(), event.y()) + + return super(PositionInfoWidget, self).eventFilter(watched, event) + + def clear(self): + """Clean-up displayed values""" + for widget in (self._xLabel, self._yLabel, self._zLabel, + self._dataLabel, self._itemLabel): + widget.setText('-') + + _SUPPORTED_ITEMS = (items.Scatter3D, + items.Scatter2D, + items.ImageData, + items.ImageRgba, + items.Mesh, + items.Box, + items.Cylinder, + items.Hexagon, + volume.CutPlane, + volume.Isosurface) + """Type of items that are picked""" + + def _isSupportedItem(self, item): + """Returns True if item is of supported type + + :param Item3D item: The Item3D to check + :rtype: bool + """ + return isinstance(item, self._SUPPORTED_ITEMS) + + def pick(self, x, y): + """Pick items in the associated SceneWidget and display result + + Only the closest point is displayed. + + :param int x: X coordinate in pixel in the SceneWidget + :param int y: Y coordinate in pixel in the SceneWidget + """ + self.clear() + + sceneWidget = self.getSceneWidget() + if sceneWidget is None: # No associated widget + _logger.info('Picking without associated SceneWidget') + return + + # Find closest (and latest in the tree) supported item + closestNdcZ = float('inf') + picking = None + for result in sceneWidget.pickItems(x, y, + condition=self._isSupportedItem): + ndcZ = result.getPositions('ndc', copy=False)[0, 2] + if ndcZ <= closestNdcZ: + closestNdcZ = ndcZ + picking = result + + if picking is None: + return # No picked item + + item = picking.getItem() + self._itemLabel.setText(item.getLabel()) + positions = picking.getPositions('scene', copy=False) + x, y, z = positions[0] + self._xLabel.setText("%g" % x) + self._yLabel.setText("%g" % y) + self._zLabel.setText("%g" % z) + + data = picking.getData(copy=False) + if data is not None: + data = data[0] + if hasattr(data, '__len__'): + text = ' '.join(["%.3g"] * len(data)) % tuple(data) + else: + text = "%g" % data + self._dataLabel.setText(text) + + def updateInfo(self): + """Update information according to cursor position""" + widget = self.getSceneWidget() + if widget is None: + _logger.info('Update without associated SceneWidget') + self.clear() + return + + position = widget.mapFromGlobal(qt.QCursor.pos()) + self.pick(position.x(), position.y()) diff --git a/silx/gui/plot3d/scene/setup.py b/silx/gui/plot3d/tools/test/__init__.py index ff4c0a6..2dbc0ab 100644 --- a/silx/gui/plot3d/scene/setup.py +++ b/silx/gui/plot3d/tools/test/__init__.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2015-2017 European Synchrotron Radiation Facility +# Copyright (c) 2018 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 @@ -22,20 +22,20 @@ # THE SOFTWARE. # # ###########################################################################*/ -__authors__ = ["T. Vincent"] -__license__ = "MIT" -__date__ = "25/07/2016" +"""plot3d tools test suite.""" -from numpy.distutils.misc_util import Configuration +from __future__ import absolute_import +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "03/10/2018" -def configuration(parent_package='', top_path=None): - config = Configuration('scene', parent_package, top_path) - config.add_subpackage('test') - return config +import unittest +from .testPositionInfoWidget import suite as testPositionInfoWidgetSuite -if __name__ == "__main__": - from numpy.distutils.core import setup - setup(configuration=configuration) +def suite(): + testsuite = unittest.TestSuite() + testsuite.addTest(testPositionInfoWidgetSuite()) + return testsuite diff --git a/silx/gui/plot3d/tools/test/testPositionInfoWidget.py b/silx/gui/plot3d/tools/test/testPositionInfoWidget.py new file mode 100644 index 0000000..4520a2a --- /dev/null +++ b/silx/gui/plot3d/tools/test/testPositionInfoWidget.py @@ -0,0 +1,101 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2018 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. +# ###########################################################################*/ +"""Test PositionInfoWidget""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "03/10/2018" + + +import unittest + +import numpy + +from silx.gui.utils.testutils import TestCaseQt +from silx.gui import qt + +from silx.gui.plot3d.SceneWidget import SceneWidget +from silx.gui.plot3d.tools.PositionInfoWidget import PositionInfoWidget + + +class TestPositionInfoWidget(TestCaseQt): + """Tests PositionInfoWidget""" + + def setUp(self): + super(TestPositionInfoWidget, self).setUp() + self.sceneWidget = SceneWidget() + self.sceneWidget.resize(300, 300) + self.sceneWidget.show() + + self.positionInfoWidget = PositionInfoWidget() + self.positionInfoWidget.setSceneWidget(self.sceneWidget) + self.positionInfoWidget.show() + self.qWaitForWindowExposed(self.positionInfoWidget) + + # self.qWaitForWindowExposed(self.widget) + + def tearDown(self): + self.qapp.processEvents() + + self.sceneWidget.setAttribute(qt.Qt.WA_DeleteOnClose) + self.sceneWidget.close() + del self.sceneWidget + + self.positionInfoWidget.setAttribute(qt.Qt.WA_DeleteOnClose) + self.positionInfoWidget.close() + del self.positionInfoWidget + super(TestPositionInfoWidget, self).tearDown() + + def test(self): + """Test PositionInfoWidget""" + self.assertIs(self.positionInfoWidget.getSceneWidget(), + self.sceneWidget) + + data = numpy.arange(100) + self.sceneWidget.add2DScatter(x=data, y=data, value=data) + self.sceneWidget.resetZoom('front') + + # Double click at the center + self.mouseDClick(self.sceneWidget, button=qt.Qt.LeftButton) + + # Clear displayed value + self.positionInfoWidget.clear() + + # Update info from API + self.positionInfoWidget.pick(x=10, y=10) + + # Remove SceneWidget + self.positionInfoWidget.setSceneWidget(None) + + +def suite(): + testsuite = unittest.TestSuite() + testsuite.addTest( + unittest.defaultTestLoader.loadTestsFromTestCase( + TestPositionInfoWidget)) + return testsuite + + +if __name__ == '__main__': + unittest.main(defaultTest='suite') |