diff options
author | Alexandre Marie <alexandre.marie@synchrotron-soleil.fr> | 2019-07-09 10:20:39 +0200 |
---|---|---|
committer | Alexandre Marie <alexandre.marie@synchrotron-soleil.fr> | 2019-07-09 10:20:39 +0200 |
commit | 032cc0bed452e96456cdc499f98ccaf473416978 (patch) | |
tree | 514f4532d1ab4dcbea0495488e6dd30bc422a94b /silx/gui/plot3d | |
parent | 8ff15764a99df31a5d75e1e19a89b413408cfcc2 (diff) | |
parent | 654a6ac93513c3cc1ef97cacd782ff674c6f4559 (diff) |
Update upstream source from tag 'upstream/0.11.0+dfsg'
Update to upstream version '0.11.0+dfsg'
with Debian dir 711605a3a57c11c3b5d699da5819c94403f8ac62
Diffstat (limited to 'silx/gui/plot3d')
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""" |