summaryrefslogtreecommitdiff
path: root/silx/gui/plot3d
diff options
context:
space:
mode:
Diffstat (limited to 'silx/gui/plot3d')
-rw-r--r--silx/gui/plot3d/ParamTreeView.py11
-rw-r--r--silx/gui/plot3d/Plot3DWidget.py2
-rw-r--r--silx/gui/plot3d/SFViewParamTree.py64
-rw-r--r--silx/gui/plot3d/SceneWidget.py28
-rw-r--r--silx/gui/plot3d/_model/items.py2
-rw-r--r--silx/gui/plot3d/actions/io.py2
-rw-r--r--silx/gui/plot3d/items/_pick.py292
-rw-r--r--silx/gui/plot3d/items/clipplane.py90
-rw-r--r--silx/gui/plot3d/items/core.py201
-rw-r--r--silx/gui/plot3d/items/image.py68
-rw-r--r--silx/gui/plot3d/items/mesh.py176
-rw-r--r--silx/gui/plot3d/items/scatter.py182
-rw-r--r--silx/gui/plot3d/items/volume.py173
-rw-r--r--silx/gui/plot3d/scene/event.py4
-rw-r--r--silx/gui/plot3d/scene/function.py4
-rw-r--r--silx/gui/plot3d/scene/primitives.py2
-rw-r--r--silx/gui/plot3d/scene/transform.py42
-rw-r--r--silx/gui/plot3d/scene/utils.py180
-rw-r--r--silx/gui/plot3d/setup.py4
-rw-r--r--silx/gui/plot3d/test/__init__.py25
-rw-r--r--silx/gui/plot3d/test/testGL.py2
-rw-r--r--silx/gui/plot3d/test/testScalarFieldView.py29
-rw-r--r--silx/gui/plot3d/test/testSceneWidgetPicking.py267
-rw-r--r--silx/gui/plot3d/tools/PositionInfoWidget.py209
-rw-r--r--silx/gui/plot3d/tools/test/__init__.py (renamed from silx/gui/plot3d/scene/setup.py)24
-rw-r--r--silx/gui/plot3d/tools/test/testPositionInfoWidget.py101
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')