summaryrefslogtreecommitdiff
path: root/silx/gui/plot3d/items
diff options
context:
space:
mode:
Diffstat (limited to 'silx/gui/plot3d/items')
-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
7 files changed, 1083 insertions, 99 deletions
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()