summaryrefslogtreecommitdiff
path: root/silx/gui/plot3d
diff options
context:
space:
mode:
authorAlexandre Marie <alexandre.marie@synchrotron-soleil.fr>2019-07-09 10:20:20 +0200
committerAlexandre Marie <alexandre.marie@synchrotron-soleil.fr>2019-07-09 10:20:20 +0200
commit654a6ac93513c3cc1ef97cacd782ff674c6f4559 (patch)
tree3b986e4972de7c57fa465820367602fc34bcb0d3 /silx/gui/plot3d
parenta763e5d1b3921b3194f3d4e94ab9de3fbe08bbdd (diff)
New upstream version 0.11.0+dfsg
Diffstat (limited to 'silx/gui/plot3d')
-rw-r--r--silx/gui/plot3d/Plot3DWidget.py52
-rw-r--r--silx/gui/plot3d/Plot3DWindow.py21
-rw-r--r--silx/gui/plot3d/SceneWidget.py27
-rw-r--r--silx/gui/plot3d/SceneWindow.py22
-rw-r--r--silx/gui/plot3d/_model/items.py512
-rw-r--r--silx/gui/plot3d/actions/mode.py61
-rw-r--r--silx/gui/plot3d/items/__init__.py4
-rw-r--r--silx/gui/plot3d/items/mesh.py5
-rw-r--r--silx/gui/plot3d/items/mixins.py18
-rw-r--r--silx/gui/plot3d/items/scatter.py108
-rw-r--r--silx/gui/plot3d/items/volume.py308
-rw-r--r--silx/gui/plot3d/scene/camera.py2
-rw-r--r--silx/gui/plot3d/scene/core.py11
-rw-r--r--silx/gui/plot3d/scene/cutplane.py18
-rw-r--r--silx/gui/plot3d/scene/function.py87
-rw-r--r--silx/gui/plot3d/scene/interaction.py60
-rw-r--r--silx/gui/plot3d/scene/primitives.py130
-rw-r--r--silx/gui/plot3d/scene/utils.py73
-rw-r--r--silx/gui/plot3d/scene/viewport.py75
-rw-r--r--silx/gui/plot3d/test/__init__.py4
-rw-r--r--silx/gui/plot3d/test/testSceneWidget.py84
-rw-r--r--silx/gui/plot3d/test/testSceneWidgetPicking.py96
-rw-r--r--silx/gui/plot3d/test/testSceneWindow.py209
-rw-r--r--silx/gui/plot3d/tools/PositionInfoWidget.py42
24 files changed, 1497 insertions, 532 deletions
diff --git a/silx/gui/plot3d/Plot3DWidget.py b/silx/gui/plot3d/Plot3DWidget.py
index eed4438..f512cd8 100644
--- a/silx/gui/plot3d/Plot3DWidget.py
+++ b/silx/gui/plot3d/Plot3DWidget.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2015-2018 European Synchrotron Radiation Facility
+# Copyright (c) 2015-2019 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -31,11 +31,14 @@ __license__ = "MIT"
__date__ = "24/04/2018"
+import enum
import logging
from silx.gui import qt
from silx.gui.colors import rgba
from . import actions
+
+from ...utils.enum import Enum as _Enum
from ..utils.image import convertArrayToQImage
from .. import _glutils as glu
@@ -106,6 +109,22 @@ class Plot3DWidget(glu.OpenGLWidget):
It provides the updated property.
"""
+ sigSceneClicked = qt.Signal(float, float)
+ """Signal emitted when the scene is clicked with the left mouse button.
+
+ It provides the (x, y) clicked mouse position
+ """
+
+ @enum.unique
+ class FogMode(_Enum):
+ """Different mode to render the scene with fog"""
+
+ NONE = 'none'
+ """No fog effect"""
+
+ LINEAR = 'linear'
+ """Linear fog through the whole scene"""
+
def __init__(self, parent=None, f=qt.Qt.WindowFlags()):
self._firstRender = True
@@ -146,6 +165,11 @@ class Plot3DWidget(glu.OpenGLWidget):
self.eventHandler = None
self.setInteractiveMode('rotate')
+ def __clickHandler(self, *args):
+ """Handle interaction state machine click"""
+ x, y = args[0][:2]
+ self.sigSceneClicked.emit(x, y)
+
def setInteractiveMode(self, mode):
"""Set the interactive mode.
@@ -163,7 +187,7 @@ class Plot3DWidget(glu.OpenGLWidget):
orbitAroundCenter=False,
mode='position',
scaleTransform=self._sceneScale,
- selectCB=None)
+ selectCB=self.__clickHandler)
elif mode == 'pan':
self.eventHandler = interaction.PanCameraControl(
@@ -171,7 +195,7 @@ class Plot3DWidget(glu.OpenGLWidget):
orbitAroundCenter=False,
mode='position',
scaleTransform=self._sceneScale,
- selectCB=None)
+ selectCB=self.__clickHandler)
elif isinstance(mode, interaction.StateMachine):
self.eventHandler = mode
@@ -244,6 +268,28 @@ class Plot3DWidget(glu.OpenGLWidget):
"""Returns the RGBA background color (QColor)."""
return qt.QColor.fromRgbF(*self.viewport.background)
+ def setFogMode(self, mode):
+ """Set the kind of fog to use for the whole scene.
+
+ :param Union[str,FogMode] mode: The mode to use
+ :raise ValueError: If mode is not supported
+ """
+ mode = self.FogMode.from_value(mode)
+ if mode != self.getFogMode():
+ self.viewport.fog.isOn = mode is self.FogMode.LINEAR
+ self.sigStyleChanged.emit('fogMode')
+
+ def getFogMode(self):
+ """Returns the kind of fog in use
+
+ :return: The kind of fog in use
+ :rtype: FogMode
+ """
+ if self.viewport.fog.isOn:
+ return self.FogMode.LINEAR
+ else:
+ return self.FogMode.NONE
+
def isOrientationIndicatorVisible(self):
"""Returns True if the orientation indicator is displayed.
diff --git a/silx/gui/plot3d/Plot3DWindow.py b/silx/gui/plot3d/Plot3DWindow.py
index 331eca2..470b966 100644
--- a/silx/gui/plot3d/Plot3DWindow.py
+++ b/silx/gui/plot3d/Plot3DWindow.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2015-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2015-2019 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -32,6 +32,7 @@ __license__ = "MIT"
__date__ = "26/01/2017"
+from silx.utils.proxy import docstring
from silx.gui import qt
from .Plot3DWidget import Plot3DWidget
@@ -62,32 +63,26 @@ class Plot3DWindow(qt.QMainWindow):
# Proxy to Plot3DWidget
+ @docstring(Plot3DWidget)
def setProjection(self, projection):
return self._plot3D.setProjection(projection)
- setProjection.__doc__ = Plot3DWidget.setProjection.__doc__
-
+ @docstring(Plot3DWidget)
def getProjection(self):
return self._plot3D.getProjection()
- getProjection.__doc__ = Plot3DWidget.getProjection.__doc__
-
+ @docstring(Plot3DWidget)
def centerScene(self):
return self._plot3D.centerScene()
- centerScene.__doc__ = Plot3DWidget.centerScene.__doc__
-
+ @docstring(Plot3DWidget)
def resetZoom(self):
return self._plot3D.resetZoom()
- resetZoom.__doc__ = Plot3DWidget.resetZoom.__doc__
-
+ @docstring(Plot3DWidget)
def getBackgroundColor(self):
return self._plot3D.getBackgroundColor()
- getBackgroundColor.__doc__ = Plot3DWidget.getBackgroundColor.__doc__
-
+ @docstring(Plot3DWidget)
def setBackgroundColor(self, color):
return self._plot3D.setBackgroundColor(color)
-
- setBackgroundColor.__doc__ = Plot3DWidget.setBackgroundColor.__doc__
diff --git a/silx/gui/plot3d/SceneWidget.py b/silx/gui/plot3d/SceneWidget.py
index e60dcfc..883f5e7 100644
--- a/silx/gui/plot3d/SceneWidget.py
+++ b/silx/gui/plot3d/SceneWidget.py
@@ -45,7 +45,6 @@ from .scene import interaction
from ._model import SceneModel, visitQAbstractItemModel
from ._model.items import Item3DRow
-
__all__ = ['items', 'SceneWidget']
@@ -268,7 +267,7 @@ class SceneSelection(qt.QObject):
assert isinstance(parent, SceneWidget)
if item.root() != parent.getSceneGroup():
- self.setSelectedItem(None)
+ self.setCurrentItem(None)
# Synchronization with QItemSelectionModel
@@ -482,27 +481,37 @@ class SceneWidget(Plot3DWidget):
# Add/remove items
- def add3DScalarField(self, data, copy=True, index=None):
- """Add 3D scalar data volume to :class:`SceneWidget` content.
+ def addVolume(self, data, copy=True, index=None):
+ """Add 3D data volume of scalar or complex to :class:`SceneWidget` content.
Dataset order is zyx (i.e., first dimension is z).
- :param data: 3D array
- :type data: 3D numpy.ndarray of float32 with shape at least (2, 2, 2)
+ :param data: 3D array of complex with shape at least (2, 2, 2)
+ :type data: numpy.ndarray[Union[numpy.complex64,numpy.float32]]
:param bool copy:
True (default) to make a copy,
False to avoid copy (DO NOT MODIFY data afterwards)
:param int index: The index at which to place the item.
By default it is appended to the end of the list.
- :return: The newly created scalar volume item
- :rtype: ~silx.gui.plot3d.items.volume.ScalarField3D
+ :return: The newly created 3D volume item
+ :rtype: Union[ScalarField3D,ComplexField3D]
"""
- volume = items.ScalarField3D()
+ if data is not None:
+ data = numpy.array(data, copy=False)
+
+ if numpy.iscomplexobj(data):
+ volume = items.ComplexField3D()
+ else:
+ volume = items.ScalarField3D()
volume.setData(data, copy=copy)
self.addItem(volume, index)
return volume
+ def add3DScalarField(self, data, copy=True, index=None):
+ # TODO deprecate in the future
+ return self.addVolume(data, copy=copy, index=index)
+
def add3DScatter(self, x, y, z, value, copy=True, index=None):
"""Add 3D scatter data to :class:`SceneWidget` content.
diff --git a/silx/gui/plot3d/SceneWindow.py b/silx/gui/plot3d/SceneWindow.py
index 56fb21f..052a4dc 100644
--- a/silx/gui/plot3d/SceneWindow.py
+++ b/silx/gui/plot3d/SceneWindow.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2017-2018 European Synchrotron Radiation Facility
+# Copyright (c) 2017-2019 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -33,11 +33,13 @@ __date__ = "29/11/2017"
from ...gui import qt, icons
+from ...gui.widgets.BoxLayoutDockWidget import BoxLayoutDockWidget
from .actions.mode import InteractiveModeAction
from .SceneWidget import SceneWidget
from .tools import OutputToolBar, InteractiveModeToolBar, ViewpointToolBar
from .tools.GroupPropertiesWidget import GroupPropertiesWidget
+from .tools.PositionInfoWidget import PositionInfoWidget
from .ParamTreeView import ParamTreeView
@@ -118,8 +120,19 @@ class SceneWindow(qt.QMainWindow):
self._sceneWidget = SceneWidget()
self.setCentralWidget(self._sceneWidget)
+ # Add PositionInfoWidget to display picking info
+ self._positionInfo = PositionInfoWidget()
+ self._positionInfo.setSceneWidget(self._sceneWidget)
+
+ dock = BoxLayoutDockWidget()
+ dock.setWindowTitle("Selection Info")
+ dock.setWidget(self._positionInfo)
+ self.addDockWidget(qt.Qt.BottomDockWidgetArea, dock)
+
self._interactiveModeToolBar = InteractiveModeToolBar(parent=self)
panPlaneAction = _PanPlaneAction(self, plot3d=self._sceneWidget)
+ self._interactiveModeToolBar.addAction(
+ self._positionInfo.toggleAction())
self._interactiveModeToolBar.addAction(panPlaneAction)
self._viewpointToolBar = ViewpointToolBar(parent=self)
@@ -197,3 +210,10 @@ class SceneWindow(qt.QMainWindow):
:rtype: ~silx.gui.plot3d.tools.OutputToolBar
"""
return self._outputToolBar
+
+ def getPositionInfoWidget(self):
+ """Returns the widget displaying selected position information.
+
+ :rtype: ~silx.gui.plot3d.tools.PositionInfoWidget.PositionInfoWidget
+ """
+ return self._positionInfo
diff --git a/silx/gui/plot3d/_model/items.py b/silx/gui/plot3d/_model/items.py
index 7e58d14..9fe3e51 100644
--- a/silx/gui/plot3d/_model/items.py
+++ b/silx/gui/plot3d/_model/items.py
@@ -33,6 +33,7 @@ __license__ = "MIT"
__date__ = "24/04/2018"
+from collections import OrderedDict
import functools
import logging
import weakref
@@ -45,6 +46,7 @@ from ...colors import preferredColormaps
from ... import qt, icons
from .. import items
from ..items.volume import Isosurface, CutPlane
+from ..Plot3DWidget import Plot3DWidget
from .core import AngleDegreeRow, BaseRow, ColorProxyRow, ProxyRow, StaticRow
@@ -53,6 +55,76 @@ from .core import AngleDegreeRow, BaseRow, ColorProxyRow, ProxyRow, StaticRow
_logger = logging.getLogger(__name__)
+class ItemProxyRow(ProxyRow):
+ """Provides a node to proxy a data accessible through functions.
+
+ It listens on sigItemChanged to trigger the update.
+
+ Warning: Only weak reference are kept on fget and fset.
+
+ :param Item3D item: The item to
+ :param str name: The name of this node
+ :param callable fget: A callable returning the data
+ :param callable fset:
+ An optional callable setting the data with data as a single argument.
+ :param events:
+ An optional event kind or list of event kinds to react upon.
+ :param callable toModelData:
+ An optional callable to convert from fget
+ callable to data returned by the model.
+ :param callable fromModelData:
+ An optional callable converting data provided to the model to
+ data for fset.
+ :param editorHint: Data to provide as UserRole for editor selection/setup
+ """
+
+ def __init__(self,
+ item,
+ name='',
+ fget=None,
+ fset=None,
+ events=None,
+ toModelData=None,
+ fromModelData=None,
+ editorHint=None):
+ super(ItemProxyRow, self).__init__(
+ name=name,
+ fget=fget,
+ fset=fset,
+ notify=None,
+ toModelData=toModelData,
+ fromModelData=fromModelData,
+ editorHint=editorHint)
+
+ if isinstance(events, (items.ItemChangedType,
+ items.Item3DChangedType)):
+ events = (events,)
+ self.__events = events
+ item.sigItemChanged.connect(self.__itemChanged)
+
+ def __itemChanged(self, event):
+ """Handle item changed
+
+ :param Union[ItemChangedType,Item3DChangedType] event:
+ """
+ if self.__events is None or event in self.__events:
+ self._notified()
+
+
+class ItemColorProxyRow(ColorProxyRow, ItemProxyRow):
+ """Combines :class:`ColorProxyRow` and :class:`ItemProxyRow`"""
+
+ def __init__(self, *args, **kwargs):
+ ItemProxyRow.__init__(self, *args, **kwargs)
+
+
+class ItemAngleDegreeRow(AngleDegreeRow, ItemProxyRow):
+ """Combines :class:`AngleDegreeRow` and :class:`ItemProxyRow`"""
+
+ def __init__(self, *args, **kwargs):
+ ItemProxyRow.__init__(self, *args, **kwargs)
+
+
class _DirectionalLightProxy(qt.QObject):
"""Proxy to handle directional light with angles rather than vector.
"""
@@ -67,8 +139,8 @@ class _DirectionalLightProxy(qt.QObject):
super(_DirectionalLightProxy, self).__init__()
self._light = light
light.addListener(self._directionUpdated)
- self._azimuth = 0.
- self._altitude = 0.
+ self._azimuth = 0
+ self._altitude = 0
def getAzimuthAngle(self):
"""Returns the signed angle in the horizontal plane.
@@ -76,7 +148,7 @@ class _DirectionalLightProxy(qt.QObject):
Unit: degrees.
The 0 angle corresponds to the axis perpendicular to the screen.
- :rtype: float
+ :rtype: int
"""
return self._azimuth
@@ -86,15 +158,16 @@ class _DirectionalLightProxy(qt.QObject):
Unit: degrees.
Range: [-90, +90]
- :rtype: float
+ :rtype: int
"""
return self._altitude
def setAzimuthAngle(self, angle):
"""Set the horizontal angle.
- :param float angle: Angle from -z axis in zx plane in degrees.
+ :param int angle: Angle from -z axis in zx plane in degrees.
"""
+ angle = int(round(angle))
if angle != self._azimuth:
self._azimuth = angle
self._updateLight()
@@ -103,8 +176,9 @@ class _DirectionalLightProxy(qt.QObject):
def setAltitudeAngle(self, angle):
"""Set the horizontal angle.
- :param float angle: Angle from -z axis in zy plane in degrees.
+ :param int angle: Angle from -z axis in zy plane in degrees.
"""
+ angle = int(round(angle))
if angle != self._altitude:
self._altitude = angle
self._updateLight()
@@ -117,20 +191,21 @@ class _DirectionalLightProxy(qt.QObject):
x, y, z = - self._light.direction
# Horizontal plane is plane xz
- azimuth = numpy.degrees(numpy.arctan2(x, z))
- altitude = numpy.degrees(numpy.pi/2. - numpy.arccos(y))
+ azimuth = int(round(numpy.degrees(numpy.arctan2(x, z))))
+ altitude = int(round(numpy.degrees(numpy.pi/2. - numpy.arccos(y))))
- if (abs(azimuth - self.getAzimuthAngle()) > 0.01 and
- abs(abs(altitude) - 90.) >= 0.001): # Do not update when at zenith
+ if azimuth != self.getAzimuthAngle():
self.setAzimuthAngle(azimuth)
- if abs(altitude - self.getAltitudeAngle()) > 0.01:
+ if altitude != self.getAltitudeAngle():
self.setAltitudeAngle(altitude)
def _updateLight(self):
"""Update light direction in the scene"""
azimuth = numpy.radians(self._azimuth)
delta = numpy.pi/2. - numpy.radians(self._altitude)
+ if delta == 0.: # Avoids zenith position
+ delta = 0.0001
z = - numpy.sin(delta) * numpy.cos(azimuth)
x = - numpy.sin(delta) * numpy.sin(azimuth)
y = - numpy.cos(delta)
@@ -195,9 +270,18 @@ class Settings(StaticRow):
lightDirection = StaticRow(('Light Direction', None),
children=(azimuthNode, altitudeNode))
+ # Fog
+ fog = ProxyRow(
+ name='Fog',
+ fget=sceneWidget.getFogMode,
+ fset=sceneWidget.setFogMode,
+ notify=sceneWidget.sigStyleChanged,
+ toModelData=lambda mode: mode is Plot3DWidget.FogMode.LINEAR,
+ fromModelData=lambda mode: Plot3DWidget.FogMode.LINEAR if mode else Plot3DWidget.FogMode.NONE)
+
# Settings row
children = (background, foreground, text, highlight,
- axesIndicator, lightDirection)
+ axesIndicator, lightDirection, fog)
super(Settings, self).__init__(('Settings', None), children=children)
@@ -208,6 +292,9 @@ class Item3DRow(BaseRow):
:param str name: The optional name of the item
"""
+ _EVENTS = items.ItemChangedType.VISIBLE, items.Item3DChangedType.LABEL
+ """Events for which to update the first column in the tree"""
+
def __init__(self, item, name=None):
self.__name = None if name is None else six.text_type(name)
super(Item3DRow, self).__init__()
@@ -221,12 +308,11 @@ class Item3DRow(BaseRow):
item.sigItemChanged.connect(self._itemChanged)
def _itemChanged(self, event):
- """Handle visibility change"""
- if event in (items.ItemChangedType.VISIBLE,
- items.Item3DChangedType.LABEL):
+ """Handle model update upon change"""
+ if event in self._EVENTS:
model = self.model()
if model is not None:
- index = self.index(column=1)
+ index = self.index(column=0)
model.dataChanged.emit(index, index)
def item(self):
@@ -268,7 +354,7 @@ class Item3DRow(BaseRow):
return 2
-class DataItem3DBoundingBoxRow(ProxyRow):
+class DataItem3DBoundingBoxRow(ItemProxyRow):
"""Represents :class:`DataItem3D` bounding box visibility
:param DataItem3D item: The item for which to display/control bounding box
@@ -276,13 +362,14 @@ class DataItem3DBoundingBoxRow(ProxyRow):
def __init__(self, item):
super(DataItem3DBoundingBoxRow, self).__init__(
+ item=item,
name='Bounding box',
fget=item.isBoundingBoxVisible,
fset=item.setBoundingBoxVisible,
- notify=item.sigItemChanged)
+ events=items.Item3DChangedType.BOUNDING_BOX_VISIBLE)
-class MatrixProxyRow(ProxyRow):
+class MatrixProxyRow(ItemProxyRow):
"""Proxy for a row of a DataItem3D 3x3 matrix transform
:param DataItem3D item:
@@ -294,10 +381,11 @@ class MatrixProxyRow(ProxyRow):
self._index = index
super(MatrixProxyRow, self).__init__(
+ item=item,
name='',
fget=self._getMatrixRow,
fset=self._setMatrixRow,
- notify=item.sigItemChanged)
+ events=items.Item3DChangedType.TRANSFORM)
def _getMatrixRow(self):
"""Returns the matrix row.
@@ -344,11 +432,13 @@ class DataItem3DTransformRow(StaticRow):
super(DataItem3DTransformRow, self).__init__(('Transform', None))
self._item = weakref.ref(item)
- translation = ProxyRow(name='Translation',
- fget=item.getTranslation,
- fset=self._setTranslation,
- notify=item.sigItemChanged,
- toModelData=lambda data: qt.QVector3D(*data))
+ translation = ItemProxyRow(
+ item=item,
+ name='Translation',
+ fget=item.getTranslation,
+ fset=self._setTranslation,
+ events=items.Item3DChangedType.TRANSFORM,
+ toModelData=lambda data: qt.QVector3D(*data))
self.addRow(translation)
# Here to keep a reference
@@ -359,51 +449,60 @@ class DataItem3DTransformRow(StaticRow):
rotateCenter = StaticRow(
('Center', None),
children=(
- ProxyRow(name='X axis',
- fget=item.getRotationCenter,
- fset=self._xSetCenter,
- notify=item.sigItemChanged,
- toModelData=functools.partial(
- self._centerToModelData, index=0),
- editorHint=self._ROTATION_CENTER_OPTIONS),
- ProxyRow(name='Y axis',
- fget=item.getRotationCenter,
- fset=self._ySetCenter,
- notify=item.sigItemChanged,
- toModelData=functools.partial(
- self._centerToModelData, index=1),
- editorHint=self._ROTATION_CENTER_OPTIONS),
- ProxyRow(name='Z axis',
- fget=item.getRotationCenter,
- fset=self._zSetCenter,
- notify=item.sigItemChanged,
- toModelData=functools.partial(
- self._centerToModelData, index=2),
- editorHint=self._ROTATION_CENTER_OPTIONS),
+ ItemProxyRow(item=item,
+ name='X axis',
+ fget=item.getRotationCenter,
+ fset=self._xSetCenter,
+ events=items.Item3DChangedType.TRANSFORM,
+ toModelData=functools.partial(
+ self._centerToModelData, index=0),
+ editorHint=self._ROTATION_CENTER_OPTIONS),
+ ItemProxyRow(item=item,
+ name='Y axis',
+ fget=item.getRotationCenter,
+ fset=self._ySetCenter,
+ events=items.Item3DChangedType.TRANSFORM,
+ toModelData=functools.partial(
+ self._centerToModelData, index=1),
+ editorHint=self._ROTATION_CENTER_OPTIONS),
+ ItemProxyRow(item=item,
+ name='Z axis',
+ fget=item.getRotationCenter,
+ fset=self._zSetCenter,
+ events=items.Item3DChangedType.TRANSFORM,
+ toModelData=functools.partial(
+ self._centerToModelData, index=2),
+ editorHint=self._ROTATION_CENTER_OPTIONS),
))
rotate = StaticRow(
('Rotation', None),
children=(
- AngleDegreeRow(name='Angle',
- fget=item.getRotation,
- fset=self._setAngle,
- notify=item.sigItemChanged,
- toModelData=lambda data: data[0]),
- ProxyRow(name='Axis',
- fget=item.getRotation,
- fset=self._setAxis,
- notify=item.sigItemChanged,
- toModelData=lambda data: qt.QVector3D(*data[1])),
+ ItemAngleDegreeRow(
+ item=item,
+ name='Angle',
+ fget=item.getRotation,
+ fset=self._setAngle,
+ events=items.Item3DChangedType.TRANSFORM,
+ toModelData=lambda data: data[0]),
+ ItemProxyRow(
+ item=item,
+ name='Axis',
+ fget=item.getRotation,
+ fset=self._setAxis,
+ events=items.Item3DChangedType.TRANSFORM,
+ toModelData=lambda data: qt.QVector3D(*data[1])),
rotateCenter
))
self.addRow(rotate)
- scale = ProxyRow(name='Scale',
- fget=item.getScale,
- fset=self._setScale,
- notify=item.sigItemChanged,
- toModelData=lambda data: qt.QVector3D(*data))
+ scale = ItemProxyRow(
+ item=item,
+ name='Scale',
+ fget=item.getScale,
+ fset=self._setScale,
+ events=items.Item3DChangedType.TRANSFORM,
+ toModelData=lambda data: qt.QVector3D(*data))
self.addRow(scale)
matrix = StaticRow(
@@ -545,7 +644,7 @@ class GroupItemRow(Item3DRow):
raise RuntimeError("Model does not correspond to scene content")
-class InterpolationRow(ProxyRow):
+class InterpolationRow(ItemProxyRow):
"""Represents :class:`InterpolationMixIn` property.
:param Item3D item: Scene item with interpolation property
@@ -554,10 +653,11 @@ class InterpolationRow(ProxyRow):
def __init__(self, item):
modes = [mode.title() for mode in item.INTERPOLATION_MODES]
super(InterpolationRow, self).__init__(
+ item=item,
name='Interpolation',
fget=item.getInterpolation,
fset=item.setInterpolation,
- notify=item.sigItemChanged,
+ events=items.Item3DChangedType.INTERPOLATION,
toModelData=lambda mode: mode.title(),
fromModelData=lambda mode: mode.lower(),
editorHint=modes)
@@ -817,7 +917,7 @@ class ColormapRow(_ColormapBaseProxyRow):
return super(ColormapRow, self).data(column, role)
-class SymbolRow(ProxyRow):
+class SymbolRow(ItemProxyRow):
"""Represents :class:`SymbolMixIn` symbol property.
:param Item3D item: Scene item with symbol property
@@ -826,14 +926,15 @@ class SymbolRow(ProxyRow):
def __init__(self, item):
names = [item.getSymbolName(s) for s in item.getSupportedSymbols()]
super(SymbolRow, self).__init__(
- name='Marker',
- fget=item.getSymbolName,
- fset=item.setSymbol,
- notify=item.sigItemChanged,
- editorHint=names)
+ item=item,
+ name='Marker',
+ fget=item.getSymbolName,
+ fset=item.setSymbol,
+ events=items.ItemChangedType.SYMBOL,
+ editorHint=names)
-class SymbolSizeRow(ProxyRow):
+class SymbolSizeRow(ItemProxyRow):
"""Represents :class:`SymbolMixIn` symbol size property.
:param Item3D item: Scene item with symbol size property
@@ -841,25 +942,27 @@ class SymbolSizeRow(ProxyRow):
def __init__(self, item):
super(SymbolSizeRow, self).__init__(
+ item=item,
name='Marker size',
fget=item.getSymbolSize,
fset=item.setSymbolSize,
- notify=item.sigItemChanged,
+ events=items.ItemChangedType.SYMBOL_SIZE,
editorHint=(1, 20)) # TODO link with OpenGL max point size
-class PlaneRow(ProxyRow):
- """Represents :class:`PlaneMixIn` property.
+class PlaneEquationRow(ItemProxyRow):
+ """Represents :class:`PlaneMixIn` as plane equation.
:param Item3D item: Scene item with plane equation property
"""
def __init__(self, item):
- super(PlaneRow, self).__init__(
+ super(PlaneEquationRow, self).__init__(
+ item=item,
name='Equation',
fget=item.getParameters,
fset=item.setParameters,
- notify=item.sigItemChanged,
+ events=items.ItemChangedType.POSITION,
toModelData=lambda data: qt.QVector4D(*data),
fromModelData=lambda data: (data.x(), data.y(), data.z(), data.w()))
self._item = weakref.ref(item)
@@ -871,7 +974,99 @@ class PlaneRow(ProxyRow):
params = item.getParameters()
return ('%gx %+gy %+gz %+g = 0' %
(params[0], params[1], params[2], params[3]))
- return super(PlaneRow, self).data(column, role)
+ return super(PlaneEquationRow, self).data(column, role)
+
+
+class PlaneRow(ItemProxyRow):
+ """Represents :class:`PlaneMixIn` property.
+
+ :param Item3D item: Scene item with plane equation property
+ """
+
+ _PLANES = OrderedDict((('Plane 0', (1., 0., 0.)),
+ ('Plane 1', (0., 1., 0.)),
+ ('Plane 2', (0., 0., 1.)),
+ ('-', None)))
+ """Mapping of plane names to normals"""
+
+ _PLANE_ICONS = {'Plane 0': '3d-plane-normal-x',
+ 'Plane 1': '3d-plane-normal-y',
+ 'Plane 2': '3d-plane-normal-z',
+ '-': '3d-plane'}
+ """Mapping of plane names to normals"""
+
+ def __init__(self, item):
+ super(PlaneRow, self).__init__(
+ item=item,
+ name='Plane',
+ fget=self.__getPlaneName,
+ fset=self.__setPlaneName,
+ events=items.ItemChangedType.POSITION,
+ editorHint=tuple(self._PLANES.keys()))
+ self._item = weakref.ref(item)
+ self._lastName = None
+
+ self.addRow(PlaneEquationRow(item))
+
+ def _notified(self, *args, **kwargs):
+ """Handle notification of modification
+
+ Here only send if plane name actually changed
+ """
+ if self._lastName != self.__getPlaneName():
+ super(PlaneRow, self)._notified()
+
+ def __getPlaneName(self):
+ """Returns name of plane // to axes or '-'
+
+ :rtype: str
+ """
+ item = self._item()
+ planeNormal = item.getNormal() if item is not None else None
+
+ for name, normal in self._PLANES.items():
+ if numpy.array_equal(planeNormal, normal):
+ return name
+ return '-'
+
+ def __setPlaneName(self, data):
+ """Set plane normal according to given plane name
+
+ :param str data: Selected plane name
+ """
+ item = self._item()
+ if item is not None:
+ for name, normal in self._PLANES.items():
+ if data == name and normal is not None:
+ item.setNormal(normal)
+
+ def data(self, column, role):
+ if column == 1 and role == qt.Qt.DecorationRole:
+ return icons.getQIcon(self._PLANE_ICONS[self.__getPlaneName()])
+ data = super(PlaneRow, self).data(column, role)
+ if column == 1 and role == qt.Qt.DisplayRole:
+ self._lastName = data
+ return data
+
+
+class ComplexModeRow(ItemProxyRow):
+ """Represents :class:`items.ComplexMixIn` symbol property.
+
+ :param Item3D item: Scene item with symbol property
+ """
+
+ def __init__(self, item):
+ names = [m.value.replace('_', ' ').title()
+ for m in item.supportedComplexModes()]
+ super(ComplexModeRow, self).__init__(
+ item=item,
+ name='Mode',
+ fget=item.getComplexMode,
+ fset=item.setComplexMode,
+ events=items.ItemChangedType.COMPLEX_MODE,
+ toModelData=lambda data: data.value.replace('_', ' ').title(),
+ fromModelData=lambda data: data.lower().replace(' ', '_'),
+ editorHint=names)
class RemoveIsosurfaceRow(BaseRow):
@@ -923,9 +1118,9 @@ class RemoveIsosurfaceRow(BaseRow):
"""Handle Delete button clicked"""
isosurface = self.isosurface()
if isosurface is not None:
- scalarField3D = isosurface.parent()
- if scalarField3D is not None:
- scalarField3D.removeIsosurface(isosurface)
+ volume = isosurface.parent()
+ if volume is not None:
+ volume.removeIsosurface(isosurface)
class IsosurfaceRow(Item3DRow):
@@ -937,6 +1132,9 @@ class IsosurfaceRow(Item3DRow):
_LEVEL_SLIDER_RANGE = 0, 1000
"""Range given as editor hint"""
+ _EVENTS = items.ItemChangedType.VISIBLE, items.ItemChangedType.COLOR
+ """Events for which to update the first column in the tree"""
+
def __init__(self, item):
super(IsosurfaceRow, self).__init__(item, name=item.getLevel())
@@ -944,24 +1142,27 @@ class IsosurfaceRow(Item3DRow):
item.sigItemChanged.connect(self._levelChanged)
- self.addRow(ProxyRow(
+ self.addRow(ItemProxyRow(
+ item=item,
name='Level',
fget=self._getValueForLevelSlider,
fset=self._setLevelFromSliderValue,
- notify=item.sigItemChanged,
+ events=items.Item3DChangedType.ISO_LEVEL,
editorHint=self._LEVEL_SLIDER_RANGE))
- self.addRow(ColorProxyRow(
+ self.addRow(ItemColorProxyRow(
+ item=item,
name='Color',
fget=self._rgbColor,
fset=self._setRgbColor,
- notify=item.sigItemChanged))
+ events=items.ItemChangedType.COLOR))
- self.addRow(ProxyRow(
+ self.addRow(ItemProxyRow(
+ item=item,
name='Opacity',
fget=self._opacity,
fset=self._setOpacity,
- notify=item.sigItemChanged,
+ events=items.ItemChangedType.COLOR,
editorHint=(0, 255)))
self.addRow(RemoveIsosurfaceRow(item))
@@ -973,12 +1174,15 @@ class IsosurfaceRow(Item3DRow):
"""
item = self.item()
if item is not None:
- scalarField3D = item.parent()
- if scalarField3D is not None:
- dataRange = scalarField3D.getDataRange()
+ volume = item.parent()
+ if volume is not None:
+ dataRange = volume.getDataRange()
if dataRange is not None:
dataMin, dataMax = dataRange[0], dataRange[-1]
- offset = (item.getLevel() - dataMin) / (dataMax - dataMin)
+ if dataMax != dataMin:
+ offset = (item.getLevel() - dataMin) / (dataMax - dataMin)
+ else:
+ offset = 0.
sliderMin, sliderMax = self._LEVEL_SLIDER_RANGE
value = sliderMin + (sliderMax - sliderMin) * offset
@@ -992,9 +1196,9 @@ class IsosurfaceRow(Item3DRow):
"""
item = self.item()
if item is not None:
- scalarField3D = item.parent()
- if scalarField3D is not None:
- dataRange = scalarField3D.getDataRange()
+ volume = item.parent()
+ if volume is not None:
+ dataRange = volume.getDataRange()
if dataRange is not None:
sliderMin, sliderMax = self._LEVEL_SLIDER_RANGE
offset = (value - sliderMin) / (sliderMax - sliderMin)
@@ -1082,13 +1286,13 @@ class IsosurfaceRow(Item3DRow):
class AddIsosurfaceRow(BaseRow):
"""Class for Isosurface create button
- :param ScalarField3D scalarField3D:
- The ScalarField3D item to attach the button to.
+ :param Union[ScalarField3D,ComplexField3D] volume:
+ The volume item to attach the button to.
"""
- def __init__(self, scalarField3D):
+ def __init__(self, volume):
super(AddIsosurfaceRow, self).__init__()
- self._scalarField3D = weakref.ref(scalarField3D)
+ self._volume = weakref.ref(volume)
def createEditor(self):
"""Specific editor factory provided to the model"""
@@ -1106,12 +1310,12 @@ class AddIsosurfaceRow(BaseRow):
layout.addStretch(1)
return editor
- def scalarField3D(self):
- """Returns the controlled ScalarField3D
+ def volume(self):
+ """Returns the controlled volume item
- :rtype: ScalarField3D
+ :rtype: Union[ScalarField3D,ComplexField3D]
"""
- return self._scalarField3D()
+ return self._volume()
def data(self, column, role):
if column == 0 and role == qt.Qt.UserRole: # editor hint
@@ -1127,53 +1331,59 @@ class AddIsosurfaceRow(BaseRow):
def _addClicked(self):
"""Handle Delete button clicked"""
- scalarField3D = self.scalarField3D()
- if scalarField3D is not None:
- dataRange = scalarField3D.getDataRange()
+ volume = self.volume()
+ if volume is not None:
+ dataRange = volume.getDataRange()
if dataRange is None:
dataRange = 0., 1.
- scalarField3D.addIsosurface(
+ volume.addIsosurface(
numpy.mean((dataRange[0], dataRange[-1])),
'#0000FF')
-class ScalarField3DIsoSurfacesRow(StaticRow):
+class VolumeIsoSurfacesRow(StaticRow):
"""Represents :class:`ScalarFieldView`'s isosurfaces
- :param ScalarFieldView scalarField3D: ScalarFieldView to control
+ :param Union[ScalarField3D,ComplexField3D] volume:
+ Volume item to control
"""
- def __init__(self, scalarField3D):
- super(ScalarField3DIsoSurfacesRow, self).__init__(
+ def __init__(self, volume):
+ super(VolumeIsoSurfacesRow, self).__init__(
('Isosurfaces', None))
- self._scalarField3D = weakref.ref(scalarField3D)
+ self._volume = weakref.ref(volume)
- scalarField3D.sigIsosurfaceAdded.connect(self._isosurfaceAdded)
- scalarField3D.sigIsosurfaceRemoved.connect(self._isosurfaceRemoved)
+ volume.sigIsosurfaceAdded.connect(self._isosurfaceAdded)
+ volume.sigIsosurfaceRemoved.connect(self._isosurfaceRemoved)
- for item in scalarField3D.getIsosurfaces():
+ if isinstance(volume, items.ComplexMixIn):
+ self.addRow(ComplexModeRow(volume))
+
+ for item in volume.getIsosurfaces():
self.addRow(nodeFromItem(item))
- self.addRow(AddIsosurfaceRow(scalarField3D))
+ self.addRow(AddIsosurfaceRow(volume))
- def scalarField3D(self):
- """Returns the controlled ScalarField3D
+ def volume(self):
+ """Returns the controlled volume item
- :rtype: ScalarField3D
+ :rtype: Union[ScalarField3D,ComplexField3D]
"""
- return self._scalarField3D()
+ return self._volume()
def _isosurfaceAdded(self, item):
"""Handle isosurface addition
:param Isosurface item: added isosurface
"""
- scalarField3D = self.scalarField3D()
- if scalarField3D is None:
+ volume = self.volume()
+ if volume is None:
return
- row = scalarField3D.getIsosurfaces().index(item)
+ row = volume.getIsosurfaces().index(item)
+ if isinstance(volume, items.ComplexMixIn):
+ row += 1 # Offset for the ComplexModeRow
self.addRow(nodeFromItem(item), row)
def _isosurfaceRemoved(self, item):
@@ -1181,13 +1391,13 @@ class ScalarField3DIsoSurfacesRow(StaticRow):
:param Isosurface item: removed isosurface
"""
- scalarField3D = self.scalarField3D()
- if scalarField3D is None:
+ volume = self.volume()
+ if volume is None:
return
# Find item
for row in self.children():
- if row.item() is item:
+ if isinstance(row, IsosurfaceRow) and row.item() is item:
self.removeRow(row)
break # Got it
else:
@@ -1267,7 +1477,7 @@ class Scatter2DSymbolSizeRow(Scatter2DPropertyMixInRow, SymbolSizeRow):
Scatter2DPropertyMixInRow.__init__(self, item, 'symbolSize')
-class Scatter2DLineWidth(Scatter2DPropertyMixInRow, ProxyRow):
+class Scatter2DLineWidth(Scatter2DPropertyMixInRow, ItemProxyRow):
"""Specific class for Scatter2D symbol size.
It is enabled/disabled according to visualization mode.
@@ -1277,12 +1487,13 @@ class Scatter2DLineWidth(Scatter2DPropertyMixInRow, ProxyRow):
def __init__(self, item):
# TODO link editorHint with OpenGL max line width
- ProxyRow.__init__(self,
- name='Line width',
- fget=item.getLineWidth,
- fset=item.setLineWidth,
- notify=item.sigItemChanged,
- editorHint=(1, 10))
+ ItemProxyRow.__init__(self,
+ item=item,
+ name='Line width',
+ fget=item.getLineWidth,
+ fset=item.setLineWidth,
+ events=items.ItemChangedType.LINE_WIDTH,
+ editorHint=(1, 10))
Scatter2DPropertyMixInRow.__init__(self, item, 'lineWidth')
@@ -1292,20 +1503,22 @@ def initScatter2DNode(node, item):
:param Item3DRow node: The model node to setup
:param Scatter2D item: The Scatter2D the node is representing
"""
- node.addRow(ProxyRow(
+ node.addRow(ItemProxyRow(
+ item=item,
name='Mode',
fget=item.getVisualization,
fset=item.setVisualization,
- notify=item.sigItemChanged,
- editorHint=[m.title() for m in item.supportedVisualizations()],
- toModelData=lambda data: data.title(),
+ events=items.ItemChangedType.VISUALIZATION_MODE,
+ editorHint=[m.value.title() for m in item.supportedVisualizations()],
+ toModelData=lambda data: data.value.title(),
fromModelData=lambda data: data.lower()))
- node.addRow(ProxyRow(
+ node.addRow(ItemProxyRow(
+ item=item,
name='Height map',
fget=item.isHeightMap,
fset=item.setHeightMap,
- notify=item.sigItemChanged))
+ events=items.Item3DChangedType.HEIGHT_MAP))
node.addRow(ColormapRow(item))
@@ -1315,39 +1528,44 @@ def initScatter2DNode(node, item):
node.addRow(Scatter2DLineWidth(item))
-def initScalarField3DNode(node, item):
- """Specific node init for ScalarField3D
+def initVolumeNode(node, item):
+ """Specific node init for volume items
:param Item3DRow node: The model node to setup
- :param ScalarField3D item: The ScalarField3D the node is representing
+ :param Union[ScalarField3D,ComplexField3D] item:
+ The volume item represented by the node
"""
node.addRow(nodeFromItem(item.getCutPlanes()[0])) # Add cut plane
- node.addRow(ScalarField3DIsoSurfacesRow(item))
+ node.addRow(VolumeIsoSurfacesRow(item))
-def initScalarField3DCutPlaneNode(node, item):
- """Specific node init for ScalarField3D CutPlane
+def initVolumeCutPlaneNode(node, item):
+ """Specific node init for volume CutPlane
:param Item3DRow node: The model node to setup
:param CutPlane item: The CutPlane the node is representing
"""
+ if isinstance(item, items.ComplexMixIn):
+ node.addRow(ComplexModeRow(item))
+
node.addRow(PlaneRow(item))
node.addRow(ColormapRow(item))
- node.addRow(ProxyRow(
- name='Values<=Min',
+ node.addRow(ItemProxyRow(
+ item=item,
+ name='Show <=Min',
fget=item.getDisplayValuesBelowMin,
fset=item.setDisplayValuesBelowMin,
- notify=item.sigItemChanged))
+ events=items.ItemChangedType.ALPHA))
node.addRow(InterpolationRow(item))
NODE_SPECIFIC_INIT = [ # class, init(node, item)
(items.Scatter2D, initScatter2DNode),
- (items.ScalarField3D, initScalarField3DNode),
- (CutPlane, initScalarField3DCutPlaneNode),
+ (items.ScalarField3D, initVolumeNode),
+ (CutPlane, initVolumeCutPlaneNode),
]
"""List of specific node init for different item class"""
diff --git a/silx/gui/plot3d/actions/mode.py b/silx/gui/plot3d/actions/mode.py
index b591290..ce09b4c 100644
--- a/silx/gui/plot3d/actions/mode.py
+++ b/silx/gui/plot3d/actions/mode.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2017-2018 European Synchrotron Radiation Facility
+# Copyright (c) 2017-2019 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -24,7 +24,8 @@
# ###########################################################################*/
"""This module provides Plot3DAction related to interaction modes.
-It provides QAction to rotate or pan a Plot3DWidget.
+It provides QAction to rotate or pan a Plot3DWidget
+as well as toggle a picking mode.
"""
from __future__ import absolute_import, division
@@ -36,7 +37,9 @@ __date__ = "06/09/2017"
import logging
-from silx.gui.icons import getQIcon
+from ....utils.proxy import docstring
+from ... import qt
+from ...icons import getQIcon
from .Plot3DAction import Plot3DAction
@@ -69,6 +72,7 @@ class InteractiveModeAction(Plot3DAction):
plot3d.setInteractiveMode(self._interaction)
self.setChecked(True)
+ @docstring(Plot3DAction)
def setPlot3DWidget(self, widget):
# Disconnect from previous Plot3DWidget
plot3d = self.getPlot3DWidget()
@@ -86,9 +90,6 @@ class InteractiveModeAction(Plot3DAction):
widget.sigInteractiveModeChanged.connect(
self._interactiveModeChanged)
- # Reuse docstring from super class
- setPlot3DWidget.__doc__ = Plot3DAction.setPlot3DWidget.__doc__
-
def _interactiveModeChanged(self):
plot3d = self.getPlot3DWidget()
if plot3d is None:
@@ -127,3 +128,51 @@ class PanAction(InteractiveModeAction):
self.setIcon(getQIcon('pan'))
self.setText('Pan')
self.setToolTip('Pan the view. Press <b>Ctrl</b> to rotate.')
+
+
+class PickingModeAction(Plot3DAction):
+ """QAction to toggle picking moe on a Plot3DWidget
+
+ :param parent: See :class:`QAction`
+ :param ~silx.gui.plot3d.Plot3DWidget.Plot3DWidget plot3d:
+ Plot3DWidget the action is associated with
+ """
+
+ sigSceneClicked = qt.Signal(float, float)
+ """Signal emitted when the scene is clicked with the left mouse button.
+
+ This signal is only emitted when the action is checked.
+
+ It provides the (x, y) clicked mouse position
+ """
+
+ def __init__(self, parent, plot3d=None):
+ super(PickingModeAction, self).__init__(parent, plot3d)
+ self.setIcon(getQIcon('pointing-hand'))
+ self.setText('Picking')
+ self.setToolTip('Toggle picking with left button click')
+ self.setCheckable(True)
+ self.triggered[bool].connect(self._triggered)
+
+ def _triggered(self, checked=False):
+ plot3d = self.getPlot3DWidget()
+ if plot3d is not None:
+ if checked:
+ plot3d.sigSceneClicked.connect(self.sigSceneClicked)
+ else:
+ plot3d.sigSceneClicked.disconnect(self.sigSceneClicked)
+
+ @docstring(Plot3DAction)
+ def setPlot3DWidget(self, widget):
+ # Disconnect from previous Plot3DWidget
+ plot3d = self.getPlot3DWidget()
+ if plot3d is not None and self.isChecked():
+ plot3d.sigSceneClicked.disconnect(self.sigSceneClicked)
+
+ super(PickingModeAction, self).setPlot3DWidget(widget)
+
+ # Connect to new Plot3DWidget
+ if widget is None:
+ self.setChecked(False)
+ elif self.isChecked():
+ widget.sigSceneClicked.connect(self.sigSceneClicked)
diff --git a/silx/gui/plot3d/items/__init__.py b/silx/gui/plot3d/items/__init__.py
index 58eee9c..5810618 100644
--- a/silx/gui/plot3d/items/__init__.py
+++ b/silx/gui/plot3d/items/__init__.py
@@ -34,10 +34,10 @@ __date__ = "15/11/2017"
from .core import DataItem3D, Item3D, GroupItem, GroupWithAxesItem # noqa
from .core import ItemChangedType, Item3DChangedType # noqa
-from .mixins import (ColormapMixIn, InterpolationMixIn, # noqa
+from .mixins import (ColormapMixIn, ComplexMixIn, InterpolationMixIn, # noqa
PlaneMixIn, SymbolMixIn) # noqa
from .clipplane import ClipPlane # noqa
from .image import ImageData, ImageRgba # noqa
from .mesh import Mesh, ColormapMesh, Box, Cylinder, Hexagon # noqa
from .scatter import Scatter2D, Scatter3D # noqa
-from .volume import ScalarField3D # noqa
+from .volume import ComplexField3D, ScalarField3D # noqa
diff --git a/silx/gui/plot3d/items/mesh.py b/silx/gui/plot3d/items/mesh.py
index d3f5e38..3577dbf 100644
--- a/silx/gui/plot3d/items/mesh.py
+++ b/silx/gui/plot3d/items/mesh.py
@@ -35,6 +35,7 @@ __date__ = "17/07/2018"
import logging
import numpy
+from ... import _glutils as glu
from ..scene import primitives, utils, function
from ..scene.transform import Rotate
from .core import DataItem3D, ItemChangedType
@@ -168,7 +169,7 @@ class _MeshBase(DataItem3D):
_logger.warning("Unsupported draw mode: %s" % mode)
return None
- trianglesIndices, t, barycentric = utils.segmentTrianglesIntersection(
+ trianglesIndices, t, barycentric = glu.segmentTrianglesIntersection(
rayObject, triangles)
if len(trianglesIndices) == 0:
@@ -494,7 +495,7 @@ class _CylindricalVolume(DataItem3D):
positions = self._mesh.getAttribute('position', copy=False)
triangles = positions.reshape(-1, 3, 3) # 'triangle' draw mode
- trianglesIndices, t = utils.segmentTrianglesIntersection(
+ trianglesIndices, t = glu.segmentTrianglesIntersection(
rayObject, triangles)[:2]
if len(trianglesIndices) == 0:
diff --git a/silx/gui/plot3d/items/mixins.py b/silx/gui/plot3d/items/mixins.py
index 40b8438..b355627 100644
--- a/silx/gui/plot3d/items/mixins.py
+++ b/silx/gui/plot3d/items/mixins.py
@@ -38,6 +38,7 @@ from silx.math.combo import min_max
from ...plot.items.core import ItemMixInBase
from ...plot.items.core import ColormapMixIn as _ColormapMixIn
from ...plot.items.core import SymbolMixIn as _SymbolMixIn
+from ...plot.items.core import ComplexMixIn as _ComplexMixIn
from ...colors import rgba
from ..scene import primitives
@@ -139,8 +140,9 @@ class ColormapMixIn(_ColormapMixIn):
self._dataRange = dataRange
- if self.getColormap().isAutoscale():
- self._syncSceneColormap()
+ colormap = self.getColormap()
+ if None in (colormap.getVMin(), colormap.getVMax()):
+ self._colormapChanged()
def _getDataRange(self):
"""Returns the data range as used in the scene for colormap
@@ -173,6 +175,18 @@ class ColormapMixIn(_ColormapMixIn):
self.__sceneColormap.range_ = range_
+class ComplexMixIn(_ComplexMixIn):
+ __doc__ = _ComplexMixIn.__doc__ # Reuse docstring
+
+ _SUPPORTED_COMPLEX_MODES = (
+ _ComplexMixIn.ComplexMode.REAL,
+ _ComplexMixIn.ComplexMode.IMAGINARY,
+ _ComplexMixIn.ComplexMode.ABSOLUTE,
+ _ComplexMixIn.ComplexMode.PHASE,
+ _ComplexMixIn.ComplexMode.SQUARE_AMPLITUDE)
+ """Overrides supported ComplexMode"""
+
+
class SymbolMixIn(_SymbolMixIn):
"""Mix-in class for symbol and symbolSize properties for Item3D"""
diff --git a/silx/gui/plot3d/items/scatter.py b/silx/gui/plot3d/items/scatter.py
index b7bcd09..e8ffee1 100644
--- a/silx/gui/plot3d/items/scatter.py
+++ b/silx/gui/plot3d/items/scatter.py
@@ -31,14 +31,19 @@ __authors__ = ["T. Vincent"]
__license__ = "MIT"
__date__ = "15/11/2017"
-import collections
+try:
+ from collections import abc
+except ImportError: # Python2 support
+ import collections as abc
import logging
-import sys
import numpy
from ....utils.deprecation import deprecated
+from ... import _glutils as glu
+from ...plot._utils.delaunay import delaunay
from ..scene import function, primitives, utils
+from ...plot.items import ScatterVisualizationMixIn
from .core import DataItem3D, Item3DChangedType, ItemChangedType
from .mixins import ColormapMixIn, SymbolMixIn
from ._pick import PickingResult
@@ -213,16 +218,19 @@ class Scatter3D(DataItem3D, ColormapMixIn, SymbolMixIn):
return None
-class Scatter2D(DataItem3D, ColormapMixIn, SymbolMixIn):
+class Scatter2D(DataItem3D, ColormapMixIn, SymbolMixIn,
+ ScatterVisualizationMixIn):
"""2D scatter data with settable visualization mode.
:param parent: The View widget this item belongs to.
"""
_VISUALIZATION_PROPERTIES = {
- 'points': ('symbol', 'symbolSize'),
- 'lines': ('lineWidth',),
- 'solid': (),
+ ScatterVisualizationMixIn.Visualization.POINTS:
+ ('symbol', 'symbolSize'),
+ ScatterVisualizationMixIn.Visualization.LINES:
+ ('lineWidth',),
+ ScatterVisualizationMixIn.Visualization.SOLID: (),
}
"""Dict {visualization mode: property names used in this mode}"""
@@ -230,8 +238,8 @@ class Scatter2D(DataItem3D, ColormapMixIn, SymbolMixIn):
DataItem3D.__init__(self, parent=parent)
ColormapMixIn.__init__(self)
SymbolMixIn.__init__(self)
+ ScatterVisualizationMixIn.__init__(self)
- self._visualizationMode = 'points'
self._heightMap = False
self._lineWidth = 1.
@@ -254,48 +262,14 @@ class Scatter2D(DataItem3D, ColormapMixIn, SymbolMixIn):
child.marker = symbol
child.setAttribute('size', size, copy=True)
- elif event == ItemChangedType.VISIBLE:
+ elif event is ItemChangedType.VISIBLE:
# TODO smart update?, need dirty flags
self._updateScene()
- super(Scatter2D, self)._updated(event)
-
- def supportedVisualizations(self):
- """Returns the list of supported visualization modes.
-
- See :meth:`setVisualizationModes`
-
- :rtype: tuple of str
- """
- return tuple(self._VISUALIZATION_PROPERTIES.keys())
-
- def setVisualization(self, mode):
- """Set the visualization mode of the data.
-
- Supported visualization modes are:
-
- - 'points': For scatter plot representation
- - 'lines': For Delaunay tessellation-based wireframe representation
- - 'solid': For Delaunay tessellation-based solid surface representation
-
- :param str mode: Mode of representation to use
- """
- mode = str(mode)
- assert mode in self.supportedVisualizations()
-
- if mode != self.getVisualization():
- self._visualizationMode = mode
+ elif event is ItemChangedType.VISUALIZATION_MODE:
self._updateScene()
- self._updated(ItemChangedType.VISUALIZATION_MODE)
- def getVisualization(self):
- """Returns the current visualization mode.
-
- See :meth:`setVisualization`
-
- :rtype: str
- """
- return self._visualizationMode
+ super(Scatter2D, self)._updated(event)
def isPropertyEnabled(self, name, visualization=None):
"""Returns true if the property is used with visualization mode.
@@ -374,7 +348,7 @@ class Scatter2D(DataItem3D, ColormapMixIn, SymbolMixIn):
y, copy=copy, dtype=numpy.float32, order='C').reshape(-1)
assert len(x) == len(y)
- if isinstance(value, collections.Iterable):
+ if isinstance(value, abc.Iterable):
value = numpy.array(
value, copy=copy, dtype=numpy.float32, order='C').reshape(-1)
assert len(value) == len(x)
@@ -503,7 +477,7 @@ class Scatter2D(DataItem3D, ColormapMixIn, SymbolMixIn):
trianglesIndices = self._cachedTrianglesIndices.reshape(-1, 3)
triangles = points[trianglesIndices, :3]
- selectedIndices, t, barycentric = utils.segmentTrianglesIntersection(
+ selectedIndices, t, barycentric = glu.segmentTrianglesIntersection(
rayObject, triangles)
closest = numpy.argmax(barycentric, axis=1)
@@ -542,14 +516,14 @@ class Scatter2D(DataItem3D, ColormapMixIn, SymbolMixIn):
numpy.ones_like(xData)))
mode = self.getVisualization()
- if mode == 'points':
+ if mode is self.Visualization.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':
+ elif mode is self.Visualization.LINES:
# Picking only at point
return self._pickPoints(context, points, threshold=5.)
@@ -569,7 +543,7 @@ class Scatter2D(DataItem3D, ColormapMixIn, SymbolMixIn):
mode = self.getVisualization()
heightMap = self.isHeightMap()
- if mode == 'points':
+ if mode is self.Visualization.POINTS:
z = value if heightMap else 0.
symbol, size = self._getSceneSymbol()
primitive = primitives.Points(
@@ -582,35 +556,19 @@ class Scatter2D(DataItem3D, ColormapMixIn, SymbolMixIn):
# TODO run delaunay in a thread
# Compute lines/triangles indices if not cached
if self._cachedTrianglesIndices is None:
- coordinates = numpy.array((x, y)).T
-
- if len(coordinates) > 3:
- # Enough points to try a Delaunay tesselation
-
- # Lazy loading of Delaunay
- from silx.third_party.scipy_spatial import Delaunay as _Delaunay
-
- try:
- tri = _Delaunay(coordinates)
- except RuntimeError:
- _logger.error("Delaunay tesselation failed: %s",
- sys.exc_info()[1])
- return None
-
- self._cachedTrianglesIndices = numpy.ravel(
- tri.simplices.astype(numpy.uint32))
-
- else:
- # 3 or less points: Draw one triangle
- self._cachedTrianglesIndices = \
- numpy.arange(3, dtype=numpy.uint32) % len(coordinates)
-
- if mode == 'lines' and self._cachedLinesIndices is None:
+ triangulation = delaunay(x, y)
+ if triangulation is None:
+ return None
+ self._cachedTrianglesIndices = numpy.ravel(
+ triangulation.simplices.astype(numpy.uint32))
+
+ if (mode is self.Visualization.LINES and
+ self._cachedLinesIndices is None):
# Compute line indices
self._cachedLinesIndices = utils.triangleToLineIndices(
self._cachedTrianglesIndices, unicity=True)
- if mode == 'lines':
+ if mode is self.Visualization.LINES:
indices = self._cachedLinesIndices
renderMode = 'lines'
else:
@@ -627,7 +585,7 @@ class Scatter2D(DataItem3D, ColormapMixIn, SymbolMixIn):
# TODO option to enable/disable light, cache normals
# TODO smooth surface
- if mode == 'solid':
+ if mode is self.Visualization.SOLID:
if heightMap:
coordinates = coordinates[indices]
if len(value) > 1:
diff --git a/silx/gui/plot3d/items/volume.py b/silx/gui/plot3d/items/volume.py
index 08ad02a..ae91e82 100644
--- a/silx/gui/plot3d/items/volume.py
+++ b/silx/gui/plot3d/items/volume.py
@@ -38,13 +38,15 @@ import numpy
from silx.math.combo import min_max
from silx.math.marchingcubes import MarchingCubes
+from ....utils.proxy import docstring
+from ... import _glutils as glu
from ... import qt
from ...colors import rgba
from ..scene import cutplane, primitives, transform, utils
from .core import BaseNodeItem, Item3D, ItemChangedType, Item3DChangedType
-from .mixins import ColormapMixIn, InterpolationMixIn, PlaneMixIn
+from .mixins import ColormapMixIn, ComplexMixIn, InterpolationMixIn, PlaneMixIn
from ._pick import PickingResult
@@ -60,12 +62,13 @@ class CutPlane(Item3D, ColormapMixIn, InterpolationMixIn, PlaneMixIn):
def __init__(self, parent):
plane = cutplane.CutPlane(normal=(0, 1, 0))
- Item3D.__init__(self, parent=parent)
+ Item3D.__init__(self, parent=None)
ColormapMixIn.__init__(self)
InterpolationMixIn.__init__(self)
PlaneMixIn.__init__(self, plane=plane)
self._dataRange = None
+ self._data = None
self._getScenePrimitive().children = [plane]
@@ -73,20 +76,53 @@ class CutPlane(Item3D, ColormapMixIn, InterpolationMixIn, PlaneMixIn):
ColormapMixIn._setSceneColormap(self, plane.colormap)
InterpolationMixIn._setPrimitive(self, plane)
- parent.sigItemChanged.connect(self._parentChanged)
+ self.setParent(parent)
+
+ def _updateData(self, data, range_):
+ """Update used dataset
+
+ No copy is made.
+
+ :param Union[numpy.ndarray[float],None] data: The dataset
+ :param Union[List[float],None] range_:
+ (min, min positive, max) values
+ """
+ self._data = None if data is None else numpy.array(data, copy=False)
+ self._getPlane().setData(self._data, copy=False)
+
+ # Store data range info as 3-tuple of values
+ self._dataRange = range_
+ self._setRangeFromData(
+ None if self._dataRange is None else numpy.array(self._dataRange))
+
+ self._updated(ItemChangedType.DATA)
+
+ def _syncDataWithParent(self):
+ """Synchronize this instance data with that of its parent"""
+ parent = self.parent()
+ if parent is None:
+ data, range_ = None, None
+ else:
+ data = parent.getData(copy=False)
+ range_ = parent.getDataRange()
+ self._updateData(data, range_)
def _parentChanged(self, event):
"""Handle data change in the parent this plane belongs to"""
if event == ItemChangedType.DATA:
- data = self.sender().getData(copy=False)
- self._getPlane().setData(data, copy=False)
+ self._syncDataWithParent()
+
+ def setParent(self, parent):
+ oldParent = self.parent()
+ if isinstance(oldParent, Item3D):
+ oldParent.sigItemChanged.disconnect(self._parentChanged)
- # Store data range info as 3-tuple of values
- self._dataRange = self.sender().getDataRange()
- self._setRangeFromData(
- None if self._dataRange is None else numpy.array(self._dataRange))
+ super(CutPlane, self).setParent(parent)
- self._updated(ItemChangedType.DATA)
+ if isinstance(parent, Item3D):
+ parent.sigItemChanged.connect(self._parentChanged)
+
+ self._syncDataWithParent()
# Colormap
@@ -114,8 +150,9 @@ class CutPlane(Item3D, ColormapMixIn, InterpolationMixIn, PlaneMixIn):
positive min is NaN if no data is positive.
:return: (min, positive min, max) or None.
+ :rtype: Union[List[float],None]
"""
- return self._dataRange
+ return None if self._dataRange is None else tuple(self._dataRange)
def getData(self, copy=True):
"""Return 3D dataset.
@@ -125,8 +162,10 @@ class CutPlane(Item3D, ColormapMixIn, InterpolationMixIn, PlaneMixIn):
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)
+ if self._data is None:
+ return None
+ else:
+ return numpy.array(self._data, copy=copy)
def _pickFull(self, context):
"""Perform picking in this item at given widget position.
@@ -172,18 +211,38 @@ class Isosurface(Item3D):
"""
def __init__(self, parent):
- Item3D.__init__(self, parent=parent)
- assert isinstance(parent, ScalarField3D)
- parent.sigItemChanged.connect(self._scalarField3DChanged)
+ Item3D.__init__(self, parent=None)
+ self._data = None
self._level = float('nan')
self._autoLevelFunction = None
self._color = rgba('#FFD700FF')
+ self.setParent(parent)
+
+ def _syncDataWithParent(self):
+ """Synchronize this instance data with that of its parent"""
+ parent = self.parent()
+ if parent is None:
+ self._data = None
+ else:
+ self._data = parent.getData(copy=False)
self._updateScenePrimitive()
- def _scalarField3DChanged(self, event):
- """Handle parent's ScalarField3D sigItemChanged"""
+ def _parentChanged(self, event):
+ """Handle data change in the parent this isosurface belongs to"""
if event == ItemChangedType.DATA:
- self._updateScenePrimitive()
+ self._syncDataWithParent()
+
+ def setParent(self, parent):
+ oldParent = self.parent()
+ if isinstance(oldParent, Item3D):
+ oldParent.sigItemChanged.disconnect(self._parentChanged)
+
+ super(Isosurface, self).setParent(parent)
+
+ if isinstance(parent, Item3D):
+ parent.sigItemChanged.connect(self._parentChanged)
+
+ self._syncDataWithParent()
def getData(self, copy=True):
"""Return 3D dataset.
@@ -193,8 +252,10 @@ class Isosurface(Item3D):
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)
+ if self._data is None:
+ return None
+ else:
+ return numpy.array(self._data, copy=copy)
def getLevel(self):
"""Return the level of this iso-surface (float)"""
@@ -349,7 +410,7 @@ class Isosurface(Item3D):
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 = glu.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
@@ -372,6 +433,12 @@ class ScalarField3D(BaseNodeItem):
:param parent: The View widget this item belongs to.
"""
+ _CutPlane = CutPlane
+ """CutPlane class associated to this class"""
+
+ _Isosurface = Isosurface
+ """Isosurface classe associated to this class"""
+
def __init__(self, parent=None):
BaseNodeItem.__init__(self, parent=parent)
@@ -385,7 +452,7 @@ class ScalarField3D(BaseNodeItem):
self._data = None
self._dataRange = None
- self._cutPlane = CutPlane(parent=self)
+ self._cutPlane = self._CutPlane(parent=self)
self._cutPlane.setVisible(False)
self._isogroup = primitives.GroupDepthOffset()
@@ -405,6 +472,26 @@ class ScalarField3D(BaseNodeItem):
self._cutPlane._getScenePrimitive(),
self._isogroup]
+ @staticmethod
+ def _computeRangeFromData(data):
+ """Compute range info (min, min positive, max) from data
+
+ :param Union[numpy.ndarray,None] data:
+ :return: Union[List[float],None]
+ """
+ if data is None:
+ return None
+
+ dataRange = min_max(data, min_positive=True, finite=True)
+ if dataRange.minimum is None: # Only non-finite data
+ return None
+
+ if dataRange is not None:
+ min_positive = dataRange.min_positive
+ if min_positive is None:
+ min_positive = float('nan')
+ return dataRange.minimum, min_positive, dataRange.maximum
+
def setData(self, data, copy=True):
"""Set the 3D scalar data represented by this item.
@@ -418,7 +505,6 @@ class ScalarField3D(BaseNodeItem):
"""
if data is None:
self._data = None
- self._dataRange = None
self._boundedGroup.shape = None
else:
@@ -427,21 +513,9 @@ class ScalarField3D(BaseNodeItem):
assert min(data.shape) >= 2
self._data = data
-
- # Store data range info
- dataRange = min_max(self._data, min_positive=True, finite=True)
- if dataRange.minimum is None: # Only non-finite data
- dataRange = None
-
- if dataRange is not None:
- min_positive = dataRange.min_positive
- if min_positive is None:
- min_positive = float('nan')
- dataRange = dataRange.minimum, min_positive, dataRange.maximum
- self._dataRange = dataRange
-
self._boundedGroup.shape = self._data.shape
+ self._dataRange = self._computeRangeFromData(self._data)
self._updated(ItemChangedType.DATA)
def getData(self, copy=True):
@@ -506,7 +580,7 @@ class ScalarField3D(BaseNodeItem):
:return: isosurface object
:rtype: ~silx.gui.plot3d.items.volume.Isosurface
"""
- isosurface = Isosurface(parent=self)
+ isosurface = self._Isosurface(parent=self)
isosurface.setColor(color)
if callable(level):
isosurface.setAutoLevelFunction(level)
@@ -561,8 +635,164 @@ class ScalarField3D(BaseNodeItem):
# BaseNodeItem
def getItems(self):
- """Returns the list of items currently present in the ScalarField3D.
+ """Returns the list of items currently present in this item.
:rtype: tuple
"""
return self.getCutPlanes() + self.getIsosurfaces()
+
+
+##################
+# ComplexField3D #
+##################
+
+class ComplexCutPlane(CutPlane, ComplexMixIn):
+ """Class representing a cutting plane in a :class:`ComplexField3D` item.
+
+ :param parent: 3D Data set in which the cut plane is applied.
+ """
+
+ def __init__(self, parent):
+ ComplexMixIn.__init__(self)
+ CutPlane.__init__(self, parent=parent)
+
+ def _syncDataWithParent(self):
+ """Synchronize this instance data with that of its parent"""
+ parent = self.parent()
+ if parent is None:
+ data, range_ = None, None
+ else:
+ mode = self.getComplexMode()
+ data = parent.getData(mode=mode, copy=False)
+ range_ = parent.getDataRange(mode=mode)
+ self._updateData(data, range_)
+
+ def _updated(self, event=None):
+ """Handle update of the cut plane (and take care of mode change
+
+ :param Union[None,ItemChangedType] event: The kind of update
+ """
+ if event == ItemChangedType.COMPLEX_MODE:
+ self._syncDataWithParent()
+ super(ComplexCutPlane, self)._updated(event)
+
+
+class ComplexIsosurface(Isosurface):
+ """Class representing an iso-surface in a :class:`ComplexField3D` item.
+
+ :param parent: The DataItem3D this iso-surface belongs to
+ """
+
+ def __init__(self, parent):
+ super(ComplexIsosurface, self).__init__(parent)
+
+ def _syncDataWithParent(self):
+ """Synchronize this instance data with that of its parent"""
+ parent = self.parent()
+ if parent is None:
+ self._data = None
+ else:
+ self._data = parent.getData(
+ mode=parent.getComplexMode(), copy=False)
+ self._updateScenePrimitive()
+
+ def _parentChanged(self, event):
+ """Handle data change in the parent this isosurface belongs to"""
+ if event == ItemChangedType.COMPLEX_MODE:
+ self._syncDataWithParent()
+ super(ComplexIsosurface, self)._parentChanged(event)
+
+
+class ComplexField3D(ScalarField3D, ComplexMixIn):
+ """3D complex field on a regular grid.
+
+ :param parent: The View widget this item belongs to.
+ """
+
+ _CutPlane = ComplexCutPlane
+ _Isosurface = ComplexIsosurface
+
+ def __init__(self, parent=None):
+ self._dataRangeCache = None
+
+ ComplexMixIn.__init__(self)
+ ScalarField3D.__init__(self, parent=parent)
+
+ @docstring(ComplexMixIn)
+ def setComplexMode(self, mode):
+ if mode != self.getComplexMode():
+ self.clearIsosurfaces() # Reset isosurfaces
+ ComplexMixIn.setComplexMode(self, mode)
+
+ def setData(self, data, copy=True):
+ """Set the 3D complex data represented by this item.
+
+ Dataset order is zyx (i.e., first dimension is z).
+
+ :param data: 3D array
+ :type data: 3D numpy.ndarray of float32 with shape at least (2, 2, 2)
+ :param bool copy:
+ True (default) to make a copy,
+ False to avoid copy (DO NOT MODIFY data afterwards)
+ """
+ if data is None:
+ self._data = None
+ self._dataRangeCache = None
+ self._boundedGroup.shape = None
+
+ else:
+ data = numpy.array(data, copy=copy, dtype=numpy.complex64, order='C')
+ assert data.ndim == 3
+ assert min(data.shape) >= 2
+
+ self._data = data
+ self._dataRangeCache = {}
+ self._boundedGroup.shape = self._data.shape
+
+ self._updated(ItemChangedType.DATA)
+
+ def getData(self, copy=True, mode=None):
+ """Return 3D dataset.
+
+ This method does not cache data converted to a specific mode,
+ it computes it for each request.
+
+ :param bool copy:
+ True (default) to get a copy,
+ False to get the internal data (DO NOT modify!)
+ :param Union[None,Mode] mode:
+ The kind of data to retrieve.
+ If None (the default), it returns the complex data,
+ else it computes the requested scalar data.
+ :return: The data set (or None if not set)
+ :rtype: Union[numpy.ndarray,None]
+ """
+ if mode is None:
+ return super(ComplexField3D, self).getData(copy=copy)
+ else:
+ return self._convertComplexData(self._data, mode)
+
+ def getDataRange(self, mode=None):
+ """Return the range of the requested data as a 3-tuple of values.
+
+ Positive min is NaN if no data is positive.
+
+ :param Union[None,Mode] mode:
+ The kind of data for which to get the range information.
+ If None (the default), it returns the data range for the current mode,
+ else it returns the data range for the requested mode.
+ :return: (min, positive min, max) or None.
+ :rtype: Union[None,List[float]]
+ """
+ if self._dataRangeCache is None:
+ return None
+
+ if mode is None:
+ mode = self.getComplexMode()
+
+ if mode not in self._dataRangeCache:
+ # Compute it and store it in cache
+ data = self.getData(copy=False, mode=mode)
+ self._dataRangeCache[mode] = self._computeRangeFromData(data)
+
+ return self._dataRangeCache[mode]
diff --git a/silx/gui/plot3d/scene/camera.py b/silx/gui/plot3d/scene/camera.py
index acc5899..90de7ed 100644
--- a/silx/gui/plot3d/scene/camera.py
+++ b/silx/gui/plot3d/scene/camera.py
@@ -292,6 +292,8 @@ class Camera(transform.Transform):
center = 0.5 * (bounds[0] + bounds[1])
radius = numpy.linalg.norm(0.5 * (bounds[1] - bounds[0]))
+ if radius == 0.: # bounds are all collapsed
+ radius = 1.
if isinstance(self.intrinsic, transform.Perspective):
# Get the viewpoint distance from the bounds center
diff --git a/silx/gui/plot3d/scene/core.py b/silx/gui/plot3d/scene/core.py
index a293f28..43838fe 100644
--- a/silx/gui/plot3d/scene/core.py
+++ b/silx/gui/plot3d/scene/core.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2015-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2015-2019 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -112,6 +112,15 @@ class Base(event.Notifier):
return root if isinstance(root, Viewport) else None
@property
+ def root(self):
+ """The root node of the scene.
+
+ If attached to a :class:`Viewport`, this is the item right under it
+ """
+ path = self.path
+ return path[1] if isinstance(path[0], Viewport) else path[0]
+
+ @property
def objectToNDCTransform(self):
"""Transform from object to normalized device coordinates.
diff --git a/silx/gui/plot3d/scene/cutplane.py b/silx/gui/plot3d/scene/cutplane.py
index 08a9899..81c74c7 100644
--- a/silx/gui/plot3d/scene/cutplane.py
+++ b/silx/gui/plot3d/scene/cutplane.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2016-2018 European Synchrotron Radiation Facility
+# Copyright (c) 2016-2019 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -79,19 +79,20 @@ class ColormapMesh3D(Geometry):
uniform float alpha;
$colormapDecl
-
- $clippingDecl
+ $sceneDecl
$lightingFunction
void main(void)
{
+ $scenePreCall(vCameraPosition);
+
float value = texture3D(data, vTexCoords).r;
vec4 color = $colormapCall(value);
color.a = alpha;
- $clippingCall(vCameraPosition);
-
gl_FragColor = $lightingCall(color, vPosition, vNormal);
+
+ $scenePostCall(vCameraPosition);
}
"""))
@@ -186,8 +187,9 @@ class ColormapMesh3D(Geometry):
def renderGL2(self, ctx):
fragment = self._shaders[1].substitute(
- clippingDecl=ctx.clipper.fragDecl,
- clippingCall=ctx.clipper.fragCall,
+ sceneDecl=ctx.fragDecl,
+ scenePreCall=ctx.fragCallPre,
+ scenePostCall=ctx.fragCallPost,
lightingFunction=ctx.viewport.light.fragmentDef,
lightingCall=ctx.viewport.light.fragmentCall,
colormapDecl=self.colormap.decl,
@@ -216,7 +218,7 @@ class ColormapMesh3D(Geometry):
gl.glUniform1i(program.uniforms['data'], self._texture.texUnit)
- ctx.clipper.setupProgram(ctx, program)
+ ctx.setupProgram(program)
self._texture.bind()
self._draw(program)
diff --git a/silx/gui/plot3d/scene/function.py b/silx/gui/plot3d/scene/function.py
index 2921d48..7651f75 100644
--- a/silx/gui/plot3d/scene/function.py
+++ b/silx/gui/plot3d/scene/function.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2015-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2015-2019 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -60,6 +60,91 @@ class ProgramFunction(object):
pass
+class Fog(event.Notifier, ProgramFunction):
+ """Linear fog over the whole scene content.
+
+ The background of the viewport is used as fog color,
+ otherwise it defaults to white.
+ """
+ # TODO: add more controls (set fog range), add more fog modes
+
+ _fragDecl = """
+ /* (1/(far - near) or 0, near) z in [0 (camera), -inf[ */
+ uniform vec2 fogExtentInfo;
+
+ /* Color to use as fog color */
+ uniform vec3 fogColor;
+
+ vec4 fog(vec4 color, vec4 cameraPosition) {
+ /* d = (pos - near) / (far - near) */
+ float distance = fogExtentInfo.x * (cameraPosition.z/cameraPosition.w - fogExtentInfo.y);
+ float fogFactor = clamp(distance, 0.0, 1.0);
+ vec3 rgb = mix(color.rgb, fogColor, fogFactor);
+ return vec4(rgb.r, rgb.g, rgb.b, color.a);
+ }
+ """
+
+ _fragDeclNoop = """
+ vec4 fog(vec4 color, vec4 cameraPosition) {
+ return color;
+ }
+ """
+
+ def __init__(self):
+ super(Fog, self).__init__()
+ self._isOn = True
+
+ @property
+ def isOn(self):
+ """True to enable fog, False to disable (bool)"""
+ return self._isOn
+
+ @isOn.setter
+ def isOn(self, isOn):
+ isOn = bool(isOn)
+ if self._isOn != isOn:
+ self._isOn = bool(isOn)
+ self.notify()
+
+ @property
+ def fragDecl(self):
+ return self._fragDecl if self.isOn else self._fragDeclNoop
+
+ @property
+ def fragCall(self):
+ return "fog"
+
+ @staticmethod
+ def _zExtentCamera(viewport):
+ """Return (far, near) planes Z in camera coordinates.
+
+ :param Viewport viewport:
+ :return: (far, near) position in camera coords (from 0 to -inf)
+ """
+ # Provide scene z extent in camera coords
+ bounds = viewport.camera.extrinsic.transformBounds(
+ viewport.scene.bounds(transformed=True, dataBounds=True))
+ return bounds[:, 2]
+
+ def setupProgram(self, context, program):
+ if not self.isOn:
+ return
+
+ far, near = context.cache(key='zExtentCamera',
+ factory=self._zExtentCamera,
+ viewport=context.viewport)
+ extent = far - near
+ gl.glUniform2f(program.uniforms['fogExtentInfo'],
+ 0.9/extent if extent != 0. else 0.,
+ near)
+
+ # Use background color as fog color
+ bgColor = context.viewport.background
+ if bgColor is None:
+ bgColor = 1., 1., 1.
+ gl.glUniform3f(program.uniforms['fogColor'], *bgColor[:3])
+
+
class ClippingPlane(ProgramFunction):
"""Description of a clipping plane and rendering.
diff --git a/silx/gui/plot3d/scene/interaction.py b/silx/gui/plot3d/scene/interaction.py
index e5cfb6d..14a54dc 100644
--- a/silx/gui/plot3d/scene/interaction.py
+++ b/silx/gui/plot3d/scene/interaction.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2015-2018 European Synchrotron Radiation Facility
+# Copyright (c) 2015-2019 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -43,11 +43,11 @@ from . import transform
_logger = logging.getLogger(__name__)
-# ClickOrDrag #################################################################
-
-# TODO merge with silx.gui.plot.Interaction.ClickOrDrag
class ClickOrDrag(StateMachine):
- """Click or drag interaction for a given button."""
+ """Click or drag interaction for a given button.
+
+ """
+ #TODO: merge this class with silx.gui.plot.Interaction.ClickOrDrag
DRAG_THRESHOLD_SQUARE_DIST = 5 ** 2
@@ -126,23 +126,29 @@ class ClickOrDrag(StateMachine):
pass
-# CameraRotate ################################################################
-
-class CameraRotate(ClickOrDrag):
+class CameraSelectRotate(ClickOrDrag):
"""Camera rotation using an arcball-like interaction."""
- def __init__(self, viewport, orbitAroundCenter=True, button=RIGHT_BTN):
+ def __init__(self, viewport, orbitAroundCenter=True, button=RIGHT_BTN,
+ selectCB=None):
self._viewport = viewport
self._orbitAroundCenter = orbitAroundCenter
+ self._selectCB = selectCB
self._reset()
- super(CameraRotate, self).__init__(button)
+ super(CameraSelectRotate, self).__init__(button)
def _reset(self):
self._origin, self._center = None, None
self._startExtrinsic = None
def click(self, x, y):
- pass # No interaction yet
+ if self._selectCB is not None:
+ ndcZ = self._viewport._pickNdcZGL(x, y)
+ position = self._viewport._getXZYGL(x, y)
+ # This assume no object lie on the far plane
+ # Alternative, change the depth range so that far is < 1
+ if ndcZ != 1. and position is not None:
+ self._selectCB((x, y, ndcZ), position)
def beginDrag(self, x, y):
centerPos = None
@@ -205,8 +211,6 @@ class CameraRotate(ClickOrDrag):
self._reset()
-# CameraSelectPan #############################################################
-
class CameraSelectPan(ClickOrDrag):
"""Picking on click and pan camera on drag."""
@@ -259,8 +263,6 @@ class CameraSelectPan(ClickOrDrag):
self._lastPosNdc = None
-# CameraWheel #################################################################
-
class CameraWheel(object):
"""StateMachine like class, just handling wheel events."""
@@ -371,8 +373,6 @@ class CameraWheel(object):
return True
-# FocusManager ################################################################
-
class FocusManager(StateMachine):
"""Manages focus across multiple event handlers
@@ -449,8 +449,6 @@ class FocusManager(StateMachine):
handler.cancel()
-# CameraControl ###############################################################
-
class RotateCameraControl(FocusManager):
"""Combine wheel and rotate state machine for left button
and pan when ctrl is pressed
@@ -460,7 +458,8 @@ class RotateCameraControl(FocusManager):
mode='center', scaleTransform=None,
selectCB=None):
handlers = (CameraWheel(viewport, mode, scaleTransform),
- CameraRotate(viewport, orbitAroundCenter, LEFT_BTN))
+ CameraSelectRotate(
+ viewport, orbitAroundCenter, LEFT_BTN, selectCB))
ctrlHandlers = (CameraWheel(viewport, mode, scaleTransform),
CameraSelectPan(viewport, LEFT_BTN, selectCB))
super(RotateCameraControl, self).__init__(handlers, ctrlHandlers)
@@ -476,7 +475,8 @@ class PanCameraControl(FocusManager):
handlers = (CameraWheel(viewport, mode, scaleTransform),
CameraSelectPan(viewport, LEFT_BTN, selectCB))
ctrlHandlers = (CameraWheel(viewport, mode, scaleTransform),
- CameraRotate(viewport, orbitAroundCenter, LEFT_BTN))
+ CameraSelectRotate(
+ viewport, orbitAroundCenter, LEFT_BTN, selectCB))
super(PanCameraControl, self).__init__(handlers, ctrlHandlers)
@@ -488,12 +488,11 @@ class CameraControl(FocusManager):
selectCB=None):
handlers = (CameraWheel(viewport, mode, scaleTransform),
CameraSelectPan(viewport, LEFT_BTN, selectCB),
- CameraRotate(viewport, orbitAroundCenter, RIGHT_BTN))
+ CameraSelectRotate(
+ viewport, orbitAroundCenter, RIGHT_BTN, selectCB))
super(CameraControl, self).__init__(handlers)
-# PlaneRotate #################################################################
-
class PlaneRotate(ClickOrDrag):
"""Plane rotation using arcball interaction.
@@ -603,8 +602,6 @@ class PlaneRotate(ClickOrDrag):
self._reset()
-# PlanePan ###################################################################
-
class PlanePan(ClickOrDrag):
"""Pan a plane along its normal on drag."""
@@ -668,8 +665,6 @@ class PlanePan(ClickOrDrag):
self._beginPlanePoint = None
-# PlaneControl ################################################################
-
class PlaneControl(FocusManager):
"""Combine wheel, selectPan and rotate state machine for plane control."""
def __init__(self, viewport, plane,
@@ -686,9 +681,9 @@ class PanPlaneRotateCameraControl(FocusManager):
mode='center', scaleTransform=None):
handlers = (CameraWheel(viewport, mode, scaleTransform),
PlanePan(viewport, plane, LEFT_BTN),
- CameraRotate(viewport,
- orbitAroundCenter=False,
- button=RIGHT_BTN))
+ CameraSelectRotate(viewport,
+ orbitAroundCenter=False,
+ button=RIGHT_BTN))
super(PanPlaneRotateCameraControl, self).__init__(handlers)
@@ -701,5 +696,6 @@ class PanPlaneZoomOnWheelControl(FocusManager):
handlers = (CameraWheel(viewport, mode, scaleTransform),
PlanePan(viewport, plane, LEFT_BTN))
ctrlHandlers = (CameraWheel(viewport, mode, scaleTransform),
- CameraRotate(viewport, orbitAroundCenter, LEFT_BTN))
+ CameraSelectRotate(
+ viewport, orbitAroundCenter, LEFT_BTN))
super(PanPlaneZoomOnWheelControl, self).__init__(handlers, ctrlHandlers)
diff --git a/silx/gui/plot3d/scene/primitives.py b/silx/gui/plot3d/scene/primitives.py
index ca06e30..08724ba 100644
--- a/silx/gui/plot3d/scene/primitives.py
+++ b/silx/gui/plot3d/scene/primitives.py
@@ -29,8 +29,10 @@ __authors__ = ["T. Vincent"]
__license__ = "MIT"
__date__ = "24/04/2018"
-
-import collections
+try:
+ from collections import abc
+except ImportError: # Python2 support
+ import collections as abc
import ctypes
from functools import reduce
import logging
@@ -47,7 +49,7 @@ from . import event
from . import core
from . import transform
from . import utils
-from .function import Colormap
+from .function import Colormap, Fog
_logger = logging.getLogger(__name__)
@@ -146,7 +148,7 @@ class Geometry(core.Elem):
:param bool copy: True to make a copy of the array, False to use as is
"""
# Convert single value (int, float, numpy types) to tuple
- if not isinstance(array, collections.Iterable):
+ if not isinstance(array, abc.Iterable):
array = (array, )
# Makes sure it is an array
@@ -361,9 +363,11 @@ class Geometry(core.Elem):
if attribute.ndim == 1: # Single value
min_ = attribute
max_ = attribute
- else: # Array of values, compute min/max
+ elif len(attribute) > 0: # Array of values, compute min/max
min_ = numpy.nanmin(attribute, axis=0)
max_ = numpy.nanmax(attribute, axis=0)
+ else:
+ min_, max_ = numpy.zeros((2, attribute.shape[1]), dtype=numpy.float32)
toCopy = min(len(min_), 3-index)
if toCopy != len(min_):
@@ -451,13 +455,14 @@ class Lines(Geometry):
varying vec3 vNormal;
varying vec4 vColor;
- $clippingDecl
+ $sceneDecl
$lightingFunction
void main(void)
{
- $clippingCall(vCameraPosition);
+ $scenePreCall(vCameraPosition);
gl_FragColor = $lightingCall(vColor, vPosition, vNormal);
+ $scenePostCall(vCameraPosition);
}
"""))
@@ -492,8 +497,9 @@ class Lines(Geometry):
fraglightfunction = ctx.viewport.light.fragmentShaderFunctionNoop
fragment = self._shaders[1].substitute(
- clippingDecl=ctx.clipper.fragDecl,
- clippingCall=ctx.clipper.fragCall,
+ sceneDecl=ctx.fragDecl,
+ scenePreCall=ctx.fragCallPre,
+ scenePostCall=ctx.fragCallPost,
lightingFunction=fraglightfunction,
lightingCall=ctx.viewport.light.fragmentCall)
prog = ctx.glCtx.prog(self._shaders[0], fragment)
@@ -509,7 +515,7 @@ class Lines(Geometry):
ctx.objectToCamera.matrix,
safe=True)
- ctx.clipper.setupProgram(ctx, prog)
+ ctx.setupProgram(prog)
with gl.enabled(gl.GL_LINE_SMOOTH, self._smooth):
self._draw(prog)
@@ -560,18 +566,21 @@ class DashedLines(Lines):
uniform vec2 dash;
- $clippingDecl
+ $sceneDecl
$lightingFunction
void main(void)
{
+ $scenePreCall(vCameraPosition);
+
/* Discard off dash fragments */
float lineDist = distance(vOriginFragCoord, gl_FragCoord.xy);
if (mod(lineDist, dash.x + dash.y) > dash.x) {
discard;
}
- $clippingCall(vCameraPosition);
gl_FragColor = $lightingCall(vColor, vPosition, vNormal);
+
+ $scenePostCall(vCameraPosition);
}
"""))
@@ -627,8 +636,9 @@ class DashedLines(Lines):
context.viewport.light.fragmentShaderFunctionNoop
fragment = self._shaders[1].substitute(
- clippingDecl=context.clipper.fragDecl,
- clippingCall=context.clipper.fragCall,
+ sceneDecl=context.fragDecl,
+ scenePreCall=context.fragCallPre,
+ scenePostCall=context.fragCallPost,
lightingFunction=fraglightfunction,
lightingCall=context.viewport.light.fragmentCall)
program = context.glCtx.prog(self._shaders[0], fragment)
@@ -648,7 +658,7 @@ class DashedLines(Lines):
program.uniforms['viewportSize'], *context.viewport.size)
gl.glUniform2f(program.uniforms['dash'], *self.dash)
- context.clipper.setupProgram(context, program)
+ context.setupProgram(program)
self._draw(program)
@@ -1236,14 +1246,12 @@ class _Points(Geometry):
varying $valueType vValue;
$valueToColorDecl
-
- $clippingDecl
-
+ $sceneDecl
$alphaSymbolDecl
void main(void)
{
- $clippingCall(vCameraPosition);
+ $scenePreCall(vCameraPosition);
float alpha = alphaSymbol(gl_PointCoord, vSize);
@@ -1252,6 +1260,8 @@ class _Points(Geometry):
if (gl_FragColor.a == 0.0) {
discard;
}
+
+ $scenePostCall(vCameraPosition);
}
"""))
@@ -1305,8 +1315,9 @@ class _Points(Geometry):
vertexShader = self._shaders[0].substitute(
valueType=valueType)
fragmentShader = self._shaders[1].substitute(
- clippingDecl=ctx.clipper.fragDecl,
- clippingCall=ctx.clipper.fragCall,
+ sceneDecl=ctx.fragDecl,
+ scenePreCall=ctx.fragCallPre,
+ scenePostCall=ctx.fragCallPost,
valueType=valueType,
valueToColorDecl=valueToColorDecl,
valueToColorCall=valueToColorCall,
@@ -1324,7 +1335,7 @@ class _Points(Geometry):
ctx.objectToCamera.matrix,
safe=True)
- ctx.clipper.setupProgram(ctx, program)
+ ctx.setupProgram(program)
self._renderGL2PreDrawHook(ctx, program)
@@ -1475,15 +1486,17 @@ class GridPoints(Geometry):
in vec4 vCameraPosition;
in float vNormValue;
- out vec4 fragColor;
+ out vec4 gl_FragColor;
- $clippingDecl
+ $sceneDecl
void main(void)
{
- $clippingCall(vCameraPosition);
+ $scenePreCall(vCameraPosition);
+
+ gl_FragColor = vec4(0.5 * vNormValue + 0.5, 0.0, 0.0, 1.0);
- fragColor = vec4(0.5 * vNormValue + 0.5, 0.0, 0.0, 1.0);
+ $scenePostCall(vCameraPosition);
}
"""))
@@ -1497,7 +1510,7 @@ class GridPoints(Geometry):
def __init__(self, values=0., shape=None, sizes=1., indices=None,
minValue=None, maxValue=None):
- if isinstance(values, collections.Iterable):
+ if isinstance(values, abc.Iterable):
values = numpy.array(values, copy=False)
# Test if gl_VertexID will overflow
@@ -1532,8 +1545,9 @@ class GridPoints(Geometry):
def renderGL2(self, ctx):
fragment = self._shaders[1].substitute(
- clippingDecl=ctx.clipper.fragDecl,
- clippingCall=ctx.clipper.fragCall)
+ sceneDecl=ctx.fragDecl,
+ scenePreCall=ctx.fragCallPre,
+ scenePostCall=ctx.fragCallPost)
prog = ctx.glCtx.prog(self._shaders[0], fragment)
prog.use()
@@ -1546,7 +1560,7 @@ class GridPoints(Geometry):
ctx.objectToCamera.matrix,
safe=True)
- ctx.clipper.setupProgram(ctx, prog)
+ ctx.setupProgram(prog)
gl.glUniform3i(prog.uniforms['gridDims'],
self._shape[2] if len(self._shape) == 3 else 1,
@@ -1632,12 +1646,12 @@ class Spheres(Geometry):
varying float vViewDepth;
varying float vViewRadius;
- $clippingDecl
+ $sceneDecl
$lightingFunction
void main(void)
{
- $clippingCall(vCameraPosition);
+ $scenePreCall(vCameraPosition);
/* Get normal from point coords */
vec3 normal;
@@ -1658,6 +1672,8 @@ class Spheres(Geometry):
float viewDepth = vViewDepth + vViewRadius * normal.z;
vec2 clipZW = viewDepth * projMat[2].zw + projMat[3].zw;
gl_FragDepth = 0.5 * (clipZW.x / clipZW.y) + 0.5;
+
+ $scenePostCall(vCameraPosition);
}
"""))
@@ -1676,8 +1692,9 @@ class Spheres(Geometry):
def renderGL2(self, ctx):
fragment = self._shaders[1].substitute(
- clippingDecl=ctx.clipper.fragDecl,
- clippingCall=ctx.clipper.fragCall,
+ sceneDecl=ctx.fragDecl,
+ scenePreCall=ctx.fragCallPre,
+ scenePostCall=ctx.fragCallPost,
lightingFunction=ctx.viewport.light.fragmentDef,
lightingCall=ctx.viewport.light.fragmentCall)
prog = ctx.glCtx.prog(self._shaders[0], fragment)
@@ -1694,7 +1711,7 @@ class Spheres(Geometry):
ctx.objectToCamera.matrix,
safe=True)
- ctx.clipper.setupProgram(ctx, prog)
+ ctx.setupProgram(prog)
gl.glUniform2f(prog.uniforms['screenSize'], *ctx.viewport.size)
@@ -1748,14 +1765,16 @@ class Mesh3D(Geometry):
varying vec3 vNormal;
varying vec4 vColor;
- $clippingDecl
+ $sceneDecl
$lightingFunction
void main(void)
{
- $clippingCall(vCameraPosition);
+ $scenePreCall(vCameraPosition);
gl_FragColor = $lightingCall(vColor, vPosition, vNormal);
+
+ $scenePostCall(vCameraPosition);
}
"""))
@@ -1798,8 +1817,9 @@ class Mesh3D(Geometry):
fragLightFunction = ctx.viewport.light.fragmentShaderFunctionNoop
fragment = self._shaders[1].substitute(
- clippingDecl=ctx.clipper.fragDecl,
- clippingCall=ctx.clipper.fragCall,
+ sceneDecl=ctx.fragDecl,
+ scenePreCall=ctx.fragCallPre,
+ scenePostCall=ctx.fragCallPost,
lightingFunction=fragLightFunction,
lightingCall=ctx.viewport.light.fragmentCall)
prog = ctx.glCtx.prog(self._shaders[0], fragment)
@@ -1818,7 +1838,7 @@ class Mesh3D(Geometry):
ctx.objectToCamera.matrix,
safe=True)
- ctx.clipper.setupProgram(ctx, prog)
+ ctx.setupProgram(prog)
self._draw(prog)
@@ -1860,15 +1880,17 @@ class ColormapMesh3D(Geometry):
varying float vValue;
$colormapDecl
- $clippingDecl
+ $sceneDecl
$lightingFunction
void main(void)
{
- $clippingCall(vCameraPosition);
+ $scenePreCall(vCameraPosition);
vec4 color = $colormapCall(vValue);
gl_FragColor = $lightingCall(color, vPosition, vNormal);
+
+ $scenePostCall(vCameraPosition);
}
"""))
@@ -1933,8 +1955,9 @@ class ColormapMesh3D(Geometry):
def _renderGL2(self, ctx):
fragment = self._shaders[1].substitute(
- clippingDecl=ctx.clipper.fragDecl,
- clippingCall=ctx.clipper.fragCall,
+ sceneDecl=ctx.fragDecl,
+ scenePreCall=ctx.fragCallPre,
+ scenePostCall=ctx.fragCallPost,
lightingFunction=ctx.viewport.light.fragmentDef,
lightingCall=ctx.viewport.light.fragmentCall,
colormapDecl=self.colormap.decl,
@@ -1943,7 +1966,7 @@ class ColormapMesh3D(Geometry):
program.use()
ctx.viewport.light.setupProgram(ctx, program)
- ctx.clipper.setupProgram(ctx, program)
+ ctx.setupProgram(program)
self.colormap.setupProgram(ctx, program)
if self.culling is not None:
@@ -2001,20 +2024,20 @@ class _Image(Geometry):
uniform float alpha;
$imageDecl
-
- $clippingDecl
-
+ $sceneDecl
$lightingFunction
void main(void)
{
+ $scenePreCall(vCameraPosition);
+
vec4 color = imageColor(data, vTexCoords);
color.a = alpha;
- $clippingCall(vCameraPosition);
-
vec3 normal = vec3(0.0, 0.0, 1.0);
gl_FragColor = $lightingCall(color, vPosition, normal);
+
+ $scenePostCall(vCameraPosition);
}
"""))
@@ -2133,8 +2156,9 @@ class _Image(Geometry):
def _renderGL2(self, ctx):
fragment = self._shaders[1].substitute(
- clippingDecl=ctx.clipper.fragDecl,
- clippingCall=ctx.clipper.fragCall,
+ sceneDecl=ctx.fragDecl,
+ scenePreCall=ctx.fragCallPre,
+ scenePostCall=ctx.fragCallPost,
lightingFunction=ctx.viewport.light.fragmentDef,
lightingCall=ctx.viewport.light.fragmentCall,
imageDecl=self._shaderImageColorDecl()
@@ -2159,7 +2183,7 @@ class _Image(Geometry):
gl.glUniform1i(program.uniforms['data'], self._texture.texUnit)
- ctx.clipper.setupProgram(ctx, program)
+ ctx.setupProgram(program)
self._texture.bind()
diff --git a/silx/gui/plot3d/scene/utils.py b/silx/gui/plot3d/scene/utils.py
index 1224f5e..bddbcac 100644
--- a/silx/gui/plot3d/scene/utils.py
+++ b/silx/gui/plot3d/scene/utils.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2015-2018 European Synchrotron Radiation Facility
+# Copyright (c) 2015-2019 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -544,77 +544,6 @@ def segmentVolumeIntersect(segment, nbins):
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/scene/viewport.py b/silx/gui/plot3d/scene/viewport.py
index 41aa999..6de640e 100644
--- a/silx/gui/plot3d/scene/viewport.py
+++ b/silx/gui/plot3d/scene/viewport.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2015-2018 European Synchrotron Radiation Facility
+# Copyright (c) 2015-2019 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -36,6 +36,7 @@ __license__ = "MIT"
__date__ = "24/04/2018"
+import string
import numpy
from silx.gui.colors import rgba
@@ -45,7 +46,7 @@ from ..._glutils import gl
from . import camera
from . import event
from . import transform
-from .function import DirectionalLight, ClippingPlane
+from .function import DirectionalLight, ClippingPlane, Fog
class RenderContext(object):
@@ -61,12 +62,33 @@ class RenderContext(object):
:param Context glContext: The operating system OpenGL context in use.
"""
+ _FRAGMENT_SHADER_SRC = string.Template("""
+ void scene_post(vec4 cameraPosition) {
+ gl_FragColor = $fogCall(gl_FragColor, cameraPosition);
+ }
+ """)
+
def __init__(self, viewport, glContext):
self._viewport = viewport
self._glContext = glContext
self._transformStack = [viewport.camera.extrinsic]
self._clipPlane = ClippingPlane(normal=(0., 0., 0.))
+ # cache
+ self.__cache = {}
+
+ def cache(self, key, factory, *args, **kwargs):
+ """Lazy-loading cache to store values in the context for rendering
+
+ :param key: The key to retrieve
+ :param factory: A callback taking args and kwargs as arguments
+ and returning the value to store.
+ :return: The stored or newly allocated value
+ """
+ if key not in self.__cache:
+ self.__cache[key] = factory(*args, **kwargs)
+ return self.__cache[key]
+
@property
def viewport(self):
"""Viewport doing the current rendering"""
@@ -127,8 +149,7 @@ class RenderContext(object):
@property
def clipper(self):
- """The current clipping plane
- """
+ """The current clipping plane (ClippingPlane)"""
return self._clipPlane
def setClipPlane(self, point=(0., 0., 0.), normal=(0., 0., 0.)):
@@ -143,6 +164,40 @@ class RenderContext(object):
"""
self._clipPlane = ClippingPlane(point, normal)
+ def setupProgram(self, program):
+ """Sets-up uniforms of a program using the context shader functions.
+
+ :param GLProgram program: The program to set-up.
+ It MUST be in use and using the context function.
+ """
+ self.clipper.setupProgram(self, program)
+ self.viewport.fog.setupProgram(self, program)
+
+ @property
+ def fragDecl(self):
+ """Fragment shader declaration for scene shader functions"""
+ return '\n'.join((
+ self.clipper.fragDecl,
+ self.viewport.fog.fragDecl,
+ self._FRAGMENT_SHADER_SRC.substitute(
+ fogCall=self.viewport.fog.fragCall)))
+
+ @property
+ def fragCallPre(self):
+ """Fragment shader call for scene shader functions (to do first)
+
+ It takes the camera position (vec4) as argument.
+ """
+ return self.clipper.fragCall
+
+ @property
+ def fragCallPost(self):
+ """Fragment shader call for scene shader functions (to do last)
+
+ It takes the camera position (vec4) as argument.
+ """
+ return "scene_post"
+
class Viewport(event.Notifier):
"""Rendering a single scene through a camera in part of a framebuffer.
@@ -170,6 +225,9 @@ class Viewport(event.Notifier):
ambient=(0.3, 0.3, 0.3),
diffuse=(0.7, 0.7, 0.7))
self._light.addListener(self._changed)
+ self._fog = Fog()
+ self._fog.isOn = False
+ self._fog.addListener(self._changed)
@property
def transforms(self):
@@ -224,6 +282,11 @@ class Viewport(event.Notifier):
return self._light
@property
+ def fog(self):
+ """The fog function used to render the scene"""
+ return self._fog
+
+ @property
def origin(self):
"""Origin (ox, oy) of the viewport in pixels"""
return self._origin
@@ -351,8 +414,8 @@ class Viewport(event.Notifier):
"""
bounds = self.scene.bounds(transformed=True)
if bounds is None:
- bounds = numpy.array(((0., 0., 0.), (1., 1., 1.)),
- dtype=numpy.float32)
+ bounds = numpy.array(((0., 0., 0.), (1., 1., 1.)),
+ dtype=numpy.float32)
self.camera.resetCamera(bounds)
def orbitCamera(self, direction, angle=1.):
diff --git a/silx/gui/plot3d/test/__init__.py b/silx/gui/plot3d/test/__init__.py
index 8825cf4..77172d1 100644
--- a/silx/gui/plot3d/test/__init__.py
+++ b/silx/gui/plot3d/test/__init__.py
@@ -58,14 +58,18 @@ def suite():
from ..tools.test import suite as toolsTestSuite
from .testGL import suite as testGLSuite
from .testScalarFieldView import suite as testScalarFieldViewSuite
+ from .testSceneWidget import suite as testSceneWidgetSuite
from .testSceneWidgetPicking import suite as testSceneWidgetPickingSuite
+ from .testSceneWindow import suite as testSceneWindowSuite
from .testStatsWidget import suite as testStatsWidgetSuite
testsuite = unittest.TestSuite()
testsuite.addTest(testGLSuite())
testsuite.addTest(sceneTestSuite())
testsuite.addTest(testScalarFieldViewSuite())
+ testsuite.addTest(testSceneWidgetSuite())
testsuite.addTest(testSceneWidgetPickingSuite())
+ testsuite.addTest(testSceneWindowSuite())
testsuite.addTest(toolsTestSuite())
testsuite.addTest(testStatsWidgetSuite())
return testsuite
diff --git a/silx/gui/plot3d/test/testSceneWidget.py b/silx/gui/plot3d/test/testSceneWidget.py
new file mode 100644
index 0000000..13ddd37
--- /dev/null
+++ b/silx/gui/plot3d/test/testSceneWidget.py
@@ -0,0 +1,84 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2019 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+# ###########################################################################*/
+"""Test SceneWidget"""
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "06/03/2019"
+
+
+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
+
+
+class TestSceneWidget(TestCaseQt, ParametricTestCase):
+ """Tests SceneWidget picking feature"""
+
+ def setUp(self):
+ super(TestSceneWidget, self).setUp()
+ self.widget = SceneWidget()
+ 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(TestSceneWidget, self).tearDown()
+
+ def testFogEffect(self):
+ """Test fog effect on scene primitive"""
+ image = self.widget.addImage(numpy.arange(100).reshape(10, 10))
+ scatter = self.widget.add3DScatter(*numpy.random.random(4000).reshape(4, -1))
+ scatter.setTranslation(10, 10)
+ scatter.setScale(10, 10, 10)
+
+ self.widget.resetZoom('front')
+ self.qapp.processEvents()
+
+ self.widget.setFogMode(self.widget.FogMode.LINEAR)
+ self.qapp.processEvents()
+
+ self.widget.setFogMode(self.widget.FogMode.NONE)
+ self.qapp.processEvents()
+
+
+def suite():
+ testsuite = unittest.TestSuite()
+ testsuite.addTest(
+ unittest.defaultTestLoader.loadTestsFromTestCase(
+ TestSceneWidget))
+ return testsuite
+
+
+if __name__ == '__main__':
+ unittest.main(defaultTest='suite')
diff --git a/silx/gui/plot3d/test/testSceneWidgetPicking.py b/silx/gui/plot3d/test/testSceneWidgetPicking.py
index 649fb47..aea30f6 100644
--- a/silx/gui/plot3d/test/testSceneWidgetPicking.py
+++ b/silx/gui/plot3d/test/testSceneWidgetPicking.py
@@ -128,50 +128,60 @@ class TestSceneWidgetPicking(TestCaseQt, ParametricTestCase):
picking = list(self.widget.pickItems(1, 1))
self.assertEqual(len(picking), 0)
- def testPickScalarField3D(self):
+ def testPickVolume(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)
+ for dtype in (numpy.float32, numpy.complex64):
+ with self.subTest(dtype=dtype):
+ refData = numpy.arange(10**3, dtype=dtype).reshape(10, 10, 10)
+ volume = self.widget.addVolume(refData)
+ if dtype == numpy.complex64:
+ volume.setComplexMode(volume.ComplexMode.REAL)
+ refData = numpy.real(refData)
+ self.widget.resetZoom('front')
+
+ cutplane = volume.getCutPlanes()[0]
+ if dtype == numpy.complex64:
+ cutplane.setComplexMode(volume.ComplexMode.REAL)
+ 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,
+ refData[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,
+ refData[picking[1].getIndices()]))
+
+ # Picking outside data
+ picking = list(self.widget.pickItems(1, 1))
+ self.assertEqual(len(picking), 0)
+
+ self.widget.clearItems()
def testPickMesh(self):
"""Test picking of Mesh items"""
diff --git a/silx/gui/plot3d/test/testSceneWindow.py b/silx/gui/plot3d/test/testSceneWindow.py
new file mode 100644
index 0000000..b2e6ea0
--- /dev/null
+++ b/silx/gui/plot3d/test/testSceneWindow.py
@@ -0,0 +1,209 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2019 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+# ###########################################################################*/
+"""Test SceneWindow"""
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "22/03/2019"
+
+
+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.SceneWindow import SceneWindow
+
+
+class TestSceneWindow(TestCaseQt, ParametricTestCase):
+ """Tests SceneWidget picking feature"""
+
+ def setUp(self):
+ super(TestSceneWindow, self).setUp()
+ self.window = SceneWindow()
+ self.window.show()
+ self.qWaitForWindowExposed(self.window)
+
+ def tearDown(self):
+ self.qapp.processEvents()
+ self.window.setAttribute(qt.Qt.WA_DeleteOnClose)
+ self.window.close()
+ del self.window
+ super(TestSceneWindow, self).tearDown()
+
+ def testAdd(self):
+ """Test add basic scene primitive"""
+ sceneWidget = self.window.getSceneWidget()
+ items = []
+
+ # RGB image
+ image = sceneWidget.addImage(numpy.random.random(
+ 10*10*3).astype(numpy.float32).reshape(10, 10, 3))
+ image.setLabel('RGB image')
+ items.append(image)
+ self.assertEqual(sceneWidget.getItems(), tuple(items))
+
+ # Data image
+ image = sceneWidget.addImage(
+ numpy.arange(100, dtype=numpy.float32).reshape(10, 10))
+ image.setTranslation(10.)
+ items.append(image)
+ self.assertEqual(sceneWidget.getItems(), tuple(items))
+
+ # 2D scatter
+ scatter = sceneWidget.add2DScatter(
+ *numpy.random.random(3000).astype(numpy.float32).reshape(3, -1),
+ index=0)
+ scatter.setTranslation(0, 10)
+ scatter.setScale(10, 10, 10)
+ items.insert(0, scatter)
+ self.assertEqual(sceneWidget.getItems(), tuple(items))
+
+ # 3D scatter
+ scatter = sceneWidget.add3DScatter(
+ *numpy.random.random(4000).astype(numpy.float32).reshape(4, -1))
+ scatter.setTranslation(10, 10)
+ scatter.setScale(10, 10, 10)
+ items.append(scatter)
+ self.assertEqual(sceneWidget.getItems(), tuple(items))
+
+ # 3D array of float
+ volume = sceneWidget.addVolume(
+ numpy.arange(10**3, dtype=numpy.float32).reshape(10, 10, 10))
+ volume.setTranslation(0, 0, 10)
+ volume.setRotation(45, (0, 0, 1))
+ volume.addIsosurface(500, 'red')
+ volume.getCutPlanes()[0].getColormap().setName('viridis')
+ items.append(volume)
+ self.assertEqual(sceneWidget.getItems(), tuple(items))
+
+ # 3D array of complex
+ volume = sceneWidget.addVolume(
+ numpy.arange(10**3).reshape(10, 10, 10).astype(numpy.complex64))
+ volume.setTranslation(10, 0, 10)
+ volume.setRotation(45, (0, 0, 1))
+ volume.setComplexMode(volume.ComplexMode.REAL)
+ volume.addIsosurface(500, (1., 0., 0., .5))
+ items.append(volume)
+ self.assertEqual(sceneWidget.getItems(), tuple(items))
+
+ sceneWidget.resetZoom('front')
+ self.qapp.processEvents()
+
+ def testChangeContent(self):
+ """Test add/remove/clear items"""
+ sceneWidget = self.window.getSceneWidget()
+ items = []
+
+ # Add 2 images
+ image = numpy.arange(100, dtype=numpy.float32).reshape(10, 10)
+ items.append(sceneWidget.addImage(image))
+ items.append(sceneWidget.addImage(image))
+ self.qapp.processEvents()
+ self.assertEqual(sceneWidget.getItems(), tuple(items))
+
+ # Clear
+ sceneWidget.clearItems()
+ self.qapp.processEvents()
+ self.assertEqual(sceneWidget.getItems(), ())
+
+ # Add 2 images and remove first one
+ image = numpy.arange(100, dtype=numpy.float32).reshape(10, 10)
+ sceneWidget.addImage(image)
+ items = (sceneWidget.addImage(image),)
+ self.qapp.processEvents()
+
+ sceneWidget.removeItem(sceneWidget.getItems()[0])
+ self.qapp.processEvents()
+ self.assertEqual(sceneWidget.getItems(), items)
+
+ def testColors(self):
+ """Test setting scene colors"""
+ sceneWidget = self.window.getSceneWidget()
+
+ color = qt.QColor(128, 128, 128)
+ sceneWidget.setBackgroundColor(color)
+ self.assertEqual(sceneWidget.getBackgroundColor(), color)
+
+ color = qt.QColor(0, 0, 0)
+ sceneWidget.setForegroundColor(color)
+ self.assertEqual(sceneWidget.getForegroundColor(), color)
+
+ color = qt.QColor(255, 0, 0)
+ sceneWidget.setTextColor(color)
+ self.assertEqual(sceneWidget.getTextColor(), color)
+
+ color = qt.QColor(0, 255, 0)
+ sceneWidget.setHighlightColor(color)
+ self.assertEqual(sceneWidget.getHighlightColor(), color)
+
+ self.qapp.processEvents()
+
+ def testInteractiveMode(self):
+ """Test changing interactive mode"""
+ sceneWidget = self.window.getSceneWidget()
+ center = numpy.array((sceneWidget.width() //2, sceneWidget.height() // 2))
+
+ self.mouseMove(sceneWidget, pos=center)
+ self.mouseClick(sceneWidget, qt.Qt.LeftButton, pos=center)
+
+ volume = sceneWidget.addVolume(
+ numpy.arange(10**3).astype(numpy.float32).reshape(10, 10, 10))
+ sceneWidget.selection().setCurrentItem( volume.getCutPlanes()[0])
+ sceneWidget.resetZoom('side')
+
+ for mode in (None, 'rotate', 'pan', 'panSelectedPlane'):
+ with self.subTest(mode=mode):
+ sceneWidget.setInteractiveMode(mode)
+ self.qapp.processEvents()
+ self.assertEqual(sceneWidget.getInteractiveMode(), mode)
+
+ self.mouseMove(sceneWidget, pos=center)
+ self.mousePress(sceneWidget, qt.Qt.LeftButton, pos=center)
+ self.mouseMove(sceneWidget, pos=center-10)
+ self.mouseMove(sceneWidget, pos=center-20)
+ self.mouseRelease(sceneWidget, qt.Qt.LeftButton, pos=center-20)
+
+ self.keyPress(sceneWidget, qt.Qt.Key_Control)
+ self.mouseMove(sceneWidget, pos=center)
+ self.mousePress(sceneWidget, qt.Qt.LeftButton, pos=center)
+ self.mouseMove(sceneWidget, pos=center-10)
+ self.mouseMove(sceneWidget, pos=center-20)
+ self.mouseRelease(sceneWidget, qt.Qt.LeftButton, pos=center-20)
+ self.keyRelease(sceneWidget, qt.Qt.Key_Control)
+
+
+def suite():
+ testsuite = unittest.TestSuite()
+ testsuite.addTest(
+ unittest.defaultTestLoader.loadTestsFromTestCase(
+ TestSceneWindow))
+ 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
index b4d2c05..fc86a7f 100644
--- a/silx/gui/plot3d/tools/PositionInfoWidget.py
+++ b/silx/gui/plot3d/tools/PositionInfoWidget.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2018 European Synchrotron Radiation Facility
+# Copyright (c) 2018-2019 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -36,6 +36,7 @@ import logging
import weakref
from ... import qt
+from .. import actions
from .. import items
from ..items import volume
from ..SceneWidget import SceneWidget
@@ -65,6 +66,27 @@ class PositionInfoWidget(qt.QWidget):
layout.addStretch(1)
+ self._action = actions.mode.PickingModeAction(parent=self)
+ self._action.setText('Selection')
+ self._action.setToolTip(
+ 'Toggle selection information update with left button click')
+ self._action.sigSceneClicked.connect(self.pick)
+ self._action.changed.connect(self.__actionChanged)
+ self._action.setChecked(False) # Disabled by default
+ self.__actionChanged() # Sync action/widget
+
+ def __actionChanged(self):
+ """Handle toggle action change signal"""
+ if self.toggleAction().isChecked() != self.isEnabled():
+ self.setEnabled(self.toggleAction().isChecked())
+
+ def toggleAction(self):
+ """The action to toggle the picking mode.
+
+ :rtype: QAction
+ """
+ return self._action
+
def _addInfoField(self, label):
"""Add a description: info widget to this widget
@@ -108,23 +130,9 @@ class PositionInfoWidget(qt.QWidget):
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())
+ self._sceneWidgetRef = None if widget is None else weakref.ref(widget)
- return super(PositionInfoWidget, self).eventFilter(watched, event)
+ self.toggleAction().setPlot3DWidget(widget)
def clear(self):
"""Clean-up displayed values"""