diff options
Diffstat (limited to 'silx/gui/plot3d/scene')
-rw-r--r-- | silx/gui/plot3d/scene/__init__.py | 34 | ||||
-rw-r--r-- | silx/gui/plot3d/scene/axes.py | 241 | ||||
-rw-r--r-- | silx/gui/plot3d/scene/camera.py | 350 | ||||
-rw-r--r-- | silx/gui/plot3d/scene/core.py | 334 | ||||
-rw-r--r-- | silx/gui/plot3d/scene/cutplane.py | 374 | ||||
-rw-r--r-- | silx/gui/plot3d/scene/event.py | 225 | ||||
-rw-r--r-- | silx/gui/plot3d/scene/function.py | 467 | ||||
-rw-r--r-- | silx/gui/plot3d/scene/interaction.py | 681 | ||||
-rw-r--r-- | silx/gui/plot3d/scene/primitives.py | 1767 | ||||
-rw-r--r-- | silx/gui/plot3d/scene/setup.py | 41 | ||||
-rw-r--r-- | silx/gui/plot3d/scene/test/__init__.py | 43 | ||||
-rw-r--r-- | silx/gui/plot3d/scene/test/test_transform.py | 91 | ||||
-rw-r--r-- | silx/gui/plot3d/scene/test/test_utils.py | 275 | ||||
-rw-r--r-- | silx/gui/plot3d/scene/text.py | 534 | ||||
-rw-r--r-- | silx/gui/plot3d/scene/transform.py | 968 | ||||
-rw-r--r-- | silx/gui/plot3d/scene/utils.py | 516 | ||||
-rw-r--r-- | silx/gui/plot3d/scene/viewport.py | 505 | ||||
-rw-r--r-- | silx/gui/plot3d/scene/window.py | 421 |
18 files changed, 7867 insertions, 0 deletions
diff --git a/silx/gui/plot3d/scene/__init__.py b/silx/gui/plot3d/scene/__init__.py new file mode 100644 index 0000000..25a7171 --- /dev/null +++ b/silx/gui/plot3d/scene/__init__.py @@ -0,0 +1,34 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2015-2017 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""This module provides a 3D graphics scene graph structure.""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "08/11/2016" + + +from .core import Elem, Group, PrivateGroup # noqa +from .viewport import Viewport # noqa +from .window import Window # noqa diff --git a/silx/gui/plot3d/scene/axes.py b/silx/gui/plot3d/scene/axes.py new file mode 100644 index 0000000..520ef3e --- /dev/null +++ b/silx/gui/plot3d/scene/axes.py @@ -0,0 +1,241 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2016-2017 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. +# +# ###########################################################################*/ +"""Primitive displaying a text field in the scene.""" + +from __future__ import absolute_import, division, unicode_literals + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "17/10/2016" + + +import logging +import numpy + +from ...plot._utils import ticklayout + +from . import core, primitives, text, transform + + +_logger = logging.getLogger(__name__) + + +class LabelledAxes(primitives.GroupBBox): + """A group displaying a bounding box with axes labels around its children. + """ + + def __init__(self): + super(LabelledAxes, self).__init__() + self._ticksForBounds = None + + self._font = text.Font() + + self._boxVisibility = True + + # TODO offset labels from anchor in pixels + + self._xlabel = text.Text2D(font=self._font) + self._xlabel.align = 'center' + self._xlabel.transforms = [self._boxTransforms, + transform.Translate(tx=0.5)] + self._children.insert(-1, self._xlabel) + + self._ylabel = text.Text2D(font=self._font) + self._ylabel.align = 'center' + self._ylabel.transforms = [self._boxTransforms, + transform.Translate(ty=0.5)] + self._children.insert(-1, self._ylabel) + + self._zlabel = text.Text2D(font=self._font) + self._zlabel.align = 'center' + self._zlabel.transforms = [self._boxTransforms, + transform.Translate(tz=0.5)] + self._children.insert(-1, self._zlabel) + + # Init tick lines with dummy pos + self._tickLines = primitives.DashedLines( + positions=((0., 0., 0.), (0., 0., 0.))) + self._tickLines.dash = 5, 10 + self._tickLines.visible = False + self._children.insert(-1, self._tickLines) + + self._tickLabels = core.Group() + self._children.insert(-1, self._tickLabels) + + # Sync color + self.tickColor = 1., 1., 1., 1. + + @property + def tickColor(self): + """Color of ticks and text labels. + + This does NOT set bounding box color. + Use :attr:`color` for the bounding box. + """ + return self._xlabel.foreground + + @tickColor.setter + def tickColor(self, color): + self._xlabel.foreground = color + self._ylabel.foreground = color + self._zlabel.foreground = color + transparentColor = color[0], color[1], color[2], color[3] * 0.6 + self._tickLines.setAttribute('color', transparentColor) + for label in self._tickLabels.children: + label.foreground = color + + @property + def font(self): + """Font of axes text labels (Font)""" + return self._font + + @font.setter + def font(self, font): + self._font = font + self._xlabel.font = font + self._ylabel.font = font + self._zlabel.font = font + for label in self._tickLabels.children: + label.font = font + + @property + def xlabel(self): + """Text label of the X axis (str)""" + return self._xlabel.text + + @xlabel.setter + def xlabel(self, text): + self._xlabel.text = text + + @property + def ylabel(self): + """Text label of the Y axis (str)""" + return self._ylabel.text + + @ylabel.setter + def ylabel(self, text): + self._ylabel.text = text + + @property + def zlabel(self): + """Text label of the Z axis (str)""" + return self._zlabel.text + + @zlabel.setter + def zlabel(self, text): + self._zlabel.text = text + + @property + def boxVisible(self): + """Returns bounding box, axes labels and grid visibility.""" + return self._boxVisibility + + @boxVisible.setter + def boxVisible(self, visible): + self._boxVisibility = bool(visible) + for child in self._children: + if child == self._tickLines: + if self._ticksForBounds is not None: + child.visible = self._boxVisibility + elif child != self._group: + child.visible = self._boxVisibility + + def _updateTicks(self): + """Check if ticks need update and update them if needed.""" + bounds = self._group.bounds(transformed=False, dataBounds=True) + if bounds is None: # No content + if self._ticksForBounds is not None: + self._ticksForBounds = None + self._tickLines.visible = False + self._tickLabels.children = [] # Reset previous labels + + elif (self._ticksForBounds is None or + not numpy.all(numpy.equal(bounds, self._ticksForBounds))): + self._ticksForBounds = bounds + + # Update ticks + ticklength = numpy.abs(bounds[1] - bounds[0]) + + xticks, xlabels = ticklayout.ticks(*bounds[:, 0]) + yticks, ylabels = ticklayout.ticks(*bounds[:, 1]) + zticks, zlabels = ticklayout.ticks(*bounds[:, 2]) + + # Update tick lines + coords = numpy.empty( + ((len(xticks) + len(yticks) + len(zticks)), 4, 3), + dtype=numpy.float32) + coords[:, :, :] = bounds[0, :] # account for offset from origin + + xcoords = coords[:len(xticks)] + xcoords[:, :, 0] = numpy.asarray(xticks)[:, numpy.newaxis] + xcoords[:, 1, 1] += ticklength[1] # X ticks on XY plane + xcoords[:, 3, 2] += ticklength[2] # X ticks on XZ plane + + ycoords = coords[len(xticks):len(xticks) + len(yticks)] + ycoords[:, :, 1] = numpy.asarray(yticks)[:, numpy.newaxis] + ycoords[:, 1, 0] += ticklength[0] # Y ticks on XY plane + ycoords[:, 3, 2] += ticklength[2] # Y ticks on YZ plane + + zcoords = coords[len(xticks) + len(yticks):] + zcoords[:, :, 2] = numpy.asarray(zticks)[:, numpy.newaxis] + zcoords[:, 1, 0] += ticklength[0] # Z ticks on XZ plane + zcoords[:, 3, 1] += ticklength[1] # Z ticks on YZ plane + + self._tickLines.setPositions(coords.reshape(-1, 3)) + self._tickLines.visible = self._boxVisibility + + # Update labels + color = self.tickColor + offsets = bounds[0] - ticklength / 20. + labels = [] + for tick, label in zip(xticks, xlabels): + text2d = text.Text2D(text=label, font=self.font) + text2d.align = 'center' + text2d.foreground = color + text2d.transforms = [transform.Translate( + tx=tick, ty=offsets[1], tz=offsets[2])] + labels.append(text2d) + + for tick, label in zip(yticks, ylabels): + text2d = text.Text2D(text=label, font=self.font) + text2d.align = 'center' + text2d.foreground = color + text2d.transforms = [transform.Translate( + tx=offsets[0], ty=tick, tz=offsets[2])] + labels.append(text2d) + + for tick, label in zip(zticks, zlabels): + text2d = text.Text2D(text=label, font=self.font) + text2d.align = 'center' + text2d.foreground = color + text2d.transforms = [transform.Translate( + tx=offsets[0], ty=offsets[1], tz=tick)] + labels.append(text2d) + + self._tickLabels.children = labels # Reset previous labels + + def prepareGL2(self, context): + self._updateTicks() + super(LabelledAxes, self).prepareGL2(context) diff --git a/silx/gui/plot3d/scene/camera.py b/silx/gui/plot3d/scene/camera.py new file mode 100644 index 0000000..8cc279d --- /dev/null +++ b/silx/gui/plot3d/scene/camera.py @@ -0,0 +1,350 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2015-2017 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""This module provides classes to handle a perspective projection in 3D.""" + +from __future__ import absolute_import, division, unicode_literals + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "25/07/2016" + + +import numpy + +from . import transform + + +# CameraExtrinsic ############################################################# + +class CameraExtrinsic(transform.Transform): + """Transform matrix to handle camera position and orientation. + + :param position: Coordinates of the point of view. + :type position: numpy.ndarray-like of 3 float32. + :param direction: Sight direction vector. + :type direction: numpy.ndarray-like of 3 float32. + :param up: Vector pointing upward in the image plane. + :type up: numpy.ndarray-like of 3 float32. + """ + + def __init__(self, position=(0., 0., 0.), + direction=(0., 0., -1.), + up=(0., 1., 0.)): + + super(CameraExtrinsic, self).__init__() + self._position = None + self.position = position # set _position + self._side = 1., 0., 0. + self._up = 0., 1., 0. + self._direction = 0., 0., -1. + self.setOrientation(direction=direction, up=up) # set _direction, _up + + def _makeMatrix(self): + return transform.mat4LookAtDir(self._position, + self._direction, self._up) + + def copy(self): + """Return an independent copy""" + return CameraExtrinsic(self.position, self.direction, self.up) + + def setOrientation(self, direction=None, up=None): + """Set the rotation of the point of view. + + :param direction: Sight direction vector or + None to keep the current one. + :type direction: numpy.ndarray-like of 3 float32 or None. + :param up: Vector pointing upward in the image plane or + None to keep the current one. + :type up: numpy.ndarray-like of 3 float32 or None. + :raises RuntimeError: if the direction and up are parallel. + """ + if direction is None: # Use current direction + direction = self.direction + else: + assert len(direction) == 3 + direction = numpy.array(direction, copy=True, dtype=numpy.float32) + direction /= numpy.linalg.norm(direction) + + if up is None: # Use current up + up = self.up + else: + assert len(up) == 3 + up = numpy.array(up, copy=True, dtype=numpy.float32) + + # Update side and up to make sure they are perpendicular and normalized + side = numpy.cross(direction, up) + sidenormal = numpy.linalg.norm(side) + if sidenormal == 0.: + raise RuntimeError('direction and up vectors are parallel.') + # Alternative: when one of the input parameter is None, it is + # possible to guess correct vectors using previous direction and up + side /= sidenormal + up = numpy.cross(side, direction) + up /= numpy.linalg.norm(up) + + self._side = side + self._up = up + self._direction = direction + self.notify() + + @property + def position(self): + """Coordinates of the point of view as a numpy.ndarray of 3 float32.""" + return self._position.copy() + + @position.setter + def position(self, position): + assert len(position) == 3 + self._position = numpy.array(position, copy=True, dtype=numpy.float32) + self.notify() + + @property + def direction(self): + """Sight direction (ndarray of 3 float32).""" + return self._direction.copy() + + @direction.setter + def direction(self, direction): + self.setOrientation(direction=direction) + + @property + def up(self): + """Vector pointing upward in the image plane (ndarray of 3 float32). + """ + return self._up.copy() + + @up.setter + def up(self, up): + self.setOrientation(up=up) + + @property + def side(self): + """Vector pointing towards the side of the image plane. + + ndarray of 3 float32""" + return self._side.copy() + + def move(self, direction, step=1.): + """Move the camera relative to the image plane. + + :param str direction: Direction relative to image plane. + One of: 'up', 'down', 'left', 'right', + 'forward', 'backward'. + :param float step: The step of the pan to perform in the coordinate + in which the camera position is defined. + """ + if direction in ('up', 'down'): + vector = self.up * (1. if direction == 'up' else -1.) + elif direction in ('left', 'right'): + vector = self.side * (1. if direction == 'right' else -1.) + elif direction in ('forward', 'backward'): + vector = self.direction * (1. if direction == 'forward' else -1.) + else: + raise ValueError('Unsupported direction: %s' % direction) + + self.position += step * vector + + def rotate(self, direction, angle=1.): + """First-person rotation of the camera towards the direction. + + :param str direction: Direction of movement relative to image plane. + In: 'up', 'down', 'left', 'right'. + :param float angle: The angle in degrees of the rotation. + """ + if direction in ('up', 'down'): + axis = self.side * (1. if direction == 'up' else -1.) + elif direction in ('left', 'right'): + axis = self.up * (1. if direction == 'left' else -1.) + else: + raise ValueError('Unsupported direction: %s' % direction) + + matrix = transform.mat4RotateFromAngleAxis(numpy.radians(angle), *axis) + newdir = numpy.dot(matrix[:3, :3], self.direction) + + if direction in ('up', 'down'): + # Rotate up to avoid up and new direction to be (almost) co-linear + newup = numpy.dot(matrix[:3, :3], self.up) + self.setOrientation(newdir, newup) + else: + # No need to rotate up here as it is the rotation axis + self.direction = newdir + + def orbit(self, direction, center=(0., 0., 0.), angle=1.): + """Rotate the camera around a point. + + :param str direction: Direction of movement relative to image plane. + In: 'up', 'down', 'left', 'right'. + :param center: Position around which to rotate the point of view. + :type center: numpy.ndarray-like of 3 float32. + :param float angle: he angle in degrees of the rotation. + """ + if direction in ('up', 'down'): + axis = self.side * (1. if direction == 'down' else -1.) + elif direction in ('left', 'right'): + axis = self.up * (1. if direction == 'right' else -1.) + else: + raise ValueError('Unsupported direction: %s' % direction) + + # Rotate viewing direction + rotmatrix = transform.mat4RotateFromAngleAxis( + numpy.radians(angle), *axis) + self.direction = numpy.dot(rotmatrix[:3, :3], self.direction) + + # Rotate position around center + center = numpy.array(center, copy=False, dtype=numpy.float32) + matrix = numpy.dot(transform.mat4Translate(*center), rotmatrix) + matrix = numpy.dot(matrix, transform.mat4Translate(*(-center))) + position = numpy.append(self.position, 1.) + self.position = numpy.dot(matrix, position)[:3] + + _RESET_CAMERA_ORIENTATIONS = { + 'side': ((-1., -1., -1.), (0., 1., 0.)), + 'front': ((0., 0., -1.), (0., 1., 0.)), + 'back': ((0., 0., 1.), (0., 1., 0.)), + 'top': ((0., -1., 0.), (0., 0., -1.)), + 'bottom': ((0., 1., 0.), (0., 0., 1.)), + 'right': ((-1., 0., 0.), (0., 1., 0.)), + 'left': ((1., 0., 0.), (0., 1., 0.)) + } + + def reset(self, face=None): + """Reset the camera position to pre-defined orientations. + + :param str face: The direction of the camera in: + side, front, back, top, bottom, right, left. + """ + if face not in self._RESET_CAMERA_ORIENTATIONS: + raise ValueError('Unsupported face: %s' % face) + + distance = numpy.linalg.norm(self.position) + direction, up = self._RESET_CAMERA_ORIENTATIONS[face] + self.setOrientation(direction, up) + self.position = - self.direction * distance + + +class Camera(transform.Transform): + """Combination of camera projection and position. + + See :class:`Perspective` and :class:`CameraExtrinsic`. + + :param float fovy: Vertical field-of-view in degrees. + :param float near: The near clipping plane Z coord (strictly positive). + :param float far: The far clipping plane Z coord (> near). + :param size: Viewport's size used to compute the aspect ratio. + :type size: 2-tuple of float (width, height). + :param position: Coordinates of the point of view. + :type position: numpy.ndarray-like of 3 float32. + :param direction: Sight direction vector. + :type direction: numpy.ndarray-like of 3 float32. + :param up: Vector pointing upward in the image plane. + :type up: numpy.ndarray-like of 3 float32. + """ + + def __init__(self, fovy=30., near=0.1, far=1., size=(1., 1.), + position=(0., 0., 0.), + direction=(0., 0., -1.), up=(0., 1., 0.)): + super(Camera, self).__init__() + self._intrinsic = transform.Perspective(fovy, near, far, size) + self._intrinsic.addListener(self._transformChanged) + self._extrinsic = CameraExtrinsic(position, direction, up) + self._extrinsic.addListener(self._transformChanged) + + def _makeMatrix(self): + return numpy.dot(self.intrinsic.matrix, self.extrinsic.matrix) + + def _transformChanged(self, source): + """Listener of intrinsic and extrinsic camera parameters instances.""" + if source is not self: + self.notify() + + def resetCamera(self, bounds): + """Change camera to have the bounds in the viewing frustum. + + It updates the camera position and depth extent. + Camera sight direction and up are not affected. + + :param bounds: The axes-aligned bounds to include. + :type bounds: numpy.ndarray: ((xMin, yMin, zMin), (xMax, yMax, zMax)) + """ + + center = 0.5 * (bounds[0] + bounds[1]) + radius = numpy.linalg.norm(0.5 * (bounds[1] - bounds[0])) + + if isinstance(self.intrinsic, transform.Perspective): + # Get the viewpoint distance from the bounds center + minfov = numpy.radians(self.intrinsic.fovy) + width, height = self.intrinsic.size + if width < height: + minfov *= width / height + + offset = radius / numpy.sin(0.5 * minfov) + + # Update camera + self.extrinsic.position = \ + center - offset * self.extrinsic.direction + self.intrinsic.setDepthExtent(offset - radius, offset + radius) + + elif isinstance(self.intrinsic, transform.Orthographic): + # Y goes up + self.intrinsic.setClipping( + left=center[0] - radius, + right=center[0] + radius, + bottom=center[1] - radius, + top=center[1] + radius) + + # Update camera + self.extrinsic.position = 0, 0, 0 + self.intrinsic.setDepthExtent(center[2] - radius, + center[2] + radius) + else: + raise RuntimeError('Unsupported camera: %s' % self.intrinsic) + + @property + def intrinsic(self): + """Intrinsic camera parameters, i.e., projection matrix.""" + return self._intrinsic + + @intrinsic.setter + def intrinsic(self, intrinsic): + self._intrinsic.removeListener(self._transformChanged) + self._intrinsic = intrinsic + self._intrinsic.addListener(self._transformChanged) + + @property + def extrinsic(self): + """Extrinsic camera parameters, i.e., position and orientation.""" + return self._extrinsic + + def move(self, *args, **kwargs): + """See :meth:`CameraExtrinsic.move`.""" + self.extrinsic.move(*args, **kwargs) + + def rotate(self, *args, **kwargs): + """See :meth:`CameraExtrinsic.rotate`.""" + self.extrinsic.rotate(*args, **kwargs) + + def orbit(self, *args, **kwargs): + """See :meth:`CameraExtrinsic.orbit`.""" + self.extrinsic.orbit(*args, **kwargs) diff --git a/silx/gui/plot3d/scene/core.py b/silx/gui/plot3d/scene/core.py new file mode 100644 index 0000000..a293f28 --- /dev/null +++ b/silx/gui/plot3d/scene/core.py @@ -0,0 +1,334 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2015-2017 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""This module provides the base scene structure. + +This module provides the classes for describing a tree structure with +rendering and picking API. +All nodes inherit from :class:`Base`. +Nodes with children are provided with :class:`PrivateGroup` and +:class:`Group` classes. +Leaf rendering nodes should inherit from :class:`Elem`. +""" + +from __future__ import absolute_import, division, unicode_literals + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "25/07/2016" + + +import itertools +import weakref + +import numpy + +from . import event +from . import transform + +from .viewport import Viewport + + +# Nodes ####################################################################### + +class Base(event.Notifier): + """A scene node with common features.""" + + def __init__(self): + super(Base, self).__init__() + self._visible = True + self._pickable = False + + self._parentRef = None + + self._transforms = transform.TransformList() + self._transforms.addListener(self._transformChanged) + + # notifying properties + + visible = event.notifyProperty('_visible', + doc="Visibility flag of the node") + pickable = event.notifyProperty('_pickable', + doc="True to make node pickable") + + # Access to tree path + + @property + def parent(self): + """Parent or None if no parent""" + return None if self._parentRef is None else self._parentRef() + + def _setParent(self, parent): + """Set the parent of this node. + + For internal use. + + :param Base parent: The parent. + """ + if parent is not None and self._parentRef is not None: + raise RuntimeError('Trying to add a node at two places.') + # Alternative: remove it from previous children list + self._parentRef = None if parent is None else weakref.ref(parent) + + @property + def path(self): + """Tuple of scene nodes, from the tip of the tree down to this node. + + If this tree is attached to a :class:`Viewport`, + then the :class:`Viewport` is the first element of path. + """ + if self.parent is None: + return self, + elif isinstance(self.parent, Viewport): + return self.parent, self + else: + return self.parent.path + (self, ) + + @property + def viewport(self): + """The viewport this node is attached to or None.""" + root = self.path[0] + return root if isinstance(root, Viewport) else None + + @property + def objectToNDCTransform(self): + """Transform from object to normalized device coordinates. + + Do not forget perspective divide. + """ + # Using the Viewport's transforms property to proxy the camera + path = self.path + assert isinstance(path[0], Viewport) + return transform.StaticTransformList(elem.transforms for elem in path) + + @property + def objectToSceneTransform(self): + """Transform from object to scene. + + Combine transforms up to the Viewport (not including it). + """ + path = self.path + if isinstance(path[0], Viewport): + path = path[1:] # Remove viewport to remove camera transforms + return transform.StaticTransformList(elem.transforms for elem in path) + + # transform + + @property + def transforms(self): + """List of transforms defining the frame of this node relative + to its parent.""" + return self._transforms + + @transforms.setter + def transforms(self, iterable): + self._transforms.removeListener(self._transformChanged) + if isinstance(iterable, transform.TransformList): + # If it is a TransformList, do not create one to enable sharing. + self._transforms = iterable + else: + assert hasattr(iterable, '__iter__') + self._transforms = transform.TransformList(iterable) + self._transforms.addListener(self._transformChanged) + + def _transformChanged(self, source): + self.notify() # Broadcast transform notification + + # Bounds + + _CUBE_CORNERS = numpy.array(list(itertools.product((0., 1.), repeat=3)), + dtype=numpy.float32) + """Unit cube corners used to transform bounds""" + + def _bounds(self, dataBounds=False): + """Override in subclass to provide bounds in object coordinates""" + return None + + def bounds(self, transformed=False, dataBounds=False): + """Returns the bounds of this node aligned with the axis, + with or without transform applied. + + :param bool transformed: False to give bounds in object coordinates + (the default), True to apply this object's + transforms. + :param bool dataBounds: False to give bounds of vertices (the default), + True to give bounds of the represented data. + :return: The bounds: ((xMin, yMin, zMin), (xMax, yMax, zMax)) or None + if no bounds. + :rtype: numpy.ndarray of float + """ + bounds = self._bounds(dataBounds) + + if transformed and bounds is not None: + bounds = self.transforms.transformBounds(bounds) + + return bounds + + # Rendering + + def prepareGL2(self, ctx): + """Called before the rendering to prepare OpenGL resources. + + Override in subclass. + """ + pass + + def renderGL2(self, ctx): + """Called to perform the OpenGL rendering. + + Override in subclass. + """ + pass + + def render(self, ctx): + """Called internally to perform rendering.""" + if self.visible: + ctx.pushTransform(self.transforms) + self.prepareGL2(ctx) + self.renderGL2(ctx) + ctx.popTransform() + + def postRender(self, ctx): + """Hook called when parent's node render is finished. + + Called in the reverse of rendering order (i.e., last child first). + + Meant for nodes that modify the :class:`RenderContext` ctx to + reset their modifications. + """ + pass + + def pick(self, ctx, x, y, depth=None): + """True/False picking, should be fast""" + if self.pickable: + pass + + def pickRay(self, ctx, ray): + """Picking returning list of ray intersections.""" + if self.pickable: + pass + + +class Elem(Base): + """A scene node that does some rendering.""" + + def __init__(self): + super(Elem, self).__init__() + # self.showBBox = False # Here or outside scene graph? + # self.clipPlane = None # This needs to be handled in the shader + + +class PrivateGroup(Base): + """A scene node that renders its (private) childern. + + :param iterable children: :class:`Base` nodes to add as children + """ + + class ChildrenList(event.NotifierList): + """List of children with notification and children's parent update.""" + + def _listWillChangeHook(self, methodName, *args, **kwargs): + super(PrivateGroup.ChildrenList, self)._listWillChangeHook( + methodName, *args, **kwargs) + for item in self: + item._setParent(None) + + def _listWasChangedHook(self, methodName, *args, **kwargs): + for item in self: + item._setParent(self._parentRef()) + super(PrivateGroup.ChildrenList, self)._listWasChangedHook( + methodName, *args, **kwargs) + + def __init__(self, parent, children): + self._parentRef = weakref.ref(parent) + super(PrivateGroup.ChildrenList, self).__init__(children) + + def __init__(self, children=()): + super(PrivateGroup, self).__init__() + self.__children = PrivateGroup.ChildrenList(self, children) + self.__children.addListener(self._updated) + + @property + def _children(self): + """List of children to be rendered. + + This private attribute is meant to be used by subclass. + """ + return self.__children + + @_children.setter + def _children(self, iterable): + self.__children.removeListener(self._updated) + for item in self.__children: + item._setParent(None) + del self.__children # This is needed + self.__children = PrivateGroup.ChildrenList(self, iterable) + self.__children.addListener(self._updated) + self.notify() + + def _updated(self, source, *args, **kwargs): + """Listen for updates""" + if source is not self: # Avoid infinite recursion + self.notify(*args, **kwargs) + + def _bounds(self, dataBounds=False): + """Compute the bounds from transformed children bounds""" + bounds = [] + for child in self._children: + if child.visible: + childBounds = child.bounds( + transformed=True, dataBounds=dataBounds) + if childBounds is not None: + bounds.append(childBounds) + + if len(bounds) == 0: + return None + else: + bounds = numpy.array(bounds, dtype=numpy.float32) + return numpy.array((bounds[:, 0, :].min(axis=0), + bounds[:, 1, :].max(axis=0)), + dtype=numpy.float32) + + def prepareGL2(self, ctx): + pass + + def renderGL2(self, ctx): + """Render all children""" + for child in self._children: + child.render(ctx) + for child in reversed(self._children): + child.postRender(ctx) + + +class Group(PrivateGroup): + """A scene node that renders its (public) children.""" + + @property + def children(self): + """List of children to be rendered.""" + return self._children + + @children.setter + def children(self, iterable): + self._children = iterable diff --git a/silx/gui/plot3d/scene/cutplane.py b/silx/gui/plot3d/scene/cutplane.py new file mode 100644 index 0000000..79b4168 --- /dev/null +++ b/silx/gui/plot3d/scene/cutplane.py @@ -0,0 +1,374 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2016-2017 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. +# +# ###########################################################################*/ +"""A cut plane in a 3D texture: hackish implementation... +""" + +from __future__ import absolute_import, division, unicode_literals + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "05/10/2016" + +import string +import numpy + +from ... import _glutils +from ..._glutils import gl + +from .function import Colormap +from .primitives import Box, Geometry, PlaneInGroup +from . import transform, utils + + +class ColormapMesh3D(Geometry): + """A 3D mesh with color from a 3D texture.""" + + _shaders = (""" + attribute vec3 position; + attribute vec3 normal; + + uniform mat4 matrix; + uniform mat4 transformMat; + //uniform mat3 matrixInvTranspose; + uniform vec3 dataScale; + + varying vec4 vCameraPosition; + varying vec3 vPosition; + varying vec3 vNormal; + varying vec3 vTexCoords; + + void main(void) + { + vCameraPosition = transformMat * vec4(position, 1.0); + //vNormal = matrixInvTranspose * normalize(normal); + vPosition = position; + vTexCoords = dataScale * position; + vNormal = normal; + gl_Position = matrix * vec4(position, 1.0); + } + """, + string.Template(""" + varying vec4 vCameraPosition; + varying vec3 vPosition; + varying vec3 vNormal; + varying vec3 vTexCoords; + uniform sampler3D data; + uniform float alpha; + + $colormapDecl + + $clippingDecl + $lightingFunction + + void main(void) + { + float value = texture3D(data, vTexCoords).r; + vec4 color = $colormapCall(value); + color.a = alpha; + + $clippingCall(vCameraPosition); + + gl_FragColor = $lightingCall(color, vPosition, vNormal); + } + """)) + + def __init__(self, position, normal, data, copy=True, + mode='triangles', indices=None, colormap=None): + assert mode in self._TRIANGLE_MODES + data = numpy.array(data, copy=copy, order='C') + assert data.ndim == 3 + self._data = data + self._texture = None + self._update_texture = True + self._update_texture_filter = False + self._alpha = 1. + self._colormap = colormap or Colormap() # Default colormap + self._colormap.addListener(self._cmapChanged) + self._interpolation = 'linear' + super(ColormapMesh3D, self).__init__(mode, + indices, + position=position, + normal=normal) + + self.isBackfaceVisible = True + + def setData(self, data, copy=True): + data = numpy.array(data, copy=copy, order='C') + assert data.ndim == 3 + self._data = data + self._update_texture = True + + def getData(self, copy=True): + return numpy.array(self._data, copy=copy) + + @property + def interpolation(self): + """The texture interpolation mode: 'linear' or 'nearest'""" + return self._interpolation + + @interpolation.setter + def interpolation(self, interpolation): + assert interpolation in ('linear', 'nearest') + self._interpolation = interpolation + self._update_texture_filter = True + self.notify() + + @property + def alpha(self): + """Transparency of the plane, float in [0, 1]""" + return self._alpha + + @alpha.setter + def alpha(self, alpha): + self._alpha = float(alpha) + + @property + def colormap(self): + """The colormap used by this primitive""" + return self._colormap + + def _cmapChanged(self, source, *args, **kwargs): + """Broadcast colormap changes""" + self.notify(*args, **kwargs) + + def prepareGL2(self, ctx): + if self._texture is None or self._update_texture: + if self._texture is not None: + self._texture.discard() + + if self.interpolation == 'nearest': + filter_ = gl.GL_NEAREST + else: + filter_ = gl.GL_LINEAR + self._update_texture = False + self._update_texture_filter = False + self._texture = _glutils.Texture( + gl.GL_R32F, self._data, gl.GL_RED, + minFilter=filter_, + magFilter=filter_, + wrap=gl.GL_CLAMP_TO_EDGE) + + if self._update_texture_filter: + self._update_texture_filter = False + if self.interpolation == 'nearest': + filter_ = gl.GL_NEAREST + else: + filter_ = gl.GL_LINEAR + self._texture.minFilter = filter_ + self._texture.magFilter = filter_ + + super(ColormapMesh3D, self).prepareGL2(ctx) + + def renderGL2(self, ctx): + fragment = self._shaders[1].substitute( + clippingDecl=ctx.clipper.fragDecl, + clippingCall=ctx.clipper.fragCall, + lightingFunction=ctx.viewport.light.fragmentDef, + lightingCall=ctx.viewport.light.fragmentCall, + colormapDecl=self.colormap.decl, + colormapCall=self.colormap.call + ) + program = ctx.glCtx.prog(self._shaders[0], fragment) + program.use() + + ctx.viewport.light.setupProgram(ctx, program) + self.colormap.setupProgram(ctx, program) + + if not self.isBackfaceVisible: + gl.glCullFace(gl.GL_BACK) + gl.glEnable(gl.GL_CULL_FACE) + + program.setUniformMatrix('matrix', ctx.objectToNDC.matrix) + program.setUniformMatrix('transformMat', + ctx.objectToCamera.matrix, + safe=True) + gl.glUniform1f(program.uniforms['alpha'], self._alpha) + + shape = self._data.shape + scales = 1./shape[2], 1./shape[1], 1./shape[0] + gl.glUniform3f(program.uniforms['dataScale'], *scales) + + gl.glUniform1i(program.uniforms['data'], self._texture.texUnit) + + ctx.clipper.setupProgram(ctx, program) + + self._texture.bind() + self._draw(program) + + if not self.isBackfaceVisible: + gl.glDisable(gl.GL_CULL_FACE) + + +class CutPlane(PlaneInGroup): + """A cutting plane in a 3D texture""" + + def __init__(self, point=(0., 0., 0.), normal=(0., 0., 1.)): + self._data = None + self._mesh = None + self._alpha = 1. + self._interpolation = 'linear' + self._colormap = Colormap() + super(CutPlane, self).__init__(point, normal) + + def setData(self, data, copy=True): + if data is None: + self._data = None + if self._mesh is not None: + self._children.remove(self._mesh) + self._mesh = None + + else: + data = numpy.array(data, copy=copy, order='C') + assert data.ndim == 3 + self._data = data + if self._mesh is not None: + self._mesh.setData(data, copy=False) + + def getData(self, copy=True): + return None if self._mesh is None else self._mesh.getData(copy=copy) + + @property + def alpha(self): + return self._alpha + + @alpha.setter + def alpha(self, alpha): + self._alpha = float(alpha) + if self._mesh is not None: + self._mesh.alpha = alpha + + @property + def colormap(self): + return self._colormap + + @property + def interpolation(self): + """The texture interpolation mode: 'linear' (default) or 'nearest'""" + return self._interpolation + + @interpolation.setter + def interpolation(self, interpolation): + assert interpolation in ('nearest', 'linear') + if interpolation != self.interpolation: + self._interpolation = interpolation + if self._mesh is not None: + self._mesh.interpolation = interpolation + + def prepareGL2(self, ctx): + if self.isValid: + + contourVertices = self.contourVertices + + if (self.interpolation == 'nearest' and + contourVertices is not None and len(contourVertices)): + # Avoid cut plane co-linear with array bin edges + for index, normal in enumerate(((1., 0., 0.), (0., 1., 0.), (0., 0., 1.))): + if (numpy.all(numpy.equal(self.plane.normal, normal)) and + int(self.plane.point[index]) == self.plane.point[index]): + contourVertices += self.plane.normal * 0.01 # Add an offset + break + + if self._mesh is None and self._data is not None: + self._mesh = ColormapMesh3D(contourVertices, + normal=self.plane.normal, + data=self._data, + copy=False, + mode='fan', + colormap=self.colormap) + self._mesh.alpha = self._alpha + self._interpolation = self.interpolation + self._children.insert(0, self._mesh) + + if self._mesh is not None: + if (contourVertices is None or + len(contourVertices) == 0): + self._mesh.visible = False + else: + self._mesh.visible = True + self._mesh.setAttribute('normal', self.plane.normal) + self._mesh.setAttribute('position', contourVertices) + + super(CutPlane, self).prepareGL2(ctx) + + def renderGL2(self, ctx): + with self.viewport.light.turnOff(): + super(CutPlane, self).renderGL2(ctx) + + def _bounds(self, dataBounds=False): + if not dataBounds: + vertices = self.contourVertices + if vertices is not None: + return numpy.array( + (vertices.min(axis=0), vertices.max(axis=0)), + dtype=numpy.float32) + else: + return None # Plane in not slicing the data volume + else: + if self._data is None: + return None + else: + depth, height, width = self._data.shape + return numpy.array(((0., 0., 0.), + (width, height, depth)), + dtype=numpy.float32) + + @property + def contourVertices(self): + """The vertices of the contour of the plane/bounds intersection.""" + # TODO copy from PlaneInGroup, refactor all that! + bounds = self.bounds(dataBounds=True) + if bounds is None: + return None # No bounds: no vertices + + # Check if cache is valid and return it + cachebounds, cachevertices = self._cache + if numpy.all(numpy.equal(bounds, cachebounds)): + return cachevertices + + # Cache is not OK, rebuild it + boxvertices = bounds[0] + Box._vertices.copy()*(bounds[1] - bounds[0]) + lineindices = Box._lineIndices + vertices = utils.boxPlaneIntersect( + boxvertices, lineindices, self.plane.normal, self.plane.point) + + self._cache = bounds, vertices if len(vertices) != 0 else None + + return self._cache[1] + + # Render transforms RW, TODO refactor this! + @property + def transforms(self): + return self._transforms + + @transforms.setter + def transforms(self, iterable): + self._transforms.removeListener(self._transformChanged) + if isinstance(iterable, transform.TransformList): + # If it is a TransformList, do not create one to enable sharing. + self._transforms = iterable + else: + assert hasattr(iterable, '__iter__') + self._transforms = transform.TransformList(iterable) + self._transforms.addListener(self._transformChanged) diff --git a/silx/gui/plot3d/scene/event.py b/silx/gui/plot3d/scene/event.py new file mode 100644 index 0000000..7b85434 --- /dev/null +++ b/silx/gui/plot3d/scene/event.py @@ -0,0 +1,225 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2015-2017 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""This module provides a simple generic notification system.""" + +from __future__ import absolute_import, division, unicode_literals + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "25/07/2016" + + +import logging + +from silx.utils.weakref import WeakList + +_logger = logging.getLogger(__name__) + + +# Notifier #################################################################### + +class Notifier(object): + """Base class for object with notification mechanism.""" + + def __init__(self): + self._listeners = WeakList() + + def addListener(self, listener): + """Register a listener. + + Adding an already registered listener has no effect. + + :param callable listener: The function or method to register. + """ + if listener not in self._listeners: + self._listeners.append(listener) + else: + _logger.warning('Ignoring addition of an already registered listener') + + def removeListener(self, listener): + """Remove a previously registered listener. + + :param callable listener: The function or method to unregister. + """ + try: + self._listeners.remove(listener) + except ValueError: + _logger.warn('Trying to remove a listener that is not registered') + + def notify(self, *args, **kwargs): + """Notify all registered listeners with the given parameters. + + Listeners are called directly in this method. + Listeners are called in the order they were registered. + """ + for listener in self._listeners: + listener(self, *args, **kwargs) + + +def notifyProperty(attrName, copy=False, converter=None, doc=None): + """Create a property that adds notification to an attribute. + + :param str attrName: The name of the attribute to wrap. + :param bool copy: Whether to return a copy of the attribute + or not (the default). + :param converter: Function converting input value to appropriate type + This function takes a single argument and return the + converted value. + It can be used to perform some asserts. + :param str doc: The docstring of the property + :return: A property with getter and setter + """ + if copy: + def getter(self): + return getattr(self, attrName).copy() + else: + def getter(self): + return getattr(self, attrName) + + if converter is None: + def setter(self, value): + if getattr(self, attrName) != value: + setattr(self, attrName, value) + self.notify() + + else: + def setter(self, value): + value = converter(value) + if getattr(self, attrName) != value: + setattr(self, attrName, value) + self.notify() + + return property(getter, setter, doc=doc) + + +class HookList(list): + """List with hooks before and after modification.""" + + def __init__(self, iterable): + super(HookList, self).__init__(iterable) + + self._listWasChangedHook('__init__', iterable) + + def _listWillChangeHook(self, methodName, *args, **kwargs): + """To override. Called before modifying the list. + + This method is called with the name of the method called to + modify the list and its parameters. + """ + pass + + def _listWasChangedHook(self, methodName, *args, **kwargs): + """To override. Called after modifying the list. + + This method is called with the name of the method called to + modify the list and its parameters. + """ + pass + + # Wrapping methods that modify the list + + def _wrapper(self, methodName, *args, **kwargs): + """Generic wrapper of list methods calling the hooks.""" + self._listWillChangeHook(methodName, *args, **kwargs) + result = getattr(super(HookList, self), + methodName)(*args, **kwargs) + self._listWasChangedHook(methodName, *args, **kwargs) + return result + + # Add methods + + def __iadd__(self, *args, **kwargs): + return self._wrapper('__iadd__', *args, **kwargs) + + def __imul__(self, *args, **kwargs): + return self._wrapper('__imul__', *args, **kwargs) + + def append(self, *args, **kwargs): + return self._wrapper('append', *args, **kwargs) + + def extend(self, *args, **kwargs): + return self._wrapper('extend', *args, **kwargs) + + def insert(self, *args, **kwargs): + return self._wrapper('insert', *args, **kwargs) + + # Remove methods + + def __delitem__(self, *args, **kwargs): + return self._wrapper('__delitem__', *args, **kwargs) + + def __delslice__(self, *args, **kwargs): + return self._wrapper('__delslice__', *args, **kwargs) + + def remove(self, *args, **kwargs): + return self._wrapper('remove', *args, **kwargs) + + def pop(self, *args, **kwargs): + return self._wrapper('pop', *args, **kwargs) + + # Set methods + + def __setitem__(self, *args, **kwargs): + return self._wrapper('__setitem__', *args, **kwargs) + + def __setslice__(self, *args, **kwargs): + return self._wrapper('__setslice__', *args, **kwargs) + + # In place methods + + def sort(self, *args, **kwargs): + return self._wrapper('sort', *args, **kwargs) + + def reverse(self, *args, **kwargs): + return self._wrapper('reverse', *args, **kwargs) + + +class NotifierList(HookList, Notifier): + """List of Notifiers with notification mechanism. + + This class registers itself as a listener of the list items. + + The default listener method forward notification from list items + to the listeners of the list. + """ + + def __init__(self, iterable=()): + Notifier.__init__(self) + HookList.__init__(self, iterable) + + def _listWillChangeHook(self, methodName, *args, **kwargs): + for item in self: + item.removeListener(self._notified) + + def _listWasChangedHook(self, methodName, *args, **kwargs): + for item in self: + item.addListener(self._notified) + self.notify() + + def _notified(self, source, *args, **kwargs): + """Default listener forwarding list item changes to its listeners.""" + # Avoid infinite recursion if the list is listening itself + if source is not self: + self.notify(*args, **kwargs) diff --git a/silx/gui/plot3d/scene/function.py b/silx/gui/plot3d/scene/function.py new file mode 100644 index 0000000..73cdb72 --- /dev/null +++ b/silx/gui/plot3d/scene/function.py @@ -0,0 +1,467 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2015-2017 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""This module provides functions to add to shaders.""" + +from __future__ import absolute_import, division, unicode_literals + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "08/11/2016" + + +import contextlib +import logging +import numpy + +from ... import _glutils +from ..._glutils import gl + +from . import event +from . import utils + + +_logger = logging.getLogger(__name__) + + +class ProgramFunction(object): + """Class providing a function to add to a GLProgram shaders. + """ + + def setupProgram(self, context, program): + """Sets-up uniforms of a program using this shader function. + + :param RenderContext context: The current rendering context + :param GLProgram program: The program to set-up. + It MUST be in use and using this function. + """ + pass + + +class ClippingPlane(ProgramFunction): + """Description of a clipping plane and rendering. + + Convention: Clipping is performed in camera/eye space. + + :param point: Local coordinates of a point on the plane. + :type point: numpy.ndarray-like of 3 float32 + :param normal: Local coordinates of the plane normal. + :type normal: numpy.ndarray-like of 3 float32 + """ + + _fragDecl = """ + /* Clipping plane */ + /* as rx + gy + bz + a > 0, clipping all positive */ + uniform vec4 planeEq; + + /* Position is in camera/eye coordinates */ + + bool isClipped(vec4 position) { + vec4 tmp = planeEq * position; + float value = tmp.x + tmp.y + tmp.z + planeEq.a; + return (value < 0.0001); + } + + void clipping(vec4 position) { + if (isClipped(position)) { + discard; + } + } + /* End of clipping */ + """ + + _fragDeclNoop = """ + bool isClipped(vec4 position) + { + return false; + } + + void clipping(vec4 position) {} + """ + + def __init__(self, point=(0., 0., 0.), normal=(0., 0., 0.)): + self._plane = utils.Plane(point, normal) + + @property + def plane(self): + """Plane parameters in camera space.""" + return self._plane + + # GL2 + + @property + def fragDecl(self): + return self._fragDecl if self.plane.isPlane else self._fragDeclNoop + + @property + def fragCall(self): + return "clipping" + + def setupProgram(self, context, program): + """Sets-up uniforms of a program using this shader function. + + :param RenderContext context: The current rendering context + :param GLProgram program: The program to set-up. + It MUST be in use and using this function. + """ + if self.plane.isPlane: + gl.glUniform4f(program.uniforms['planeEq'], *self.plane.parameters) + + +class DirectionalLight(event.Notifier, ProgramFunction): + """Description of a directional Phong light. + + :param direction: The direction of the light or None to disable light + :type direction: ndarray of 3 floats or None + :param ambient: RGB ambient light + :type ambient: ndarray of 3 floats in [0, 1], default: (1., 1., 1.) + :param diffuse: RGB diffuse light parameter + :type diffuse: ndarray of 3 floats in [0, 1], default: (0., 0., 0.) + :param specular: RGB specular light parameter + :type specular: ndarray of 3 floats in [0, 1], default: (1., 1., 1.) + :param int shininess: The shininess of the material for specular term, + default: 0 which disables specular component. + """ + + fragmentShaderFunction = """ + /* Lighting */ + struct DLight { + vec3 lightDir; // Direction of light in object space + vec3 ambient; + vec3 diffuse; + vec3 specular; + float shininess; + vec3 viewPos; // Camera position in object space + }; + + uniform DLight dLight; + + vec4 lighting(vec4 color, vec3 position, vec3 normal) + { + normal = normalize(normal); + // 1-sided + float nDotL = max(0.0, dot(normal, - dLight.lightDir)); + + // 2-sided + //float nDotL = dot(normal, - dLight.lightDir); + //if (nDotL < 0.) { + // nDotL = - nDotL; + // normal = - normal; + //} + + float specFactor = 0.; + if (dLight.shininess > 0. && nDotL > 0.) { + vec3 reflection = reflect(dLight.lightDir, normal); + vec3 viewDir = normalize(dLight.viewPos - position); + specFactor = max(0.0, dot(reflection, viewDir)); + if (specFactor > 0.) { + specFactor = pow(specFactor, dLight.shininess); + } + } + + vec3 enlightedColor = color.rgb * (dLight.ambient + + dLight.diffuse * nDotL) + + dLight.specular * specFactor; + + return vec4(enlightedColor.rgb, color.a); + } + /* End of Lighting */ + """ + + fragmentShaderFunctionNoop = """ + vec4 lighting(vec4 color, vec3 position, vec3 normal) + { + return color; + } + """ + + def __init__(self, direction=None, + ambient=(1., 1., 1.), diffuse=(0., 0., 0.), + specular=(1., 1., 1.), shininess=0): + super(DirectionalLight, self).__init__() + self._direction = None + self.direction = direction # Set _direction + self._isOn = True + self._ambient = ambient + self._diffuse = diffuse + self._specular = specular + self._shininess = shininess + + ambient = event.notifyProperty('_ambient') + diffuse = event.notifyProperty('_diffuse') + specular = event.notifyProperty('_specular') + shininess = event.notifyProperty('_shininess') + + @property + def isOn(self): + """True if light is on, False otherwise.""" + return self._isOn and self._direction is not None + + @isOn.setter + def isOn(self, isOn): + self._isOn = bool(isOn) + + @contextlib.contextmanager + def turnOff(self): + """Context manager to temporary turn off lighting during rendering. + + >>> with light.turnOff(): + ... # Do some rendering without lighting + """ + wason = self._isOn + self._isOn = False + yield + self._isOn = wason + + @property + def direction(self): + """The direction of the light, or None if light is not on.""" + return self._direction + + @direction.setter + def direction(self, direction): + if direction is None: + self._direction = None + else: + assert len(direction) == 3 + direction = numpy.array(direction, dtype=numpy.float32, copy=True) + norm = numpy.linalg.norm(direction) + assert norm != 0 + self._direction = direction / norm + self.notify() + + # GL2 + + @property + def fragmentDef(self): + """Definition to add to fragment shader""" + if self.isOn: + return self.fragmentShaderFunction + else: + return self.fragmentShaderFunctionNoop + + @property + def fragmentCall(self): + """Function name to call in fragment shader""" + return "lighting" + + def setupProgram(self, context, program): + """Sets-up uniforms of a program using this shader function. + + :param RenderContext context: The current rendering context + :param GLProgram program: The program to set-up. + It MUST be in use and using this function. + """ + if self.isOn and self._direction is not None: + # Transform light direction from camera space to object coords + lightdir = context.objectToCamera.transformDir( + self._direction, direct=False) + lightdir /= numpy.linalg.norm(lightdir) + + gl.glUniform3f(program.uniforms['dLight.lightDir'], *lightdir) + + # Convert view position to object coords + viewpos = context.objectToCamera.transformPoint( + numpy.array((0., 0., 0., 1.), dtype=numpy.float32), + direct=False, + perspectiveDivide=True)[:3] + gl.glUniform3f(program.uniforms['dLight.viewPos'], *viewpos) + + gl.glUniform3f(program.uniforms['dLight.ambient'], *self.ambient) + gl.glUniform3f(program.uniforms['dLight.diffuse'], *self.diffuse) + gl.glUniform3f(program.uniforms['dLight.specular'], *self.specular) + gl.glUniform1f(program.uniforms['dLight.shininess'], + self.shininess) + + +class Colormap(event.Notifier, ProgramFunction): + # TODO use colors for out-of-bound values, for <=0 with log, for nan + + decl = """ + uniform struct { + sampler2D texture; + bool isLog; + float min; + float oneOverRange; + } cmap; + + const float oneOverLog10 = 0.43429448190325176; + + vec4 colormap(float value) { + if (cmap.isLog) { /* Log10 mapping */ + if (value > 0.0) { + value = clamp(cmap.oneOverRange * + (oneOverLog10 * log(value) - cmap.min), + 0.0, 1.0); + } else { + value = 0.0; + } + } else { /* Linear mapping */ + value = clamp(cmap.oneOverRange * (value - cmap.min), 0.0, 1.0); + } + + vec4 color = texture2D(cmap.texture, vec2(value, 0.5)); + return color; + } + """ + + call = "colormap" + + NORMS = 'linear', 'log' + """Tuple of supported normalizations.""" + + _COLORMAP_TEXTURE_UNIT = 1 + """Texture unit to use for storing the colormap""" + + def __init__(self, colormap=None, norm='linear', range_=(1., 10.)): + """Shader function to apply a colormap to a value. + + :param colormap: RGB(A) color look-up table (default: gray) + :param colormap: numpy.ndarray of numpy.uint8 of dimension Nx3 or Nx4 + :param str norm: Normalization to apply: 'linear' (default) or 'log'. + :param range_: Range of value to map to the colormap. + :type range_: 2-tuple of float (begin, end). + """ + super(Colormap, self).__init__() + + # Init privates to default + self._colormap, self._norm, self._range = None, 'linear', (1., 10.) + + self._texture = None + self._update_texture = True + + if colormap is None: + # default colormap + colormap = numpy.empty((256, 3), dtype=numpy.uint8) + colormap[:] = numpy.arange(256, + dtype=numpy.uint8)[:, numpy.newaxis] + + # Set to param values through properties to go through asserts + self.colormap = colormap + self.norm = norm + self.range_ = range_ + + @property + def colormap(self): + """Color look-up table to use.""" + return numpy.array(self._colormap, copy=True) + + @colormap.setter + def colormap(self, colormap): + colormap = numpy.array(colormap, copy=True) + assert colormap.ndim == 2 + assert colormap.shape[1] in (3, 4) + self._colormap = colormap + self._update_texture = True + self.notify() + + @property + def norm(self): + """Normalization to use for colormap mapping. + + Either 'linear' (the default) or 'log' for log10 mapping. + With 'log' normalization, values <= 0. are set to 1. (i.e. log == 0) + """ + return self._norm + + @norm.setter + def norm(self, norm): + if norm != self._norm: + assert norm in self.NORMS + self._norm = norm + if norm == 'log': + self.range_ = self.range_ # To test for positive range_ + self.notify() + + @property + def range_(self): + """Range of values to map to the colormap. + + 2-tuple of floats: (begin, end). + The begin value is mapped to the origin of the colormap and the + end value is mapped to the other end of the colormap. + The colormap is reversed if begin > end. + """ + return self._range + + @range_.setter + def range_(self, range_): + assert len(range_) == 2 + range_ = float(range_[0]), float(range_[1]) + + if self.norm == 'log' and (range_[0] <= 0. or range_[1] <= 0.): + _logger.warn( + "Log normalization and negative range: updating range.") + minPos = numpy.finfo(numpy.float32).tiny + range_ = max(range_[0], minPos), max(range_[1], minPos) + + if range_ != self._range: + self._range = range_ + self.notify() + + def setupProgram(self, context, program): + """Sets-up uniforms of a program using this shader function. + + :param RenderContext context: The current rendering context + :param GLProgram program: The program to set-up. + It MUST be in use and using this function. + """ + self.prepareGL2(context) # TODO see how to handle + + if self._texture is None: # No colormap + return + + self._texture.bind() + + gl.glUniform1i(program.uniforms['cmap.texture'], + self._texture.texUnit) + gl.glUniform1i(program.uniforms['cmap.isLog'], self._norm == 'log') + + min_, max_ = self.range_ + if self._norm == 'log': + min_, max_ = numpy.log10(min_), numpy.log10(max_) + + gl.glUniform1f(program.uniforms['cmap.min'], min_) + gl.glUniform1f(program.uniforms['cmap.oneOverRange'], + (1. / (max_ - min_)) if max_ != min_ else 0.) + + def prepareGL2(self, context): + if self._texture is None or self._update_texture: + if self._texture is not None: + self._texture.discard() + + colormap = numpy.empty( + (16, self._colormap.shape[0], self._colormap.shape[1]), + dtype=self._colormap.dtype) + colormap[:] = self._colormap + + format_ = gl.GL_RGBA if colormap.shape[-1] == 4 else gl.GL_RGB + + self._texture = _glutils.Texture( + format_, colormap, format_, + texUnit=self._COLORMAP_TEXTURE_UNIT, + minFilter=gl.GL_NEAREST, + magFilter=gl.GL_NEAREST, + wrap=gl.GL_CLAMP_TO_EDGE) + self._update_texture = False diff --git a/silx/gui/plot3d/scene/interaction.py b/silx/gui/plot3d/scene/interaction.py new file mode 100644 index 0000000..2911b2c --- /dev/null +++ b/silx/gui/plot3d/scene/interaction.py @@ -0,0 +1,681 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2015-2017 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""This module provides interaction to plug on the scene graph.""" + +from __future__ import absolute_import + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "25/07/2016" + +import logging +import numpy + +from silx.gui.plot.Interaction import \ + StateMachine, State, LEFT_BTN, RIGHT_BTN # , MIDDLE_BTN + +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.""" + + DRAG_THRESHOLD_SQUARE_DIST = 5 ** 2 + + class Idle(State): + def onPress(self, x, y, btn): + if btn == self.machine.button: + self.goto('clickOrDrag', x, y) + return True + + class ClickOrDrag(State): + def enterState(self, x, y): + self.initPos = x, y + + enter = enterState # silx v.0.3 support, remove when 0.4 out + + def onMove(self, x, y): + dx = (x - self.initPos[0]) ** 2 + dy = (y - self.initPos[1]) ** 2 + if (dx ** 2 + dy ** 2) >= self.machine.DRAG_THRESHOLD_SQUARE_DIST: + self.goto('drag', self.initPos, (x, y)) + + def onRelease(self, x, y, btn): + if btn == self.machine.button: + self.machine.click(x, y) + self.goto('idle') + + class Drag(State): + def enterState(self, initPos, curPos): + self.initPos = initPos + self.machine.beginDrag(*initPos) + self.machine.drag(*curPos) + + enter = enterState # silx v.0.3 support, remove when 0.4 out + + def onMove(self, x, y): + self.machine.drag(x, y) + + def onRelease(self, x, y, btn): + if btn == self.machine.button: + self.machine.endDrag(self.initPos, (x, y)) + self.goto('idle') + + def __init__(self, button=LEFT_BTN): + self.button = button + states = { + 'idle': ClickOrDrag.Idle, + 'clickOrDrag': ClickOrDrag.ClickOrDrag, + 'drag': ClickOrDrag.Drag + } + super(ClickOrDrag, self).__init__(states, 'idle') + + def click(self, x, y): + """Called upon a left or right button click. + To override in a subclass. + """ + pass + + def beginDrag(self, x, y): + """Called at the beginning of a drag gesture with left button + pressed. + To override in a subclass. + """ + pass + + def drag(self, x, y): + """Called on mouse moved during a drag gesture. + To override in a subclass. + """ + pass + + def endDrag(self, x, y): + """Called at the end of a drag gesture when the left button is + released. + To override in a subclass. + """ + pass + + +# CameraRotate ################################################################ + +class CameraRotate(ClickOrDrag): + """Camera rotation using an arcball-like interaction.""" + + def __init__(self, viewport, orbitAroundCenter=True, button=RIGHT_BTN): + self._viewport = viewport + self._orbitAroundCenter = orbitAroundCenter + self._reset() + super(CameraRotate, self).__init__(button) + + def _reset(self): + self._origin, self._center = None, None + self._startExtrinsic = None + + def click(self, x, y): + pass # No interaction yet + + def beginDrag(self, x, y): + centerPos = None + if not self._orbitAroundCenter: + # Try to use picked object position as center of rotation + ndcZ = self._viewport._pickNdcZGL(x, y) + if ndcZ != 1.: + # Hit an object, use picked point as center + centerPos = self._viewport._getXZYGL(x, y) # Can return None + + if centerPos is None: + # Not using picked position, use scene center + bounds = self._viewport.scene.bounds(transformed=True) + centerPos = 0.5 * (bounds[0] + bounds[1]) + + self._center = transform.Translate(*centerPos) + self._origin = x, y + self._startExtrinsic = self._viewport.camera.extrinsic.copy() + + def drag(self, x, y): + if self._center is None: + return + + dx, dy = self._origin[0] - x, self._origin[1] - y + + if dx == 0 and dy == 0: + direction = self._startExtrinsic.direction + up = self._startExtrinsic.up + position = self._startExtrinsic.position + else: + minsize = min(self._viewport.size) + distance = numpy.sqrt(dx ** 2 + dy ** 2) + angle = distance / minsize * numpy.pi + + # Take care of y inversion + direction = dx * self._startExtrinsic.side - \ + dy * self._startExtrinsic.up + direction /= numpy.linalg.norm(direction) + axis = numpy.cross(direction, self._startExtrinsic.direction) + axis /= numpy.linalg.norm(axis) + + # Orbit start camera with current angle and axis + # Rotate viewing direction + rotation = transform.Rotate(numpy.degrees(angle), *axis) + direction = rotation.transformDir(self._startExtrinsic.direction) + up = rotation.transformDir(self._startExtrinsic.up) + + # Rotate position around center + trlist = transform.StaticTransformList(( + self._center, + rotation, + self._center.inverse())) + position = trlist.transformPoint(self._startExtrinsic.position) + + camerapos = self._viewport.camera.extrinsic + camerapos.setOrientation(direction, up) + camerapos.position = position + + def endDrag(self, x, y): + self._reset() + + +# CameraSelectPan ############################################################# + +class CameraSelectPan(ClickOrDrag): + """Picking on click and pan camera on drag.""" + + def __init__(self, viewport, button=LEFT_BTN, selectCB=None): + self._viewport = viewport + self._selectCB = selectCB + self._lastPosNdc = None + super(CameraSelectPan, self).__init__(button) + + def click(self, x, y): + 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): + ndc = self._viewport.windowToNdc(x, y) + ndcZ = self._viewport._pickNdcZGL(x, y) + # ndcZ is the panning plane + if ndc is not None and ndcZ is not None: + self._lastPosNdc = numpy.array((ndc[0], ndc[1], ndcZ, 1.), + dtype=numpy.float32) + else: + self._lastPosNdc = None + + def drag(self, x, y): + if self._lastPosNdc is not None: + ndc = self._viewport.windowToNdc(x, y) + if ndc is not None: + ndcPos = numpy.array((ndc[0], ndc[1], self._lastPosNdc[2], 1.), + dtype=numpy.float32) + + # Convert last and current NDC positions to scene coords + scenePos = self._viewport.camera.transformPoint( + ndcPos, direct=False, perspectiveDivide=True) + lastScenePos = self._viewport.camera.transformPoint( + self._lastPosNdc, direct=False, perspectiveDivide=True) + + # Get translation in scene coords + translation = scenePos[:3] - lastScenePos[:3] + self._viewport.camera.extrinsic.position -= translation + + # Store for next drag + self._lastPosNdc = ndcPos + + def endDrag(self, x, y): + self._lastPosNdc = None + + +# CameraWheel ################################################################# + +class CameraWheel(object): + """StateMachine like class, just handling wheel events.""" + + # TODO choose scale of motion? Translation or Scale? + def __init__(self, viewport, mode='center', scaleTransform=None): + assert mode in ('center', 'position', 'scale') + self._viewport = viewport + if mode == 'center': + self._zoomTo = self._zoomToCenter + elif mode == 'position': + self._zoomTo = self._zoomToPosition + elif mode == 'scale': + self._zoomTo = self._zoomByScale + self._scale = scaleTransform + else: + raise ValueError('Unsupported mode: %s' % mode) + + def handleEvent(self, eventName, *args, **kwargs): + if eventName == 'wheel': + return self._zoomTo(*args, **kwargs) + + def _zoomToCenter(self, x, y, angleInDegrees): + """Zoom to center of display. + + Only works with perspective camera. + """ + direction = 'forward' if angleInDegrees > 0 else 'backward' + self._viewport.camera.move(direction) + return True + + def _zoomToPositionAbsolute(self, x, y, angleInDegrees): + """Zoom while keeping pixel under mouse invariant. + + Only works with perspective camera. + """ + ndc = self._viewport.windowToNdc(x, y) + if ndc is not None: + near = numpy.array((ndc[0], ndc[1], -1., 1.), dtype=numpy.float32) + + nearscene = self._viewport.camera.transformPoint( + near, direct=False, perspectiveDivide=True) + + far = numpy.array((ndc[0], ndc[1], 1., 1.), dtype=numpy.float32) + farscene = self._viewport.camera.transformPoint( + far, direct=False, perspectiveDivide=True) + + dirscene = farscene[:3] - nearscene[:3] + dirscene /= numpy.linalg.norm(dirscene) + + if angleInDegrees < 0: + dirscene *= -1. + + # TODO which scale + self._viewport.camera.extrinsic.position += dirscene + return True + + def _zoomToPosition(self, x, y, angleInDegrees): + """Zoom while keeping pixel under mouse invariant.""" + projection = self._viewport.camera.intrinsic + extrinsic = self._viewport.camera.extrinsic + + if isinstance(projection, transform.Perspective): + # For perspective projection, move camera + ndc = self._viewport.windowToNdc(x, y) + if ndc is not None: + ndcz = self._viewport._pickNdcZGL(x, y) + + position = numpy.array((ndc[0], ndc[1], ndcz), + dtype=numpy.float32) + positionscene = self._viewport.camera.transformPoint( + position, direct=False, perspectiveDivide=True) + + camtopos = extrinsic.position - positionscene + + step = 0.2 * (1. if angleInDegrees < 0 else -1.) + extrinsic.position += step * camtopos + + elif isinstance(projection, transform.Orthographic): + # For orthographic projection, change projection borders + ndcx, ndcy = self._viewport.windowToNdc(x, y, checkInside=False) + + step = 0.2 * (1. if angleInDegrees < 0 else -1.) + + dx = (ndcx + 1) / 2. + stepwidth = step * (projection.right - projection.left) + left = projection.left - dx * stepwidth + right = projection.right + (1. - dx) * stepwidth + + dy = (ndcy + 1) / 2. + stepheight = step * (projection.top - projection.bottom) + bottom = projection.bottom - dy * stepheight + top = projection.top + (1. - dy) * stepheight + + projection.setClipping(left, right, bottom, top) + + else: + raise RuntimeError('Unsupported camera', projection) + return True + + def _zoomByScale(self, x, y, angleInDegrees): + """Zoom by scaling scene (do not keep pixel under mouse invariant).""" + scalefactor = 1.1 + if angleInDegrees < 0.: + scalefactor = 1. / scalefactor + self._scale.scale = scalefactor * self._scale.scale + + self._viewport.adjustCameraDepthExtent() + return True + + +# FocusManager ################################################################ + +class FocusManager(StateMachine): + """Manages focus across multiple event handlers + + On press an event handler can acquire focus. + By default it looses focus when all buttons are released. + """ + class Idle(State): + def onPress(self, x, y, btn): + for eventHandler in self.machine.eventHandlers: + requestfocus = eventHandler.handleEvent('press', x, y, btn) + if requestfocus: + self.goto('focus', eventHandler, btn) + break + + def _processEvent(self, *args): + for eventHandler in self.machine.eventHandlers: + consumeevent = eventHandler.handleEvent(*args) + if consumeevent: + break + + def onMove(self, x, y): + self._processEvent('move', x, y) + + def onRelease(self, x, y, btn): + self._processEvent('release', x, y, btn) + + def onWheel(self, x, y, angle): + self._processEvent('wheel', x, y, angle) + + class Focus(State): + def enterState(self, eventHandler, btn): + self.eventHandler = eventHandler + self.focusBtns = {btn} # Set + + enter = enterState # silx v.0.3 support, remove when 0.4 out + + def onPress(self, x, y, btn): + self.focusBtns.add(btn) + self.eventHandler.handleEvent('press', x, y, btn) + + def onMove(self, x, y): + self.eventHandler.handleEvent('move', x, y) + + def onRelease(self, x, y, btn): + self.focusBtns.discard(btn) + requestfocus = self.eventHandler.handleEvent('release', x, y, btn) + if len(self.focusBtns) == 0 and not requestfocus: + self.goto('idle') + + def onWheel(self, x, y, angleInDegrees): + self.eventHandler.handleEvent('wheel', x, y, angleInDegrees) + + def __init__(self, eventHandlers=()): + self.eventHandlers = list(eventHandlers) + + states = { + 'idle': FocusManager.Idle, + 'focus': FocusManager.Focus + } + super(FocusManager, self).__init__(states, 'idle') + + def cancel(self): + for handler in self.eventHandlers: + handler.cancel() + + +# CameraControl ############################################################### + +class RotateCameraControl(FocusManager): + """Combine wheel and rotate state machine.""" + def __init__(self, viewport, + orbitAroundCenter=False, + mode='center', scaleTransform=None): + handlers = (CameraWheel(viewport, mode, scaleTransform), + CameraRotate(viewport, orbitAroundCenter, LEFT_BTN)) + super(RotateCameraControl, self).__init__(handlers) + + +class PanCameraControl(FocusManager): + """Combine wheel, selectPan and rotate state machine.""" + def __init__(self, viewport, + mode='center', scaleTransform=None, + selectCB=None): + handlers = (CameraWheel(viewport, mode, scaleTransform), + CameraSelectPan(viewport, LEFT_BTN, selectCB)) + super(PanCameraControl, self).__init__(handlers) + + +class CameraControl(FocusManager): + """Combine wheel, selectPan and rotate state machine.""" + def __init__(self, viewport, + orbitAroundCenter=False, + mode='center', scaleTransform=None, + selectCB=None): + handlers = (CameraWheel(viewport, mode, scaleTransform), + CameraSelectPan(viewport, LEFT_BTN, selectCB), + CameraRotate(viewport, orbitAroundCenter, RIGHT_BTN)) + super(CameraControl, self).__init__(handlers) + + +# PlaneRotate ################################################################# + +class PlaneRotate(ClickOrDrag): + """Plane rotation using arcball interaction. + + Arcball ref.: + Ken Shoemake. ARCBALL: A user interface for specifying three-dimensional + orientation using a mouse. In Proc. GI '92. (1992). pp. 151-156. + """ + + def __init__(self, viewport, plane, button=RIGHT_BTN): + self._viewport = viewport + self._plane = plane + self._reset() + super(PlaneRotate, self).__init__(button) + + def _reset(self): + self._beginNormal, self._beginCenter = None, None + + def click(self, x, y): + pass # No interaction + + @staticmethod + def _sphereUnitVector(radius, center, position): + """Returns the unit vector of the projection of position on a sphere. + + It assumes an orthographic projection. + For perspective projection, it gives an approximation, but it + simplifies computations and results in consistent arcball control + in control space. + + All parameters must be in screen coordinate system + (either pixels or normalized coordinates). + + :param float radius: The radius of the sphere. + :param center: (x, y) coordinates of the center. + :param position: (x, y) coordinates of the cursor position. + :return: Unit vector. + :rtype: numpy.ndarray of 3 floats. + """ + center, position = numpy.array(center), numpy.array(position) + + # Normalize x and y on a unit circle + spherecoords = (position - center) / float(radius) + squarelength = numpy.sum(spherecoords ** 2) + + # Project on the unit sphere and compute z coordinates + if squarelength > 1.0: # Outside sphere: project + spherecoords /= numpy.sqrt(squarelength) + zsphere = 0.0 + else: # In sphere: compute z + zsphere = numpy.sqrt(1. - squarelength) + + spherecoords = numpy.append(spherecoords, zsphere) + return spherecoords + + def beginDrag(self, x, y): + # Makes sure the point defining the plane is at the center as + # it will be the center of rotation (as rotation is applied to normal) + self._plane.plane.point = self._plane.center + + # Store the plane normal + self._beginNormal = self._plane.plane.normal + + _logger.debug( + 'Begin arcball, plane center %s', str(self._plane.center)) + + # Do the arcball on the screen + radius = min(self._viewport.size) + if self._plane.center is None: + self._beginCenter = None + + else: + center = self._plane.objectToNDCTransform.transformPoint( + self._plane.center, perspectiveDivide=True) + self._beginCenter = self._viewport.ndcToWindow( + center[0], center[1], checkInside=False) + + self._startVector = self._sphereUnitVector( + radius, self._beginCenter, (x, y)) + + def drag(self, x, y): + if self._beginCenter is None: + return + + # Compute rotation: this is twice the rotation of the arcball + radius = min(self._viewport.size) + currentvector = self._sphereUnitVector( + radius, self._beginCenter, (x, y)) + crossprod = numpy.cross(self._startVector, currentvector) + dotprod = numpy.dot(self._startVector, currentvector) + + quaternion = numpy.append(crossprod, dotprod) + # Rotation was computed with Y downward, but apply in NDC, invert Y + quaternion[1] *= -1. + + rotation = transform.Rotate() + rotation.quaternion = quaternion + + # Convert to NDC, rotate, convert back to object + normal = self._plane.objectToNDCTransform.transformNormal( + self._beginNormal) + normal = rotation.transformNormal(normal) + normal = self._plane.objectToNDCTransform.transformNormal( + normal, direct=False) + self._plane.plane.normal = normal + + def endDrag(self, x, y): + self._reset() + + +# PlanePan ################################################################### + +class PlanePan(ClickOrDrag): + """Pan a plane along its normal on drag.""" + + def __init__(self, viewport, plane, button=LEFT_BTN): + self._plane = plane + self._viewport = viewport + self._beginPlanePoint = None + self._beginPos = None + self._dragNdcZ = 0. + super(PlanePan, self).__init__(button) + + def click(self, x, y): + pass + + def beginDrag(self, x, y): + ndc = self._viewport.windowToNdc(x, y) + ndcZ = self._viewport._pickNdcZGL(x, y) + # ndcZ is the panning plane + if ndc is not None and ndcZ is not None: + ndcPos = numpy.array((ndc[0], ndc[1], ndcZ, 1.), + dtype=numpy.float32) + scenePos = self._viewport.camera.transformPoint( + ndcPos, direct=False, perspectiveDivide=True) + self._beginPos = self._plane.objectToSceneTransform.transformPoint( + scenePos, direct=False) + self._dragNdcZ = ndcZ + else: + self._beginPos = None + self._dragNdcZ = 0. + + self._beginPlanePoint = self._plane.plane.point + + def drag(self, x, y): + if self._beginPos is not None: + ndc = self._viewport.windowToNdc(x, y) + if ndc is not None: + ndcPos = numpy.array((ndc[0], ndc[1], self._dragNdcZ, 1.), + dtype=numpy.float32) + + # Convert last and current NDC positions to scene coords + scenePos = self._viewport.camera.transformPoint( + ndcPos, direct=False, perspectiveDivide=True) + curPos = self._plane.objectToSceneTransform.transformPoint( + scenePos, direct=False) + + # Get translation in scene coords + translation = curPos[:3] - self._beginPos[:3] + + newPoint = self._beginPlanePoint + translation + + # Keep plane point in bounds + bounds = self._plane.parent.bounds(dataBounds=True) + if bounds is not None: + newPoint = numpy.clip( + newPoint, a_min=bounds[0], a_max=bounds[1]) + + # Only update plane if it is in some bounds + self._plane.plane.point = newPoint + + def endDrag(self, x, y): + self._beginPlanePoint = None + + +# PlaneControl ################################################################ + +class PlaneControl(FocusManager): + """Combine wheel, selectPan and rotate state machine for plane control.""" + def __init__(self, viewport, plane, + mode='center', scaleTransform=None): + handlers = (CameraWheel(viewport, mode, scaleTransform), + PlanePan(viewport, plane, LEFT_BTN), + PlaneRotate(viewport, plane, RIGHT_BTN)) + super(PlaneControl, self).__init__(handlers) + + +class PanPlaneRotateCameraControl(FocusManager): + """Combine wheel, pan plane and camera rotate state machine.""" + def __init__(self, viewport, plane, + mode='center', scaleTransform=None): + handlers = (CameraWheel(viewport, mode, scaleTransform), + PlanePan(viewport, plane, LEFT_BTN), + CameraRotate(viewport, + orbitAroundCenter=False, + button=RIGHT_BTN)) + super(PanPlaneRotateCameraControl, self).__init__(handlers) + + +class PanPlaneZoomOnWheelControl(FocusManager): + """Combine zoom on wheel and pan plane state machines.""" + def __init__(self, viewport, plane, + mode='center', scaleTransform=None): + handlers = (CameraWheel(viewport, mode, scaleTransform), + PlanePan(viewport, plane, LEFT_BTN)) + super(PanPlaneZoomOnWheelControl, self).__init__(handlers) diff --git a/silx/gui/plot3d/scene/primitives.py b/silx/gui/plot3d/scene/primitives.py new file mode 100644 index 0000000..fc38e09 --- /dev/null +++ b/silx/gui/plot3d/scene/primitives.py @@ -0,0 +1,1767 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2015-2017 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. +# +# ###########################################################################*/ + +from __future__ import absolute_import, division, unicode_literals + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "25/07/2016" + + +import collections +import ctypes +from functools import reduce +import logging +import string + +import numpy + +from silx.gui.plot.Colors import rgba + +from ... import _glutils +from ..._glutils import gl + +from . import event +from . import core +from . import transform +from . import utils + +_logger = logging.getLogger(__name__) + + +# Geometry #################################################################### + +class Geometry(core.Elem): + """Set of vertices with normals and colors. + + :param str mode: OpenGL drawing mode: + lines, line_strip, loop, triangles, triangle_strip, fan + :param indices: Array of vertex indices or None + :param bool copy: True (default) to copy the data, False to use as is. + :param attributes: Provide list of attributes as extra parameters. + """ + + _ATTR_INFO = { + 'position': {'dims': (1, 2), 'lastDim': (2, 3, 4)}, + 'normal': {'dims': (1, 2), 'lastDim': (3,)}, + 'color': {'dims': (1, 2), 'lastDim': (3, 4)}, + } + + _MODE_CHECKS = { # Min, Modulo + 'lines': (2, 2), 'line_strip': (2, 0), 'loop': (2, 0), + 'points': (1, 0), + 'triangles': (3, 3), 'triangle_strip': (3, 0), 'fan': (3, 0) + } + + _MODES = { + 'lines': gl.GL_LINES, + 'line_strip': gl.GL_LINE_STRIP, + 'loop': gl.GL_LINE_LOOP, + + 'points': gl.GL_POINTS, + + 'triangles': gl.GL_TRIANGLES, + 'triangle_strip': gl.GL_TRIANGLE_STRIP, + 'fan': gl.GL_TRIANGLE_FAN + } + + _LINE_MODES = 'lines', 'line_strip', 'loop' + + _TRIANGLE_MODES = 'triangles', 'triangle_strip', 'fan' + + def __init__(self, mode, indices=None, copy=True, **attributes): + super(Geometry, self).__init__() + + self._vbos = {} # Store current vbos + self._unsyncAttributes = [] # Store attributes to copy to vbos + self.__bounds = None # Cache object's bounds + + assert mode in self._MODES + self._mode = mode + + # Set attributes + self._attributes = {} + for name, data in attributes.items(): + self.setAttribute(name, data, copy=copy) + + # Set indices + self._indices = None + self.setIndices(indices, copy=copy) + + # More consistency checks + mincheck, modulocheck = self._MODE_CHECKS[self._mode] + if self._indices is not None: + nbvertices = len(self._indices) + else: + nbvertices = self.nbVertices + assert nbvertices >= mincheck + if modulocheck != 0: + assert (nbvertices % modulocheck) == 0 + + @staticmethod + def _glReadyArray(array, copy=True): + """Making a contiguous array, checking float types. + + :param iterable array: array-like data to prepare for attribute + :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): + array = (array, ) + + # Makes sure it is an array + array = numpy.array(array, copy=False) + + # Cast all float to float32 + dtype = None + if numpy.dtype(array.dtype).kind == 'f': + dtype = numpy.float32 + + return numpy.array(array, dtype=dtype, order='C', copy=copy) + + @property + def nbVertices(self): + """Returns the number of vertices of current attributes. + + It returns None if there is no attributes. + """ + for array in self._attributes.values(): + if len(array.shape) == 2: + return len(array) + return None + + def setAttribute(self, name, array, copy=True): + """Set attribute with provided array. + + :param str name: The name of the attribute + :param array: Array-like attribute data or None to remove attribute + :param bool copy: True (default) to copy the data, False to use as is + """ + # This triggers associated GL resources to be garbage collected + self._vbos.pop(name, None) + + if array is None: + self._attributes.pop(name, None) + + else: + array = self._glReadyArray(array, copy=copy) + + if name not in self._ATTR_INFO: + _logger.info('Not checking attibute %s dimensions', name) + else: + checks = self._ATTR_INFO[name] + + if (len(array.shape) == 1 and checks['lastDim'] == (1,) and + len(array) > 1): + array = array.reshape((len(array), 1)) + + # Checks + assert len(array.shape) in checks['dims'], "Attr %s" % name + assert array.shape[-1] in checks['lastDim'], "Attr %s" % name + + # Check length against another attribute array + # Causes problems when updating + # nbVertices = self.nbVertices + # if len(array.shape) == 2 and nbVertices is not None: + # assert len(array) == nbVertices + + self._attributes[name] = array + if len(array.shape) == 2: # Store this in a VBO + self._unsyncAttributes.append(name) + + if name == 'position': # Reset bounds + self.__bounds = None + + self.notify() + + def getAttribute(self, name, copy=True): + """Returns the numpy.ndarray corresponding to the name attribute. + + :param str name: The name of the attribute to get. + :param bool copy: True to get a copy (default), + False to get internal array (DO NOT MODIFY) + :return: The corresponding array or None if no corresponding attribute. + :rtype: numpy.ndarray + """ + attr = self._attributes.get(name, None) + return None if attr is None else numpy.array(attr, copy=copy) + + def useAttribute(self, program, name=None): + """Enable and bind attribute(s) for a specific program. + + This MUST be called with OpenGL context active and after prepareGL2 + has been called. + + :param GLProgram program: The program for which to set the attributes + :param str name: The attribute name to set or None to set then all + """ + if name is None: + for name in program.attributes: + self.useAttribute(program, name) + + else: + attribute = program.attributes.get(name) + if attribute is None: + return + + vboattrib = self._vbos.get(name) + if vboattrib is not None: + gl.glEnableVertexAttribArray(attribute) + vboattrib.setVertexAttrib(attribute) + + elif name not in self._attributes: + gl.glDisableVertexAttribArray(attribute) + + else: + array = self._attributes[name] + assert array is not None + + if len(array.shape) == 1: + assert len(array) in (1, 2, 3, 4) + gl.glDisableVertexAttribArray(attribute) + _glVertexAttribFunc = getattr( + _glutils.gl, 'glVertexAttrib{}f'.format(len(array))) + _glVertexAttribFunc(attribute, *array) + else: + # TODO As is this is a never event, remove? + gl.glEnableVertexAttribArray(attribute) + gl.glVertexAttribPointer( + attribute, + array.shape[-1], + _glutils.numpyToGLType(array.dtype), + gl.GL_FALSE, + 0, + array) + + def setIndices(self, indices, copy=True): + """Set the primitive indices to use. + + :param indices: Array-like of uint primitive indices or None to unset + :param bool copy: True (default) to copy the data, False to use as is + """ + # Trigger garbage collection of previous indices VBO if any + self._vbos.pop('__indices__', None) + + if indices is None: + self._indices = None + else: + indices = self._glReadyArray(indices, copy=copy).ravel() + assert indices.dtype.name in ('uint8', 'uint16', 'uint32') + if _logger.getEffectiveLevel() <= logging.DEBUG: + # This might be a costy check + assert indices.max() < self.nbVertices + self._indices = indices + + def getIndices(self, copy=True): + """Returns the numpy.ndarray corresponding to the indices. + + :param bool copy: True to get a copy (default), + False to get internal array (DO NOT MODIFY) + :return: The primitive indices array or None if not set. + :rtype: numpy.ndarray or None + """ + if self._indices is None: + return None + else: + return numpy.array(self._indices, copy=copy) + + def _bounds(self, dataBounds=False): + if self.__bounds is None: + self.__bounds = numpy.zeros((2, 3), dtype=numpy.float32) + # Support vertex with to 2 to 4 coordinates + positions = self._attributes['position'] + self.__bounds[0, :positions.shape[1]] = \ + numpy.nanmin(positions, axis=0)[:3] + self.__bounds[1, :positions.shape[1]] = \ + numpy.nanmax(positions, axis=0)[:3] + self.__bounds[numpy.isnan(self.__bounds)] = 0. # Avoid NaNs + return self.__bounds.copy() + + def prepareGL2(self, ctx): + # TODO manage _vbo and multiple GL context + allow to share them ! + # TODO make one or multiple VBO depending on len(vertices), + # TODO use a general common VBO for small amount of data + for name in self._unsyncAttributes: + array = self._attributes[name] + self._vbos[name] = ctx.glCtx.makeVboAttrib(array) + self._unsyncAttributes = [] + + if self._indices is not None and '__indices__' not in self._vbos: + vbo = ctx.glCtx.makeVbo(self._indices, + usage=gl.GL_STATIC_DRAW, + target=gl.GL_ELEMENT_ARRAY_BUFFER) + self._vbos['__indices__'] = vbo + + def _draw(self, program=None, nbVertices=None): + """Perform OpenGL draw calls. + + :param GLProgram program: + If not None, call :meth:`useAttribute` for this program. + :param int nbVertices: + The number of vertices to render or None to render all vertices. + """ + if program is not None: + self.useAttribute(program) + + if self._indices is None: + if nbVertices is None: + nbVertices = self.nbVertices + gl.glDrawArrays(self._MODES[self._mode], 0, nbVertices) + else: + if nbVertices is None: + nbVertices = self._indices.size + with self._vbos['__indices__']: + gl.glDrawElements(self._MODES[self._mode], + nbVertices, + _glutils.numpyToGLType(self._indices.dtype), + ctypes.c_void_p(0)) + + +# Lines ####################################################################### + +class Lines(Geometry): + """A set of segments""" + _shaders = (""" + attribute vec3 position; + attribute vec3 normal; + attribute vec4 color; + + uniform mat4 matrix; + uniform mat4 transformMat; + + varying vec4 vCameraPosition; + varying vec3 vPosition; + varying vec3 vNormal; + varying vec4 vColor; + + void main(void) + { + gl_Position = matrix * vec4(position, 1.0); + vCameraPosition = transformMat * vec4(position, 1.0); + vPosition = position; + vNormal = normal; + vColor = color; + } + """, + string.Template(""" + varying vec4 vCameraPosition; + varying vec3 vPosition; + varying vec3 vNormal; + varying vec4 vColor; + + $clippingDecl + $lightingFunction + + void main(void) + { + $clippingCall(vCameraPosition); + gl_FragColor = $lightingCall(vColor, vPosition, vNormal); + } + """)) + + def __init__(self, positions, normals=None, colors=(1., 1., 1., 1.), + indices=None, mode='lines', width=1.): + if mode == 'strip': + mode = 'line_strip' + assert mode in self._LINE_MODES + + self._width = width + self._smooth = True + + super(Lines, self).__init__(mode, indices, + position=positions, + normal=normals, + color=colors) + + width = event.notifyProperty('_width', converter=float, + doc="Width of the line in pixels.") + + smooth = event.notifyProperty( + '_smooth', + converter=bool, + doc="Smooth line rendering enabled (bool, default: True)") + + def renderGL2(self, ctx): + # Prepare program + isnormals = 'normal' in self._attributes + if isnormals: + fraglightfunction = ctx.viewport.light.fragmentDef + else: + fraglightfunction = ctx.viewport.light.fragmentShaderFunctionNoop + + fragment = self._shaders[1].substitute( + clippingDecl=ctx.clipper.fragDecl, + clippingCall=ctx.clipper.fragCall, + lightingFunction=fraglightfunction, + lightingCall=ctx.viewport.light.fragmentCall) + prog = ctx.glCtx.prog(self._shaders[0], fragment) + prog.use() + + if isnormals: + ctx.viewport.light.setupProgram(ctx, prog) + + gl.glLineWidth(self.width) + + prog.setUniformMatrix('matrix', ctx.objectToNDC.matrix) + prog.setUniformMatrix('transformMat', + ctx.objectToCamera.matrix, + safe=True) + + ctx.clipper.setupProgram(ctx, prog) + + with gl.enabled(gl.GL_LINE_SMOOTH, self._smooth): + self._draw(prog) + + +class DashedLines(Lines): + """Set of dashed lines + + This MUST be defined as a set of lines (no strip or loop). + """ + + _shaders = (""" + attribute vec3 position; + attribute vec3 origin; + attribute vec3 normal; + attribute vec4 color; + + uniform mat4 matrix; + uniform mat4 transformMat; + uniform vec2 viewportSize; /* Width, height of the viewport */ + + varying vec4 vCameraPosition; + varying vec3 vPosition; + varying vec3 vNormal; + varying vec4 vColor; + varying vec2 vOriginFragCoord; + + void main(void) + { + gl_Position = matrix * vec4(position, 1.0); + vCameraPosition = transformMat * vec4(position, 1.0); + vPosition = position; + vNormal = normal; + vColor = color; + + vec4 clipOrigin = matrix * vec4(origin, 1.0); + vec4 ndcOrigin = clipOrigin / clipOrigin.w; /* Perspective divide */ + /* Convert to same frame as gl_FragCoord: lower-left, pixel center at 0.5, 0.5 */ + vOriginFragCoord = (ndcOrigin.xy + vec2(1.0, 1.0)) * 0.5 * viewportSize + vec2(0.5, 0.5); + } + """, # noqa + string.Template(""" + varying vec4 vCameraPosition; + varying vec3 vPosition; + varying vec3 vNormal; + varying vec4 vColor; + varying vec2 vOriginFragCoord; + + uniform vec2 dash; + + $clippingDecl + $lightingFunction + + void main(void) + { + /* 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); + } + """)) + + def __init__(self, positions, colors=(1., 1., 1., 1.), + indices=None, width=1.): + self._dash = 1, 0 + super(DashedLines, self).__init__(positions=positions, + colors=colors, + indices=indices, + mode='lines', + width=width) + + @property + def dash(self): + """Dash of the line as a 2-tuple of lengths in pixels: (on, off)""" + return self._dash + + @dash.setter + def dash(self, dash): + dash = float(dash[0]), float(dash[1]) + if dash != self._dash: + self._dash = dash + self.notify() + + def getPositions(self, copy=True): + """Get coordinates of lines. + + :param bool copy: True to get a copy, False otherwise + :returns: Coordinates of lines + :rtype: numpy.ndarray of float32 of shape (N, 2, Ndim) + """ + return self.getAttribute('position', copy=copy) + + def setPositions(self, positions, copy=True): + """Set line coordinates. + + :param positions: Array of line coordinates + :param bool copy: True to copy input array, False to use as is + """ + self.setAttribute('position', positions, copy=copy) + # Update line origins from given positions + origins = numpy.array(positions, copy=True, order='C') + origins[1::2] = origins[::2] + self.setAttribute('origin', origins, copy=False) + + def renderGL2(self, context): + # Prepare program + isnormals = 'normal' in self._attributes + if isnormals: + fraglightfunction = context.viewport.light.fragmentDef + else: + fraglightfunction = \ + context.viewport.light.fragmentShaderFunctionNoop + + fragment = self._shaders[1].substitute( + clippingDecl=context.clipper.fragDecl, + clippingCall=context.clipper.fragCall, + lightingFunction=fraglightfunction, + lightingCall=context.viewport.light.fragmentCall) + program = context.glCtx.prog(self._shaders[0], fragment) + program.use() + + if isnormals: + context.viewport.light.setupProgram(context, program) + + gl.glLineWidth(self.width) + + program.setUniformMatrix('matrix', context.objectToNDC.matrix) + program.setUniformMatrix('transformMat', + context.objectToCamera.matrix, + safe=True) + + gl.glUniform2f( + program.uniforms['viewportSize'], *context.viewport.size) + gl.glUniform2f(program.uniforms['dash'], *self.dash) + + context.clipper.setupProgram(context, program) + + self._draw(program) + + +class Box(core.PrivateGroup): + """Rectangular box""" + + _lineIndices = numpy.array(( + (0, 1), (1, 2), (2, 3), (3, 0), # Lines with z=0 + (0, 4), (1, 5), (2, 6), (3, 7), # Lines from z=0 to z=1 + (4, 5), (5, 6), (6, 7), (7, 4)), # Lines with z=1 + dtype=numpy.uint8) + + _faceIndices = numpy.array( + (0, 3, 1, 2, 5, 6, 4, 7, 7, 6, 6, 2, 7, 3, 4, 0, 5, 1), + dtype=numpy.uint8) + + _vertices = numpy.array(( + # Corners with z=0 + (0., 0., 0.), (1., 0., 0.), (1., 1., 0.), (0., 1., 0.), + # Corners with z=1 + (0., 0., 1.), (1., 0., 1.), (1., 1., 1.), (0., 1., 1.)), + dtype=numpy.float32) + + def __init__(self, size=(1., 1., 1.), + stroke=(1., 1., 1., 1.), + fill=(1., 1., 1., 0.)): + super(Box, self).__init__() + + self._fill = Mesh3D(self._vertices, + colors=rgba(fill), + mode='triangle_strip', + indices=self._faceIndices) + self._fill.visible = self.fillColor[-1] != 0. + + self._stroke = Lines(self._vertices, + indices=self._lineIndices, + colors=rgba(stroke), + mode='lines') + self._stroke.visible = self.strokeColor[-1] != 0. + self.strokeWidth = 1. + + self._children = [self._stroke, self._fill] + + self._size = None + self.size = size + + @property + def size(self): + """Size of the box (sx, sy, sz)""" + return self._size + + @size.setter + def size(self, size): + assert len(size) == 3 + size = tuple(size) + if size != self.size: + self._size = size + self._fill.setAttribute( + 'position', + self._vertices * numpy.array(size, dtype=numpy.float32)) + self._stroke.setAttribute( + 'position', + self._vertices * numpy.array(size, dtype=numpy.float32)) + self.notify() + + @property + def strokeSmooth(self): + """True to draw smooth stroke, False otherwise""" + return self._stroke.smooth + + @strokeSmooth.setter + def strokeSmooth(self, smooth): + smooth = bool(smooth) + if smooth != self._stroke.smooth: + self._stroke.smooth = smooth + self.notify() + + @property + def strokeWidth(self): + """Width of the stroke (float)""" + return self._stroke.width + + @strokeWidth.setter + def strokeWidth(self, width): + width = float(width) + if width != self.strokeWidth: + self._stroke.width = width + self.notify() + + @property + def strokeColor(self): + """RGBA color of the box lines (4-tuple of float in [0, 1])""" + return tuple(self._stroke.getAttribute('color', copy=False)) + + @strokeColor.setter + def strokeColor(self, color): + color = rgba(color) + if color != self.strokeColor: + self._stroke.setAttribute('color', color) + # Fully transparent = hidden + self._stroke.visible = color[-1] != 0. + self.notify() + + @property + def fillColor(self): + """RGBA color of the box faces (4-tuple of float in [0, 1])""" + return tuple(self._fill.getAttribute('color', copy=False)) + + @fillColor.setter + def fillColor(self, color): + color = rgba(color) + if color != self.fillColor: + self._fill.setAttribute('color', color) + # Fully transparent = hidden + self._fill.visible = color[-1] != 0. + self.notify() + + @property + def fillCulling(self): + return self._fill.culling + + @fillCulling.setter + def fillCulling(self, culling): + self._fill.culling = culling + + +class Axes(Lines): + """3D RGB orthogonal axes""" + _vertices = numpy.array(((0., 0., 0.), (1., 0., 0.), + (0., 0., 0.), (0., 1., 0.), + (0., 0., 0.), (0., 0., 1.)), + dtype=numpy.float32) + + _colors = numpy.array(((255, 0, 0, 255), (255, 0, 0, 255), + (0, 255, 0, 255), (0, 255, 0, 255), + (0, 0, 255, 255), (0, 0, 255, 255)), + dtype=numpy.uint8) + + def __init__(self): + super(Axes, self).__init__(self._vertices, + colors=self._colors, + width=3.) + + +class BoxWithAxes(Lines): + """Rectangular box with RGB OX, OY, OZ axes + + :param color: RGBA color of the box + """ + + _vertices = numpy.array(( + # Axes corners + (0., 0., 0.), (1., 0., 0.), + (0., 0., 0.), (0., 1., 0.), + (0., 0., 0.), (0., 0., 1.), + # Box corners with z=0 + (1., 0., 0.), (1., 1., 0.), (0., 1., 0.), + # Box corners with z=1 + (0., 0., 1.), (1., 0., 1.), (1., 1., 1.), (0., 1., 1.)), + dtype=numpy.float32) + + _axesColors = numpy.array(((1., 0., 0., 1.), (1., 0., 0., 1.), + (0., 1., 0., 1.), (0., 1., 0., 1.), + (0., 0., 1., 1.), (0., 0., 1., 1.)), + dtype=numpy.float32) + + _lineIndices = numpy.array(( + (0, 1), (2, 3), (4, 5), # Axes lines + (6, 7), (7, 8), # Box lines with z=0 + (6, 10), (7, 11), (8, 12), # Box lines from z=0 to z=1 + (9, 10), (10, 11), (11, 12), (12, 9)), # Box lines with z=1 + dtype=numpy.uint8) + + def __init__(self, color=(1., 1., 1., 1.)): + self._color = (1., 1., 1., 1.) + colors = numpy.ones((len(self._vertices), 4), dtype=numpy.float32) + colors[:len(self._axesColors), :] = self._axesColors + + super(BoxWithAxes, self).__init__(self._vertices, + indices=self._lineIndices, + colors=colors, + width=2.) + self.color = color + + @property + def color(self): + """The RGBA color to use for the box: 4 float in [0, 1]""" + return self._color + + @color.setter + def color(self, color): + color = rgba(color) + if color != self._color: + self._color = color + colors = numpy.empty((len(self._vertices), 4), dtype=numpy.float32) + colors[:len(self._axesColors), :] = self._axesColors + colors[len(self._axesColors):, :] = self._color + self.setAttribute('color', colors) # Do the notification + + +class PlaneInGroup(core.PrivateGroup): + """A plane using its parent bounds to display a contour. + + If plane is outside the bounds of its parent, it is not visible. + + Cannot set the transform attribute of this primitive. + This primitive never has any bounds. + """ + # TODO inherit from Lines directly?, make sure the plane remains visible? + + def __init__(self, point=(0., 0., 0.), normal=(0., 0., 1.)): + super(PlaneInGroup, self).__init__() + self._cache = None, None # Store bounds, vertices + self._outline = None + + self._color = None + self.color = 1., 1., 1., 1. # Set _color + self._width = 2. + + self._plane = utils.Plane(point, normal) + self._plane.addListener(self._planeChanged) + + def moveToCenter(self): + """Place the plane at the center of the data, not changing orientation. + """ + if self.parent is not None: + bounds = self.parent.bounds(dataBounds=True) + if bounds is not None: + center = (bounds[0] + bounds[1]) / 2. + _logger.debug('Moving plane to center: %s', str(center)) + self.plane.point = center + + @property + def color(self): + """Plane outline color (array of 4 float in [0, 1]).""" + return self._color.copy() + + @color.setter + def color(self, color): + self._color = numpy.array(color, copy=True, dtype=numpy.float32) + if self._outline is not None: + self._outline.setAttribute('color', self._color) + self.notify() # This is OK as Lines are rebuild for each rendering + + @property + def width(self): + """Width of the plane stroke in pixels""" + return self._width + + @width.setter + def width(self, width): + self._width = float(width) + if self._outline is not None: + self._outline.width = self._width # Sync width + + # Plane access + + @property + def plane(self): + """The plane parameters in the frame of the object.""" + return self._plane + + def _planeChanged(self, source): + """Listener of plane changes: clear cache and notify listeners.""" + self._cache = None, None + self.notify() + + # Disable some scene features + + @property + def transforms(self): + # Ready-only transforms to prevent using it + return self._transforms + + def _bounds(self, dataBounds=False): + # This is bound less as it uses the bounds of its parent. + return None + + @property + def contourVertices(self): + """The vertices of the contour of the plane/bounds intersection.""" + parent = self.parent + if parent is None: + return None # No parent: no vertices + + bounds = parent.bounds(dataBounds=True) + if bounds is None: + return None # No bounds: no vertices + + # Check if cache is valid and return it + cachebounds, cachevertices = self._cache + if numpy.all(numpy.equal(bounds, cachebounds)): + return cachevertices + + # Cache is not OK, rebuild it + boxvertices = bounds[0] + Box._vertices.copy()*(bounds[1] - bounds[0]) + lineindices = Box._lineIndices + vertices = utils.boxPlaneIntersect( + boxvertices, lineindices, self.plane.normal, self.plane.point) + + self._cache = bounds, vertices if len(vertices) != 0 else None + + return self._cache[1] + + @property + def center(self): + """The center of the plane/bounds intersection points.""" + if not self.isValid: + return None + else: + return numpy.mean(self.contourVertices, axis=0) + + @property + def isValid(self): + """True if a contour is defined, False otherwise.""" + return self.plane.isPlane and self.contourVertices is not None + + def prepareGL2(self, ctx): + if self.isValid: + if self._outline is None: # Init outline + self._outline = Lines(self.contourVertices, + mode='loop', + colors=self.color) + self._outline.width = self._width + self._children.append(self._outline) + + # Update vertices, TODO only when necessary + self._outline.setAttribute('position', self.contourVertices) + + super(PlaneInGroup, self).prepareGL2(ctx) + + def renderGL2(self, ctx): + if self.isValid: + super(PlaneInGroup, self).renderGL2(ctx) + + +# Points ###################################################################### + +_POINTS_ATTR_INFO = Geometry._ATTR_INFO.copy() +_POINTS_ATTR_INFO.update(value={'dims': (1, 2), 'lastDim': (1,)}, + size={'dims': (1, 2), 'lastDim': (1,)}, + symbol={'dims': (1, 2), 'lastDim': (1,)}) + + +class Points(Geometry): + """A set of data points with an associated value and size.""" + _shaders = (""" + #version 120 + + attribute vec3 position; + attribute float symbol; + attribute float value; + attribute float size; + + uniform mat4 matrix; + uniform mat4 transformMat; + + uniform vec2 valRange; + + varying vec4 vCameraPosition; + varying float vSymbol; + varying float vNormValue; + varying float vSize; + + void main(void) + { + vSymbol = symbol; + + vNormValue = clamp((value - valRange.x) / (valRange.y - valRange.x), + 0.0, 1.0); + + bool isValueInRange = value >= valRange.x && value <= valRange.y; + if (isValueInRange) { + gl_Position = matrix * vec4(position, 1.0); + } else { + gl_Position = vec4(2.0, 0.0, 0.0, 1.0); /* Get clipped */ + } + vCameraPosition = transformMat * vec4(position, 1.0); + + gl_PointSize = size; + vSize = size; + } + """, + string.Template(""" + #version 120 + + varying vec4 vCameraPosition; + varying float vSize; + varying float vSymbol; + varying float vNormValue; + + $clippinDecl + + /* Circle */ + #define SYMBOL_CIRCLE 1.0 + + float alphaCircle(vec2 coord, float size) { + float radius = 0.5; + float r = distance(coord, vec2(0.5, 0.5)); + return clamp(size * (radius - r), 0.0, 1.0); + } + + /* Half lines */ + #define SYMBOL_H_LINE 2.0 + #define LEFT 1.0 + #define RIGHT 2.0 + #define SYMBOL_V_LINE 3.0 + #define UP 1.0 + #define DOWN 2.0 + + float alphaLine(vec2 coord, float size, float direction) + { + vec2 delta = abs(size * (coord - 0.5)); + + if (direction == SYMBOL_H_LINE) { + return (delta.y < 0.5) ? 1.0 : 0.0; + } + else if (direction == SYMBOL_H_LINE + LEFT) { + return (coord.x <= 0.5 && delta.y < 0.5) ? 1.0 : 0.0; + } + else if (direction == SYMBOL_H_LINE + RIGHT) { + return (coord.x >= 0.5 && delta.y < 0.5) ? 1.0 : 0.0; + } + else if (direction == SYMBOL_V_LINE) { + return (delta.x < 0.5) ? 1.0 : 0.0; + } + else if (direction == SYMBOL_V_LINE + UP) { + return (coord.y <= 0.5 && delta.x < 0.5) ? 1.0 : 0.0; + } + else if (direction == SYMBOL_V_LINE + DOWN) { + return (coord.y >= 0.5 && delta.x < 0.5) ? 1.0 : 0.0; + } + return 1.0; + } + + void main(void) + { + $clippingCall(vCameraPosition); + + gl_FragColor = vec4(0.5 * vNormValue + 0.5, 0.0, 0.0, 1.0); + + float alpha = 1.0; + float symbol = floor(vSymbol); + if (1 == 1) { //symbol == SYMBOL_CIRCLE) { + alpha = alphaCircle(gl_PointCoord, vSize); + } + else if (symbol >= SYMBOL_H_LINE && + symbol <= (SYMBOL_V_LINE + DOWN)) { + alpha = alphaLine(gl_PointCoord, vSize, symbol); + } + if (alpha == 0.0) { + discard; + } + gl_FragColor.a *= alpha; + } + """)) + + _ATTR_INFO = _POINTS_ATTR_INFO + + # TODO Add colormap, light? + + def __init__(self, vertices, values=0., sizes=1., indices=None, + symbols=0., + minValue=None, maxValue=None): + super(Points, self).__init__('points', indices, + position=vertices, + value=values, + size=sizes, + symbol=symbols) + + values = self._attributes['value'] + self._minValue = values.min() if minValue is None else minValue + self._maxValue = values.max() if maxValue is None else maxValue + + minValue = event.notifyProperty('_minValue') + maxValue = event.notifyProperty('_maxValue') + + def renderGL2(self, ctx): + fragment = self._shaders[1].substitute( + clippingDecl=ctx.clipper.fragDecl, + clippingCall=ctx.clipper.fragCall) + prog = ctx.glCtx.prog(self._shaders[0], fragment) + prog.use() + + gl.glEnable(gl.GL_VERTEX_PROGRAM_POINT_SIZE) # OpenGL 2 + gl.glEnable(gl.GL_POINT_SPRITE) # OpenGL 2 + # gl.glEnable(gl.GL_PROGRAM_POINT_SIZE) + + prog.setUniformMatrix('matrix', ctx.objectToNDC.matrix) + prog.setUniformMatrix('transformMat', + ctx.objectToCamera.matrix, + safe=True) + + ctx.clipper.setupProgram(ctx, prog) + + gl.glUniform2f(prog.uniforms['valRange'], self.minValue, self.maxValue) + + self._draw(prog) + + +class ColorPoints(Geometry): + """A set of points with an associated color and size.""" + + _shaders = (""" + #version 120 + + attribute vec3 position; + attribute float symbol; + attribute vec4 color; + attribute float size; + + uniform mat4 matrix; + uniform mat4 transformMat; + + varying vec4 vCameraPosition; + varying float vSymbol; + varying vec4 vColor; + varying float vSize; + + void main(void) + { + vCameraPosition = transformMat * vec4(position, 1.0); + vSymbol = symbol; + vColor = color; + gl_Position = matrix * vec4(position, 1.0); + gl_PointSize = size; + vSize = size; + } + """, + string.Template(""" + #version 120 + + varying vec4 vCameraPosition; + varying float vSize; + varying float vSymbol; + varying vec4 vColor; + + $clippingDecl; + + /* Circle */ + #define SYMBOL_CIRCLE 1.0 + + float alphaCircle(vec2 coord, float size) { + float radius = 0.5; + float r = distance(coord, vec2(0.5, 0.5)); + return clamp(size * (radius - r), 0.0, 1.0); + } + + /* Half lines */ + #define SYMBOL_H_LINE 2.0 + #define LEFT 1.0 + #define RIGHT 2.0 + #define SYMBOL_V_LINE 3.0 + #define UP 1.0 + #define DOWN 2.0 + + float alphaLine(vec2 coord, float size, float direction) + { + vec2 delta = abs(size * (coord - 0.5)); + + if (direction == SYMBOL_H_LINE) { + return (delta.y < 0.5) ? 1.0 : 0.0; + } + else if (direction == SYMBOL_H_LINE + LEFT) { + return (coord.x <= 0.5 && delta.y < 0.5) ? 1.0 : 0.0; + } + else if (direction == SYMBOL_H_LINE + RIGHT) { + return (coord.x >= 0.5 && delta.y < 0.5) ? 1.0 : 0.0; + } + else if (direction == SYMBOL_V_LINE) { + return (delta.x < 0.5) ? 1.0 : 0.0; + } + else if (direction == SYMBOL_V_LINE + UP) { + return (coord.y <= 0.5 && delta.x < 0.5) ? 1.0 : 0.0; + } + else if (direction == SYMBOL_V_LINE + DOWN) { + return (coord.y >= 0.5 && delta.x < 0.5) ? 1.0 : 0.0; + } + return 1.0; + } + + void main(void) + { + $clippingCall(vCameraPosition); + + gl_FragColor = vColor; + + float alpha = 1.0; + float symbol = floor(vSymbol); + if (1 == 1) { //symbol == SYMBOL_CIRCLE) { + alpha = alphaCircle(gl_PointCoord, vSize); + } + else if (symbol >= SYMBOL_H_LINE && + symbol <= (SYMBOL_V_LINE + DOWN)) { + alpha = alphaLine(gl_PointCoord, vSize, symbol); + } + if (alpha == 0.0) { + discard; + } + gl_FragColor.a *= alpha; + } + """)) + + _ATTR_INFO = _POINTS_ATTR_INFO + + def __init__(self, vertices, colors=(1., 1., 1., 1.), sizes=1., + indices=None, symbols=0., + minValue=None, maxValue=None): + super(ColorPoints, self).__init__('points', indices, + position=vertices, + color=colors, + size=sizes, + symbol=symbols) + + def renderGL2(self, ctx): + fragment = self._shaders[1].substitute( + clippingDecl=ctx.clipper.fragDecl, + clippingCall=ctx.clipper.fragCall) + prog = ctx.glCtx.prog(self._shaders[0], fragment) + prog.use() + + gl.glEnable(gl.GL_VERTEX_PROGRAM_POINT_SIZE) # OpenGL 2 + gl.glEnable(gl.GL_POINT_SPRITE) # OpenGL 2 + # gl.glEnable(gl.GL_PROGRAM_POINT_SIZE) + + prog.setUniformMatrix('matrix', ctx.objectToNDC.matrix) + prog.setUniformMatrix('transformMat', + ctx.objectToCamera.matrix, + safe=True) + + ctx.clipper.setupProgram(ctx, prog) + + self._draw(prog) + + +class GridPoints(Geometry): + # GLSL 1.30 ! + """Data points on a regular grid with an associated value and size.""" + _shaders = (""" + #version 130 + + in float value; + in float size; + + uniform ivec3 gridDims; + uniform mat4 matrix; + uniform mat4 transformMat; + uniform vec2 valRange; + + out vec4 vCameraPosition; + out float vNormValue; + + //ivec3 coordsFromIndex(int index, ivec3 shape) + //{ + /*Assumes that data is stored as z-major, then y, contiguous on x + */ + // int yxPlaneSize = shape.y * shape.x; /* nb of elem in 2d yx plane */ + // int z = index / yxPlaneSize; + // int yxIndex = index - z * yxPlaneSize; /* index in 2d yx plane */ + // int y = yxIndex / shape.x; + // int x = yxIndex - y * shape.x; + // return ivec3(x, y, z); + // } + + ivec3 coordsFromIndex(int index, ivec3 shape) + { + /*Assumes that data is stored as x-major, then y, contiguous on z + */ + int yzPlaneSize = shape.y * shape.z; /* nb of elem in 2d yz plane */ + int x = index / yzPlaneSize; + int yzIndex = index - x * yzPlaneSize; /* index in 2d yz plane */ + int y = yzIndex / shape.z; + int z = yzIndex - y * shape.z; + return ivec3(x, y, z); + } + + void main(void) + { + vNormValue = clamp((value - valRange.x) / (valRange.y - valRange.x), + 0.0, 1.0); + + bool isValueInRange = value >= valRange.x && value <= valRange.y; + if (isValueInRange) { + /* Retrieve 3D position from gridIndex */ + vec3 coords = vec3(coordsFromIndex(gl_VertexID, gridDims)); + vec3 position = coords / max(vec3(gridDims) - 1.0, 1.0); + gl_Position = matrix * vec4(position, 1.0); + vCameraPosition = transformMat * vec4(position, 1.0); + } else { + gl_Position = vec4(2.0, 0.0, 0.0, 1.0); /* Get clipped */ + vCameraPosition = vec4(0.0, 0.0, 0.0, 0.0); + } + + gl_PointSize = size; + } + """, + string.Template(""" + #version 130 + + in vec4 vCameraPosition; + in float vNormValue; + out vec4 fragColor; + + $clippingDecl + + void main(void) + { + $clippingCall(vCameraPosition); + + fragColor = vec4(0.5 * vNormValue + 0.5, 0.0, 0.0, 1.0); + } + """)) + + _ATTR_INFO = { + 'value': {'dims': (1, 2), 'lastDim': (1,)}, + 'size': {'dims': (1, 2), 'lastDim': (1,)} + } + + # TODO Add colormap, shape? + # TODO could also use a texture to store values + + def __init__(self, values=0., shape=None, sizes=1., indices=None, + minValue=None, maxValue=None): + if isinstance(values, collections.Iterable): + values = numpy.array(values, copy=False) + + # Test if gl_VertexID will overflow + assert values.size < numpy.iinfo(numpy.int32).max + + self._shape = values.shape + values = values.ravel() # 1D to add as a 1D vertex attribute + + else: + assert shape is not None + self._shape = tuple(shape) + + assert len(self._shape) in (1, 2, 3) + + super(GridPoints, self).__init__('points', indices, + value=values, + size=sizes) + + data = self.getAttribute('value', copy=False) + self._minValue = data.min() if minValue is None else minValue + self._maxValue = data.max() if maxValue is None else maxValue + + minValue = event.notifyProperty('_minValue') + maxValue = event.notifyProperty('_maxValue') + + def _bounds(self, dataBounds=False): + # Get bounds from values shape + bounds = numpy.zeros((2, 3), dtype=numpy.float32) + bounds[1, :] = self._shape + bounds[1, :] -= 1 + return bounds + + def renderGL2(self, ctx): + fragment = self._shaders[1].substitute( + clippingDecl=ctx.clipper.fragDecl, + clippingCall=ctx.clipper.fragCall) + prog = ctx.glCtx.prog(self._shaders[0], fragment) + prog.use() + + gl.glEnable(gl.GL_VERTEX_PROGRAM_POINT_SIZE) # OpenGL 2 + gl.glEnable(gl.GL_POINT_SPRITE) # OpenGL 2 + # gl.glEnable(gl.GL_PROGRAM_POINT_SIZE) + + prog.setUniformMatrix('matrix', ctx.objectToNDC.matrix) + prog.setUniformMatrix('transformMat', + ctx.objectToCamera.matrix, + safe=True) + + ctx.clipper.setupProgram(ctx, prog) + + gl.glUniform3i(prog.uniforms['gridDims'], + self._shape[2] if len(self._shape) == 3 else 1, + self._shape[1] if len(self._shape) >= 2 else 1, + self._shape[0]) + + gl.glUniform2f(prog.uniforms['valRange'], self.minValue, self.maxValue) + + self._draw(prog, nbVertices=reduce(lambda a, b: a * b, self._shape)) + + +# Spheres ##################################################################### + +class Spheres(Geometry): + """A set of spheres. + + Spheres are rendered as circles using points. + This brings some limitations: + - Do not support non-uniform scaling. + - Assume the projection keeps ratio. + - Do not render distorion by perspective projection. + - If the sphere center is clipped, the whole sphere is not displayed. + """ + # TODO check those links + # Accounting for perspective projection + # http://iquilezles.org/www/articles/sphereproj/sphereproj.htm + + # Michael Mara and Morgan McGuire. + # 2D Polyhedral Bounds of a Clipped, Perspective-Projected 3D Sphere + # Journal of Computer Graphics Techniques, Vol. 2, No. 2, 2013. + # http://jcgt.org/published/0002/02/05/paper.pdf + # https://research.nvidia.com/publication/2d-polyhedral-bounds-clipped-perspective-projected-3d-sphere + + # TODO some issues with small scaling and regular grid or due to sampling + + _shaders = (""" + #version 120 + + attribute vec3 position; + attribute vec4 color; + attribute float radius; + + uniform mat4 transformMat; + uniform mat4 projMat; + uniform vec2 screenSize; + + varying vec4 vCameraPosition; + varying vec3 vPosition; + varying vec4 vColor; + varying float vViewDepth; + varying float vViewRadius; + + void main(void) + { + vCameraPosition = transformMat * vec4(position, 1.0); + gl_Position = projMat * vCameraPosition; + + vPosition = gl_Position.xyz / gl_Position.w; + + /* From object space radius to view space diameter. + * Do not support non-uniform scaling */ + vec4 viewSizeVector = transformMat * vec4(2.0 * radius, 0.0, 0.0, 0.0); + float viewSize = length(viewSizeVector.xyz); + + /* Convert to pixel size at the xy center of the view space */ + vec4 projSize = projMat * vec4(0.5 * viewSize, 0.0, + vCameraPosition.z, vCameraPosition.w); + gl_PointSize = max(1.0, screenSize[0] * projSize.x / projSize.w); + + vColor = color; + vViewRadius = 0.5 * viewSize; + vViewDepth = vCameraPosition.z; + } + """, + string.Template(""" + # version 120 + + uniform mat4 projMat; + + varying vec4 vCameraPosition; + varying vec3 vPosition; + varying vec4 vColor; + varying float vViewDepth; + varying float vViewRadius; + + $clippingDecl + $lightingFunction + + void main(void) + { + $clippingCall(vCameraPosition); + + /* Get normal from point coords */ + vec3 normal; + normal.xy = 2.0 * gl_PointCoord - vec2(1.0); + normal.y *= -1.0; /*Invert y to match NDC orientation*/ + float sqLength = dot(normal.xy, normal.xy); + if (sqLength > 1.0) { /* Length -> out of sphere */ + discard; + } + normal.z = sqrt(1.0 - sqLength); + + /*Lighting performed in NDC*/ + /*TODO update this when lighting changed*/ + //XXX vec3 position = vPosition + vViewRadius * normal; + gl_FragColor = $lightingCall(vColor, vPosition, normal); + + /*Offset depth*/ + 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; + } + """)) + + _ATTR_INFO = { + 'position': {'dims': (2, ), 'lastDim': (2, 3, 4)}, + 'radius': {'dims': (1, 2), 'lastDim': (1, )}, + 'color': {'dims': (1, 2), 'lastDim': (3, 4)}, + } + + def __init__(self, positions, radius=1., colors=(1., 1., 1., 1.)): + self.__bounds = None + super(Spheres, self).__init__('points', None, + position=positions, + radius=radius, + color=colors) + + def renderGL2(self, ctx): + fragment = self._shaders[1].substitute( + clippingDecl=ctx.clipper.fragDecl, + clippingCall=ctx.clipper.fragCall, + lightingFunction=ctx.viewport.light.fragmentDef, + lightingCall=ctx.viewport.light.fragmentCall) + prog = ctx.glCtx.prog(self._shaders[0], fragment) + prog.use() + + ctx.viewport.light.setupProgram(ctx, prog) + + gl.glEnable(gl.GL_VERTEX_PROGRAM_POINT_SIZE) # OpenGL 2 + gl.glEnable(gl.GL_POINT_SPRITE) # OpenGL 2 + # gl.glEnable(gl.GL_PROGRAM_POINT_SIZE) + + prog.setUniformMatrix('projMat', ctx.projection.matrix) + prog.setUniformMatrix('transformMat', + ctx.objectToCamera.matrix, + safe=True) + + ctx.clipper.setupProgram(ctx, prog) + + gl.glUniform2f(prog.uniforms['screenSize'], *ctx.viewport.size) + + self._draw(prog) + + def _bounds(self, dataBounds=False): + if self.__bounds is None: + self.__bounds = numpy.zeros((2, 3), dtype=numpy.float32) + # Support vertex with to 2 to 4 coordinates + positions = self._attributes['position'] + radius = self._attributes['radius'] + self.__bounds[0, :positions.shape[1]] = \ + (positions - radius).min(axis=0)[:3] + self.__bounds[1, :positions.shape[1]] = \ + (positions + radius).max(axis=0)[:3] + return self.__bounds.copy() + + +# Meshes ###################################################################### + +class Mesh3D(Geometry): + """A conventional 3D mesh""" + + _shaders = (""" + attribute vec3 position; + attribute vec3 normal; + attribute vec4 color; + + uniform mat4 matrix; + uniform mat4 transformMat; + //uniform mat3 matrixInvTranspose; + + varying vec4 vCameraPosition; + varying vec3 vPosition; + varying vec3 vNormal; + varying vec4 vColor; + + void main(void) + { + vCameraPosition = transformMat * vec4(position, 1.0); + //vNormal = matrixInvTranspose * normalize(normal); + vPosition = position; + vNormal = normal; + vColor = color; + gl_Position = matrix * vec4(position, 1.0); + } + """, + string.Template(""" + varying vec4 vCameraPosition; + varying vec3 vPosition; + varying vec3 vNormal; + varying vec4 vColor; + + $clippingDecl + $lightingFunction + + void main(void) + { + $clippingCall(vCameraPosition); + + gl_FragColor = $lightingCall(vColor, vPosition, vNormal); + } + """)) + + def __init__(self, + positions, + colors, + normals=None, + mode='triangles', + indices=None): + assert mode in self._TRIANGLE_MODES + super(Mesh3D, self).__init__(mode, indices, + position=positions, + normal=normals, + color=colors) + + self._culling = None + + @property + def culling(self): + """Face culling (str) + + One of 'back', 'front' or None. + """ + return self._culling + + @culling.setter + def culling(self, culling): + assert culling in ('back', 'front', None) + if culling != self._culling: + self._culling = culling + self.notify() + + def renderGL2(self, ctx): + isnormals = 'normal' in self._attributes + if isnormals: + fragLightFunction = ctx.viewport.light.fragmentDef + else: + fragLightFunction = ctx.viewport.light.fragmentShaderFunctionNoop + + fragment = self._shaders[1].substitute( + clippingDecl=ctx.clipper.fragDecl, + clippingCall=ctx.clipper.fragCall, + lightingFunction=fragLightFunction, + lightingCall=ctx.viewport.light.fragmentCall) + prog = ctx.glCtx.prog(self._shaders[0], fragment) + prog.use() + + if isnormals: + ctx.viewport.light.setupProgram(ctx, prog) + + if self.culling is not None: + cullFace = gl.GL_FRONT if self.culling == 'front' else gl.GL_BACK + gl.glCullFace(cullFace) + gl.glEnable(gl.GL_CULL_FACE) + + prog.setUniformMatrix('matrix', ctx.objectToNDC.matrix) + prog.setUniformMatrix('transformMat', + ctx.objectToCamera.matrix, + safe=True) + + ctx.clipper.setupProgram(ctx, prog) + + self._draw(prog) + + if self.culling is not None: + gl.glDisable(gl.GL_CULL_FACE) + + +# Group ###################################################################### + +# TODO lighting, clipping as groups? +# group composition? + +class GroupDepthOffset(core.Group): + """A group using 2-pass rendering and glDepthRange to avoid Z-fighting""" + + def __init__(self, children=(), epsilon=None): + super(GroupDepthOffset, self).__init__(children) + self._epsilon = epsilon + self.isDepthRangeOn = True + + def prepareGL2(self, ctx): + if self._epsilon is None: + depthbits = gl.glGetInteger(gl.GL_DEPTH_BITS) + self._epsilon = 1. / (1 << (depthbits - 1)) + + def renderGL2(self, ctx): + if self.isDepthRangeOn: + self._renderGL2WithDepthRange(ctx) + else: + super(GroupDepthOffset, self).renderGL2(ctx) + + def _renderGL2WithDepthRange(self, ctx): + # gl.glDepthFunc(gl.GL_LESS) + with gl.enabled(gl.GL_CULL_FACE): + gl.glCullFace(gl.GL_BACK) + for child in self.children: + gl.glColorMask( + gl.GL_FALSE, gl.GL_FALSE, gl.GL_FALSE, gl.GL_FALSE) + gl.glDepthMask(gl.GL_TRUE) + gl.glDepthRange(self._epsilon, 1.) + + child.render(ctx) + + gl.glColorMask( + gl.GL_TRUE, gl.GL_TRUE, gl.GL_TRUE, gl.GL_TRUE) + gl.glDepthMask(gl.GL_FALSE) + gl.glDepthRange(0., 1. - self._epsilon) + + child.render(ctx) + + gl.glCullFace(gl.GL_FRONT) + for child in reversed(self.children): + gl.glColorMask( + gl.GL_FALSE, gl.GL_FALSE, gl.GL_FALSE, gl.GL_FALSE) + gl.glDepthMask(gl.GL_TRUE) + gl.glDepthRange(self._epsilon, 1.) + + child.render(ctx) + + gl.glColorMask( + gl.GL_TRUE, gl.GL_TRUE, gl.GL_TRUE, gl.GL_TRUE) + gl.glDepthMask(gl.GL_FALSE) + gl.glDepthRange(0., 1. - self._epsilon) + + child.render(ctx) + + gl.glDepthMask(gl.GL_TRUE) + gl.glDepthRange(0., 1.) + # gl.glDepthFunc(gl.GL_LEQUAL) + # TODO use epsilon for all rendering? + # TODO issue with picking in depth buffer! + + +class GroupBBox(core.PrivateGroup): + """A group displaying a bounding box around the children.""" + + def __init__(self, children=(), color=(1., 1., 1., 1.)): + super(GroupBBox, self).__init__() + self._group = core.Group(children) + + self._boxTransforms = transform.TransformList( + (transform.Translate(), transform.Scale())) + + self._boxWithAxes = BoxWithAxes(color) + self._boxWithAxes.smooth = False + self._boxWithAxes.transforms = self._boxTransforms + + self._children = [self._boxWithAxes, self._group] + + def _updateBoxAndAxes(self): + """Update bbox and axes position and size according to children.""" + bounds = self._group.bounds(dataBounds=True) + if bounds is not None: + origin = bounds[0] + scale = [(d if d != 0. else 1.) for d in bounds[1] - bounds[0]] + else: + origin, scale = (0., 0., 0.), (1., 1., 1.) + + self._boxTransforms[0].translation = origin + self._boxTransforms[1].scale = scale + + def _bounds(self, dataBounds=False): + self._updateBoxAndAxes() + return super(GroupBBox, self)._bounds(dataBounds) + + def prepareGL2(self, ctx): + self._updateBoxAndAxes() + super(GroupBBox, self).prepareGL2(ctx) + + # Give access to _group children + + @property + def children(self): + return self._group.children + + @children.setter + def children(self, iterable): + self._group.children = iterable + + # Give access to box color + + @property + def color(self): + """The RGBA color to use for the box: 4 float in [0, 1]""" + return self._boxWithAxes.color + + @color.setter + def color(self, color): + self._boxWithAxes.color = color + + +# Clipping Plane ############################################################## + +class ClipPlane(PlaneInGroup): + """A clipping plane attached to a box""" + + def renderGL2(self, ctx): + super(ClipPlane, self).renderGL2(ctx) + + if self.visible: + # Set-up clipping plane for following brothers + + # No need of perspective divide, no projection + point = ctx.objectToCamera.transformPoint(self.plane.point, + perspectiveDivide=False) + normal = ctx.objectToCamera.transformNormal(self.plane.normal) + ctx.setClipPlane(point, normal) + + def postRender(self, ctx): + if self.visible: + # Disable clip planes + ctx.setClipPlane() diff --git a/silx/gui/plot3d/scene/setup.py b/silx/gui/plot3d/scene/setup.py new file mode 100644 index 0000000..ff4c0a6 --- /dev/null +++ b/silx/gui/plot3d/scene/setup.py @@ -0,0 +1,41 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2015-2017 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. +# +# ###########################################################################*/ +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "25/07/2016" + +from numpy.distutils.misc_util import Configuration + + +def configuration(parent_package='', top_path=None): + config = Configuration('scene', parent_package, top_path) + config.add_subpackage('test') + return config + + +if __name__ == "__main__": + from numpy.distutils.core import setup + + setup(configuration=configuration) diff --git a/silx/gui/plot3d/scene/test/__init__.py b/silx/gui/plot3d/scene/test/__init__.py new file mode 100644 index 0000000..fc4621e --- /dev/null +++ b/silx/gui/plot3d/scene/test/__init__.py @@ -0,0 +1,43 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2015-2017 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. +# +# ###########################################################################*/ + +from __future__ import absolute_import, division, unicode_literals + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "25/07/2016" + + +import unittest + +from .test_transform import suite as test_transform_suite +from .test_utils import suite as test_utils_suite + + +def suite(): + testsuite = unittest.TestSuite() + testsuite.addTest(test_transform_suite()) + testsuite.addTest(test_utils_suite()) + return testsuite diff --git a/silx/gui/plot3d/scene/test/test_transform.py b/silx/gui/plot3d/scene/test/test_transform.py new file mode 100644 index 0000000..9ea0af1 --- /dev/null +++ b/silx/gui/plot3d/scene/test/test_transform.py @@ -0,0 +1,91 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2015-2017 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. +# +# ###########################################################################*/ + +from __future__ import absolute_import, division, unicode_literals + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "05/01/2017" + + +import numpy +import unittest + +from silx.gui.plot3d.scene import transform + + +class TestTransformList(unittest.TestCase): + + def assertSameArrays(self, a, b): + return self.assertTrue(numpy.allclose(a, b, atol=1e-06)) + + def testTransformList(self): + """Minimalistic test of TransformList""" + transforms = transform.TransformList() + refmatrix = numpy.identity(4, dtype=numpy.float32) + self.assertSameArrays(refmatrix, transforms.matrix) + + # Append translate + transforms.append(transform.Translate(1., 1., 1.)) + refmatrix = numpy.array(((1., 0., 0., 1.), + (0., 1., 0., 1.), + (0., 0., 1., 1.), + (0., 0., 0., 1.)), dtype=numpy.float32) + self.assertSameArrays(refmatrix, transforms.matrix) + + # Extend scale + transforms.extend([transform.Scale(0.1, 2., 1.)]) + refmatrix = numpy.dot(refmatrix, + numpy.array(((0.1, 0., 0., 0.), + (0., 2., 0., 0.), + (0., 0., 1., 0.), + (0., 0., 0., 1.)), + dtype=numpy.float32)) + self.assertSameArrays(refmatrix, transforms.matrix) + + # Insert rotate + transforms.insert(0, transform.Rotate(360.)) + self.assertSameArrays(refmatrix, transforms.matrix) + + # Update translate and check for listener called + self._callCount = 0 + + def listener(source): + self._callCount += 1 + transforms.addListener(listener) + + transforms[1].tx += 1 + self.assertEqual(self._callCount, 1) + + +def suite(): + testsuite = unittest.TestSuite() + testsuite.addTest( + unittest.defaultTestLoader.loadTestsFromTestCase(TestTransformList)) + return testsuite + + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff --git a/silx/gui/plot3d/scene/test/test_utils.py b/silx/gui/plot3d/scene/test/test_utils.py new file mode 100644 index 0000000..65c2407 --- /dev/null +++ b/silx/gui/plot3d/scene/test/test_utils.py @@ -0,0 +1,275 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2015-2017 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. +# +# ###########################################################################*/ + +from __future__ import absolute_import, division, unicode_literals + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "25/07/2016" + + +import unittest +from silx.test.utils import ParametricTestCase + +import numpy + +from silx.gui.plot3d.scene import utils + + +# angleBetweenVectors ######################################################### + +class TestAngleBetweenVectors(ParametricTestCase): + + TESTS = { # name: (refvector, vectors, norm, refangles) + 'single vector': + ((1., 0., 0.), (1., 0., 0.), (0., 0., 1.), 0.), + 'single vector, no norm': + ((1., 0., 0.), (1., 0., 0.), None, 0.), + + 'with orthogonal norm': + ((1., 0., 0.), + ((1., 0., 0.), (0., 1., 0.), (-1., 0., 0.), (0., -1., 0.)), + (0., 0., 1.), + (0., 90., 180., 270.)), + + 'with coplanar norm': # = similar to no norm + ((1., 0., 0.), + ((1., 0., 0.), (0., 1., 0.), (-1., 0., 0.), (0., -1., 0.)), + (1., 0., 0.), + (0., 90., 180., 90.)), + + 'without norm': + ((1., 0., 0.), + ((1., 0., 0.), (0., 1., 0.), (-1., 0., 0.), (0., -1., 0.)), + None, + (0., 90., 180., 90.)), + + 'not unit vectors': + ((2., 2., 0.), ((1., 1., 0.), (1., -1., 0.)), None, (0., 90.)), + } + + def testAngleBetweenVectorsFunction(self): + for name, params in self.TESTS.items(): + refvector, vectors, norm, refangles = params + with self.subTest(name): + refangles = numpy.radians(refangles) + + refvector = numpy.array(refvector) + vectors = numpy.array(vectors) + if norm is not None: + norm = numpy.array(norm) + + testangles = utils.angleBetweenVectors( + refvector, vectors, norm) + + self.assertTrue( + numpy.allclose(testangles, refangles, atol=1e-5)) + + +# Plane ####################################################################### + +class AssertNotificationContext(object): + """Context that checks if an event.Notifier is sending events.""" + + def __init__(self, notifier, count=1): + """Initializer. + + :param event.Notifier notifier: The notifier to test. + :param int count: The expected number of calls. + """ + self._notifier = notifier + self._callCount = None + self._count = count + + def __enter__(self): + self._callCount = 0 + self._notifier.addListener(self._callback) + + def __exit__(self, exc_type, exc_value, traceback): + # Do not return True so exceptions are propagated + self._notifier.removeListener(self._callback) + assert self._callCount == self._count + self._callCount = None + + def _callback(self, *args, **kwargs): + self._callCount += 1 + + +class TestPlaneParameters(ParametricTestCase): + """Test Plane.parameters read/write and notifications.""" + + PARAMETERS = { + 'unit normal': (1., 0., 0., 1.), + 'not unit normal': (1., 1., 0., 1.), + 'd = 0': (1., 0., 0., 0.) + } + + def testParameters(self): + """Check parameters read/write and notification.""" + plane = utils.Plane() + + for name, parameters in self.PARAMETERS.items(): + with self.subTest(name, parameters=parameters): + with AssertNotificationContext(plane): + plane.parameters = parameters + + # Plane parameters are converted to have a unit normal + normparams = parameters / numpy.linalg.norm(parameters[:3]) + self.assertTrue(numpy.allclose(plane.parameters, normparams)) + + ZEROS_PARAMETERS = ( + (0., 0., 0., 0.), + (0., 0., 0., 1.) + ) + + ZEROS = 0., 0., 0., 0. + + def testParametersNoPlane(self): + """Test Plane.parameters with ||normal|| == 0 .""" + plane = utils.Plane() + plane.parameters = self.ZEROS + + for parameters in self.ZEROS_PARAMETERS: + with self.subTest(parameters=parameters): + with AssertNotificationContext(plane, count=0): + plane.parameters = parameters + self.assertTrue( + numpy.allclose(plane.parameters, self.ZEROS, 0., 0.)) + + +# unindexArrays ############################################################### + +class TestUnindexArrays(ParametricTestCase): + """Test unindexArrays function.""" + + def testBasicModes(self): + """Test for modes: points, lines and triangles""" + indices = numpy.array((1, 2, 0)) + arrays = (numpy.array((0., 1., 2.)), + numpy.array(((0, 0), (1, 1), (2, 2)))) + refresults = (numpy.array((1., 2., 0.)), + numpy.array(((1, 1), (2, 2), (0, 0)))) + + for mode in ('points', 'lines', 'triangles'): + with self.subTest(mode=mode): + testresults = utils.unindexArrays(mode, indices, *arrays) + for ref, test in zip(refresults, testresults): + self.assertTrue(numpy.equal(ref, test).all()) + + def testPackedLines(self): + """Test for modes: line_strip, loop""" + indices = numpy.array((1, 2, 0)) + arrays = (numpy.array((0., 1., 2.)), + numpy.array(((0, 0), (1, 1), (2, 2)))) + results = { + 'line_strip': ( + numpy.array((1., 2., 2., 0.)), + numpy.array(((1, 1), (2, 2), (2, 2), (0, 0)))), + 'loop': ( + numpy.array((1., 2., 2., 0., 0., 1.)), + numpy.array(((1, 1), (2, 2), (2, 2), (0, 0), (0, 0), (1, 1)))), + } + + for mode, refresults in results.items(): + with self.subTest(mode=mode): + testresults = utils.unindexArrays(mode, indices, *arrays) + for ref, test in zip(refresults, testresults): + self.assertTrue(numpy.equal(ref, test).all()) + + def testPackedTriangles(self): + """Test for modes: triangle_strip, fan""" + indices = numpy.array((1, 2, 0, 3)) + arrays = (numpy.array((0., 1., 2., 3.)), + numpy.array(((0, 0), (1, 1), (2, 2), (3, 3)))) + results = { + 'triangle_strip': ( + numpy.array((1., 2., 0., 2., 0., 3.)), + numpy.array(((1, 1), (2, 2), (0, 0), (2, 2), (0, 0), (3, 3)))), + 'fan': ( + numpy.array((1., 2., 0., 1., 0., 3.)), + numpy.array(((1, 1), (2, 2), (0, 0), (1, 1), (0, 0), (3, 3)))), + } + + for mode, refresults in results.items(): + with self.subTest(mode=mode): + testresults = utils.unindexArrays(mode, indices, *arrays) + for ref, test in zip(refresults, testresults): + self.assertTrue(numpy.equal(ref, test).all()) + + def testBadIndices(self): + """Test with negative indices and indices higher than array length""" + arrays = numpy.array((0, 1)), numpy.array((0, 1, 2)) + + # negative indices + with self.assertRaises(AssertionError): + utils.unindexArrays('points', (-1, 0), *arrays) + + # Too high indices + with self.assertRaises(AssertionError): + utils.unindexArrays('points', (0, 10), *arrays) + + +# triangleNormals ############################################################# + +class TestTriangleNormals(ParametricTestCase): + """Test triangleNormals function.""" + + def test(self): + """Test for modes: points, lines and triangles""" + positions = numpy.array( + ((0., 0., 0.), (1., 0., 0.), (0., 1., 0.), # normal = Z + (1., 1., 1.), (1., 2., 3.), (4., 5., 6.), # Random triangle + # Degenerated triangles: + (0., 0., 0.), (1., 0., 0.), (2., 0., 0.), # Colinear points + (1., 1., 1.), (1., 1., 1.), (1., 1., 1.), # All same point + ), + dtype='float32') + + normals = numpy.array( + ((0., 0., 1.), + (-0.40824829, 0.81649658, -0.40824829), + (0., 0., 0.), + (0., 0., 0.)), + dtype='float32') + + testnormals = utils.trianglesNormal(positions) + self.assertTrue(numpy.allclose(testnormals, normals)) + + +# suite ####################################################################### + +def suite(): + testsuite = unittest.TestSuite() + for test in (TestAngleBetweenVectors, + TestPlaneParameters, + TestUnindexArrays, + TestTriangleNormals): + testsuite.addTest( + unittest.defaultTestLoader.loadTestsFromTestCase(test)) + return testsuite + + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff --git a/silx/gui/plot3d/scene/text.py b/silx/gui/plot3d/scene/text.py new file mode 100644 index 0000000..903fc21 --- /dev/null +++ b/silx/gui/plot3d/scene/text.py @@ -0,0 +1,534 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2016-2017 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. +# +# ###########################################################################*/ +"""Primitive displaying a text field in the scene.""" + +from __future__ import absolute_import, division, unicode_literals + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "17/10/2016" + + +import logging +import numpy + +from silx.gui.plot.Colors import rgba + +from ... import _glutils +from ..._glutils import gl + +from ..._glutils import font as _font +from ...plot._utils import ticklayout + +from . import event, primitives, core, transform + + +_logger = logging.getLogger(__name__) + + +class Font(event.Notifier): + """Description of a font. + + :param str name: Family of the font + :param int size: Size of the font in points + :param int weight: Font weight + :param bool italic: True for italic font, False (default) otherwise + """ + + def __init__(self, name=None, size=-1, weight=-1, italic=False): + self._name = name if name is not None else _font.getDefaultFontFamily() + self._size = size + self._weight = weight + self._italic = italic + super(Font, self).__init__() + + name = event.notifyProperty( + '_name', + doc="""Name of the font (str)""", + converter=str) + + size = event.notifyProperty( + '_size', + doc="""Font size in points (int)""", + converter=int) + + weight = event.notifyProperty( + '_weight', + doc="""Font size in points (int)""", + converter=int) + + italic = event.notifyProperty( + '_italic', + doc="""True for italic (bool)""", + converter=bool) + + +class Text2D(primitives.Geometry): + """Text field as a 2D texture displayed with bill-boarding + + :param str text: Text to display + :param Font font: The font to use + """ + + # Text anchor values + CENTER = 'center' + + LEFT = 'left' + RIGHT = 'right' + + TOP = 'top' + BASELINE = 'baseline' + BOTTOM = 'bottom' + + _ALIGN = LEFT, CENTER, RIGHT + _VALIGN = TOP, BASELINE, CENTER, BOTTOM + + _rasterTextCache = {} + """Internal cache storing already rasterized text""" + # TODO limit cache size and discard least recent used + + def __init__(self, text='', font=None): + self._dirtyTexture = True + self._dirtyAlign = True + self._baselineOffset = 0 + self._text = text + self._font = font if font is not None else Font() + self._foreground = 1., 1., 1., 1. + self._background = 0., 0., 0., 0. + self._overlay = False + self._align = 'left' + self._valign = 'baseline' + self._devicePixelRatio = 1.0 # Store it to check for changes + + self._texture = None + self._textureDirty = True + + super(Text2D, self).__init__( + 'triangle_strip', + copy=False, + # Keep an array for position as it is bound to attr 0 and MUST + # be active and an array at least on Mac OS X + position=numpy.zeros((4, 3), dtype=numpy.float32), + vertexID=numpy.arange(4., dtype=numpy.float32).reshape(4, 1), + offsetInViewportCoords=(0., 0.)) + + @property + def text(self): + """Text displayed by this primitive (str)""" + return self._text + + @text.setter + def text(self, text): + text = str(text) + if text != self._text: + self._dirtyTexture = True + self._text = text + self.notify() + + @property + def font(self): + """Font to use to raster text (Font)""" + return self._font + + @font.setter + def font(self, font): + self._font.removeListener(self._fontChanged) + self._font = font + self._font.addListener(self._fontChanged) + self._fontChanged(self) # Which calls notify and primitive as dirty + + def _fontChanged(self, source): + """Listen for font change""" + self._dirtyTexture = True + self.notify() + + foreground = event.notifyProperty( + '_foreground', doc="""RGBA color of the text: 4 float in [0, 1]""", + converter=rgba) + + background = event.notifyProperty( + '_background', + doc="RGBA background color of the text field: 4 float in [0, 1]", + converter=rgba) + + overlay = event.notifyProperty( + '_overlay', + doc="True to always display text on top of the scene (default: False)", + converter=bool) + + def _setAlign(self, align): + assert align in self._ALIGN + self._align = align + self._dirtyAlign = True + self.notify() + + align = property( + lambda self: self._align, + _setAlign, + doc="""Horizontal anchor position of the text field (str). + + Either 'left' (default), 'center' or 'right'.""") + + def _setVAlign(self, valign): + assert valign in self._VALIGN + self._valign = valign + self._dirtyAlign = True + self.notify() + + valign = property( + lambda self: self._valign, + _setVAlign, + doc="""Vertical anchor position of the text field (str). + + Either 'top', 'baseline' (default), 'center' or 'bottom'""") + + def _raster(self, devicePixelRatio): + """Raster current primitive to a bitmap + + :param float devicePixelRatio: + The ratio between device and device-independent pixels + :return: Corresponding image in grayscale and baseline offset from top + :rtype: (HxW numpy.ndarray of uint8, int) + """ + params = (self.text, + self.font.name, + self.font.size, + self.font.weight, + self.font.italic, + devicePixelRatio) + + if params not in self._rasterTextCache: # Add to cache + self._rasterTextCache[params] = _font.rasterText(*params) + + array, offset = self._rasterTextCache[params] + return array.copy(), offset + + def _bounds(self, dataBounds=False): + return None + + def prepareGL2(self, context): + # Check if devicePixelRatio has changed since last rendering + devicePixelRatio = context.glCtx.devicePixelRatio + if self._devicePixelRatio != devicePixelRatio: + self._devicePixelRatio = devicePixelRatio + self._dirtyTexture = True + + if self._dirtyTexture: + self._dirtyTexture = False + + if self._texture is not None: + self._texture.discard() + self._texture = None + self._baselineOffset = 0 + + if self.text: + image, self._baselineOffset = self._raster( + self._devicePixelRatio) + self._texture = _glutils.Texture( + gl.GL_R8, image, gl.GL_RED, + minFilter=gl.GL_NEAREST, + magFilter=gl.GL_NEAREST, + wrap=gl.GL_CLAMP_TO_EDGE) + self._dirtyAlign = True # To force update of offset + + if self._dirtyAlign: + self._dirtyAlign = False + + if self._texture is not None: + height, width = self._texture.shape + + if self._align == 'left': + ox = 0. + elif self._align == 'center': + ox = - width // 2 + elif self._align == 'right': + ox = - width + else: + _logger.error("Unsupported align: %s", self._align) + ox = 0. + + if self._valign == 'top': + oy = 0. + elif self._valign == 'baseline': + oy = self._baselineOffset + elif self._valign == 'center': + oy = height // 2 + elif self._valign == 'bottom': + oy = height + else: + _logger.error("Unsupported valign: %s", self._valign) + oy = 0. + + offsets = (ox, oy) + numpy.array( + ((0., 0.), (width, 0.), (0., -height), (width, -height)), + dtype=numpy.float32) + self.setAttribute('offsetInViewportCoords', offsets) + + super(Text2D, self).prepareGL2(context) + + def renderGL2(self, context): + if not self.text: + return # Nothing to render + + program = context.glCtx.prog(*self._shaders) + program.use() + + program.setUniformMatrix('matrix', context.objectToNDC.matrix) + gl.glUniform2f( + program.uniforms['viewportSize'], *context.viewport.size) + gl.glUniform4f(program.uniforms['foreground'], *self.foreground) + gl.glUniform4f(program.uniforms['background'], *self.background) + gl.glUniform1i(program.uniforms['texture'], self._texture.texUnit) + gl.glUniform1i(program.uniforms['isOverlay'], + 1 if self._overlay else 0) + + self._texture.bind() + + if not self._overlay or not gl.glGetBoolean(gl.GL_DEPTH_TEST): + self._draw(program) + else: # overlay and depth test currently enabled + gl.glDisable(gl.GL_DEPTH_TEST) + self._draw(program) + gl.glEnable(gl.GL_DEPTH_TEST) + + # TODO texture atlas + viewportSize as attribute to chain text rendering + + _shaders = ( + """ + attribute vec3 position; + attribute vec2 offsetInViewportCoords; /* Offset in pixels (y upward) */ + attribute float vertexID; /* Index of rectangle corner */ + + uniform mat4 matrix; + uniform vec2 viewportSize; /* Width, height of the viewport */ + uniform int isOverlay; + + varying vec2 texCoords; + + void main(void) + { + vec4 clipPos = matrix * vec4(position, 1.0); + vec4 ndcPos = clipPos / clipPos.w; /* Perspective divide */ + + /* Align ndcPos with pixels in viewport-like coords (origin useless) */ + vec2 viewportPos = floor((ndcPos.xy + vec2(1.0, 1.0)) * 0.5 * viewportSize); + + /* Apply offset in viewport coords */ + viewportPos += offsetInViewportCoords; + + /* Convert back to NDC */ + vec2 pointPos = 2.0 * viewportPos / viewportSize - vec2(1.0, 1.0); + float z = (isOverlay != 0) ? -1.0 : ndcPos.z; + gl_Position = vec4(pointPos, z, 1.0); + + /* Index : texCoords: + * 0: (0., 0.) + * 1: (1., 0.) + * 2: (0., 1.) + * 3: (1., 1.) + */ + texCoords = vec2(vertexID == 0.0 || vertexID == 2.0 ? 0.0 : 1.0, + vertexID < 1.5 ? 0.0 : 1.0); + } + """, # noqa + + """ + varying vec2 texCoords; + + uniform vec4 foreground; + uniform vec4 background; + uniform sampler2D texture; + + void main(void) + { + float value = texture2D(texture, texCoords).r; + + if (background.a != 0.0) { + gl_FragColor = mix(background, foreground, value); + } else { + gl_FragColor = foreground; + gl_FragColor.a *= value; + if (gl_FragColor.a <= 0.01) { + discard; + } + } + } + """) + + +class LabelledAxes(primitives.GroupBBox): + """A group displaying a bounding box with axes labels around its children. + """ + + def __init__(self): + super(LabelledAxes, self).__init__() + self._ticksForBounds = None + + self._font = Font() + + # TODO offset labels from anchor in pixels + + self._xlabel = Text2D(font=self._font) + self._xlabel.align = 'center' + self._xlabel.transforms = [self._boxTransforms, + transform.Translate(tx=0.5)] + self._children.append(self._xlabel) + + self._ylabel = Text2D(font=self._font) + self._ylabel.align = 'center' + self._ylabel.transforms = [self._boxTransforms, + transform.Translate(ty=0.5)] + self._children.append(self._ylabel) + + self._zlabel = Text2D(font=self._font) + self._zlabel.align = 'center' + self._zlabel.transforms = [self._boxTransforms, + transform.Translate(tz=0.5)] + self._children.append(self._zlabel) + + self._tickLines = primitives.Lines( # Init tick lines with dummy pos + positions=((0., 0., 0.), (0., 0., 0.)), + mode='lines') + self._tickLines.visible = False + self._children.append(self._tickLines) + + self._tickLabels = core.Group() + self._children.append(self._tickLabels) + + @property + def font(self): + """Font of axes text labels (Font)""" + return self._font + + @font.setter + def font(self, font): + self._font = font + self._xlabel.font = font + self._ylabel.font = font + self._zlabel.font = font + for label in self._tickLabels.children: + label.font = font + + @property + def xlabel(self): + """Text label of the X axis (str)""" + return self._xlabel.text + + @xlabel.setter + def xlabel(self, text): + self._xlabel.text = text + + @property + def ylabel(self): + """Text label of the Y axis (str)""" + return self._ylabel.text + + @ylabel.setter + def ylabel(self, text): + self._ylabel.text = text + + @property + def zlabel(self): + """Text label of the Z axis (str)""" + return self._zlabel.text + + @zlabel.setter + def zlabel(self, text): + self._zlabel.text = text + + def _updateTicks(self): + """Check if ticks need update and update them if needed.""" + bounds = self._group.bounds(transformed=False, dataBounds=True) + if bounds is None: # No content + if self._ticksForBounds is not None: + self._ticksForBounds = None + self._tickLines.visible = False + self._tickLabels.children = [] # Reset previous labels + + elif (self._ticksForBounds is None or + not numpy.all(numpy.equal(bounds, self._ticksForBounds))): + self._ticksForBounds = bounds + + # Update ticks + # TODO make ticks having a constant length on the screen + ticklength = numpy.abs(bounds[1] - bounds[0]) / 20. + + xticks, xlabels = ticklayout.ticks(*bounds[:, 0]) + yticks, ylabels = ticklayout.ticks(*bounds[:, 1]) + zticks, zlabels = ticklayout.ticks(*bounds[:, 2]) + + # Update tick lines + coords = numpy.empty( + ((len(xticks) + len(yticks) + len(zticks)), 4, 3), + dtype=numpy.float32) + coords[:, :, :] = bounds[0, :] # account for offset from origin + + xcoords = coords[:len(xticks)] + xcoords[:, :, 0] = numpy.asarray(xticks)[:, numpy.newaxis] + xcoords[:, 1, 1] += ticklength[1] # X ticks on XY plane + xcoords[:, 3, 2] += ticklength[2] # X ticks on XZ plane + + ycoords = coords[len(xticks):len(xticks) + len(yticks)] + ycoords[:, :, 1] = numpy.asarray(yticks)[:, numpy.newaxis] + ycoords[:, 1, 0] += ticklength[0] # Y ticks on XY plane + ycoords[:, 3, 2] += ticklength[2] # Y ticks on YZ plane + + zcoords = coords[len(xticks) + len(yticks):] + zcoords[:, :, 2] = numpy.asarray(zticks)[:, numpy.newaxis] + zcoords[:, 1, 0] += ticklength[0] # Z ticks on XZ plane + zcoords[:, 3, 1] += ticklength[1] # Z ticks on YZ plane + + self._tickLines.setAttribute('position', coords.reshape(-1, 3)) + self._tickLines.visible = True + + # Update labels + offsets = bounds[0] - ticklength + labels = [] + for tick, label in zip(xticks, xlabels): + text = Text2D(text=label, font=self.font) + text.align = 'center' + text.transforms = [transform.Translate( + tx=tick, ty=offsets[1], tz=offsets[2])] + labels.append(text) + + for tick, label in zip(yticks, ylabels): + text = Text2D(text=label, font=self.font) + text.align = 'center' + text.transforms = [transform.Translate( + tx=offsets[0], ty=tick, tz=offsets[2])] + labels.append(text) + + for tick, label in zip(zticks, zlabels): + text = Text2D(text=label, font=self.font) + text.align = 'center' + text.transforms = [transform.Translate( + tx=offsets[0], ty=offsets[1], tz=tick)] + labels.append(text) + + self._tickLabels.children = labels # Reset previous labels + + def prepareGL2(self, context): + self._updateTicks() + super(LabelledAxes, self).prepareGL2(context) diff --git a/silx/gui/plot3d/scene/transform.py b/silx/gui/plot3d/scene/transform.py new file mode 100644 index 0000000..71a6b74 --- /dev/null +++ b/silx/gui/plot3d/scene/transform.py @@ -0,0 +1,968 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2015-2017 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""This module provides 4x4 matrix operation and classes to handle them.""" + +from __future__ import absolute_import, division, unicode_literals + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "25/07/2016" + + +import itertools +import numpy + +from . import event + + +# Functions ################################################################### + +# Projections + +def mat4LookAtDir(position, direction, up): + """Creates matrix to look in direction from position. + + :param position: Array-like 3 coordinates of the point of view position. + :param direction: Array-like 3 coordinates of the sight direction vector. + :param up: Array-like 3 coordinates of the upward direction + in the image plane. + :returns: Corresponding matrix. + :rtype: numpy.ndarray of shape (4, 4) + """ + assert len(position) == 3 + assert len(direction) == 3 + assert len(up) == 3 + + direction = numpy.array(direction, copy=True, dtype=numpy.float32) + dirnorm = numpy.linalg.norm(direction) + assert dirnorm != 0. + direction /= dirnorm + + side = numpy.cross(direction, + numpy.array(up, copy=False, dtype=numpy.float32)) + sidenorm = numpy.linalg.norm(side) + assert sidenorm != 0. + up = numpy.cross(side / sidenorm, direction) + upnorm = numpy.linalg.norm(up) + assert upnorm != 0. + up /= upnorm + + matrix = numpy.identity(4, dtype=numpy.float32) + matrix[0, :3] = side + matrix[1, :3] = up + matrix[2, :3] = -direction + return numpy.dot(matrix, + mat4Translate(-position[0], -position[1], -position[2])) + + +def mat4LookAt(position, center, up): + """Creates matrix to look at center from position. + + See gluLookAt. + + :param position: Array-like 3 coordinates of the point of view position. + :param center: Array-like 3 coordinates of the center of the scene. + :param up: Array-like 3 coordinates of the upward direction + in the image plane. + :returns: Corresponding matrix. + :rtype: numpy.ndarray of shape (4, 4) + """ + position = numpy.array(position, copy=False, dtype=numpy.float32) + center = numpy.array(center, copy=False, dtype=numpy.float32) + direction = center - position + return mat4LookAtDir(position, direction, up) + + +def mat4Frustum(left, right, bottom, top, near, far): + """Creates a frustum projection matrix. + + See glFrustum. + """ + return numpy.array(( + (2.*near / (right-left), 0., (right+left) / (right-left), 0.), + (0., 2.*near / (top-bottom), (top+bottom) / (top-bottom), 0.), + (0., 0., -(far+near) / (far-near), -2.*far*near / (far-near)), + (0., 0., -1., 0.)), dtype=numpy.float32) + + +def mat4Perspective(fovy, width, height, near, far): + """Creates a perspective projection matrix. + + Similar to gluPerspective. + + :param float fovy: Field of view angle in degrees in the y direction. + :param float width: Width of the viewport. + :param float height: Height of the viewport. + :param float near: Distance to the near plane (strictly positive). + :param float far: Distance to the far plane (strictly positive). + :return: Corresponding matrix. + :rtype: numpy.ndarray of shape (4, 4) + """ + assert fovy != 0 + assert height != 0 + assert width != 0 + assert near > 0. + assert far > near + aspectratio = width / height + f = 1. / numpy.tan(numpy.radians(fovy) / 2.) + return numpy.array(( + (f / aspectratio, 0., 0., 0.), + (0., f, 0., 0.), + (0., 0., (far + near) / (near - far), 2. * far * near / (near - far)), + (0., 0., -1., 0.)), dtype=numpy.float32) + + +def mat4Orthographic(left, right, bottom, top, near, far): + """Creates an orthographic (i.e., parallel) projection matrix. + + See glOrtho. + """ + return numpy.array(( + (2. / (right - left), 0., 0., - (right + left) / (right - left)), + (0., 2. / (top - bottom), 0., - (top + bottom) / (top - bottom)), + (0., 0., -2. / (far - near), - (far + near) / (far - near)), + (0., 0., 0., 1.)), dtype=numpy.float32) + + +# Affine + +def mat4Translate(tx, ty, tz): + """4x4 translation matrix.""" + return numpy.array(( + (1., 0., 0., tx), + (0., 1., 0., ty), + (0., 0., 1., tz), + (0., 0., 0., 1.)), dtype=numpy.float32) + + +def mat4Scale(sx, sy, sz): + """4x4 scale matrix.""" + return numpy.array(( + (sx, 0., 0., 0.), + (0., sy, 0., 0.), + (0., 0., sz, 0.), + (0., 0., 0., 1.)), dtype=numpy.float32) + + +def mat4RotateFromAngleAxis(angle, x=0., y=0., z=1.): + """4x4 rotation matrix from angle and axis. + + :param float angle: The rotation angle in radians. + :param float x: The rotation vector x coordinate. + :param float y: The rotation vector y coordinate. + :param float z: The rotation vector z coordinate. + """ + ca = numpy.cos(angle) + sa = numpy.sin(angle) + return numpy.array(( + ((1.-ca) * x*x + ca, (1.-ca) * x*y - sa*z, (1.-ca) * x*z + sa*y, 0.), + ((1.-ca) * x*y + sa*z, (1.-ca) * y*y + ca, (1.-ca) * y*z - sa*x, 0.), + ((1.-ca) * x*z - sa*y, (1.-ca) * y*z + sa*x, (1.-ca) * z*z + ca, 0.), + (0., 0., 0., 1.)), dtype=numpy.float32) + + +def mat4RotateFromQuaternion(quaternion): + """4x4 rotation matrix from quaternion. + + :param quaternion: Array-like unit quaternion stored as (x, y, z, w) + """ + quaternion = numpy.array(quaternion, copy=True) + quaternion /= numpy.linalg.norm(quaternion) + + qx, qy, qz, qw = quaternion + return numpy.array(( + (1. - 2.*(qy**2 + qz**2), 2.*(qx*qy - qw*qz), 2.*(qx*qz + qw*qy), 0.), + (2.*(qx*qy + qw*qz), 1. - 2.*(qx**2 + qz**2), 2.*(qy*qz - qw*qx), 0.), + (2.*(qx*qz - qw*qy), 2.*(qy*qz + qw*qx), 1. - 2.*(qx**2 + qy**2), 0.), + (0., 0., 0., 1.)), dtype=numpy.float32) + + +def mat4Shear(axis, sx=0., sy=0., sz=0.): + """4x4 shear matrix: Skew two axes relative to a third fixed one. + + shearFactor = tan(shearAngle) + + :param str axis: The axis to keep constant and shear against. + In 'x', 'y', 'z'. + :param float sx: The shear factor for the X axis relative to axis. + :param float sy: The shear factor for the Y axis relative to axis. + :param float sz: The shear factor for the Z axis relative to axis. + """ + assert axis in ('x', 'y', 'z') + + matrix = numpy.identity(4, dtype=numpy.float32) + + # Make the shear column + index = 'xyz'.find(axis) + shearcolumn = numpy.array((sx, sy, sz, 0.), dtype=numpy.float32) + shearcolumn[index] = 1. + matrix[:, index] = shearcolumn + return matrix + + +# Transforms ################################################################## + +class Transform(event.Notifier): + + def __init__(self, static=False): + """Base class for (row-major) 4x4 matrix transforms. + + :param bool static: False (default) to reset cache when changed, + True for static matrices. + """ + super(Transform, self).__init__() + self._matrix = None + self._inverse = None + if not static: + self.addListener(self._changed) # Listening self for changes + + def __repr__(self): + return '%s(%s)' % (self.__class__.__init__, + repr(self.getMatrix(copy=False))) + + def inverse(self): + """Return the Transform of the inverse. + + The returned Transform is static, it is not updated when this + Transform is modified. + + :return: A Transform which is the inverse of this Transform. + """ + return Inverse(self) + + # Matrix + + def _makeMatrix(self): + """Override to build matrix""" + return numpy.identity(4, dtype=numpy.float32) + + def _makeInverse(self): + """Override to build inverse matrix.""" + return numpy.linalg.inv(self.getMatrix(copy=False)) + + def getMatrix(self, copy=True): + """The 4x4 matrix of this transform. + + :param bool copy: True (the default) to get a copy of the matrix, + False to get the internal matrix, do not modify! + :return: 4x4 matrix of this transform. + """ + if self._matrix is None: + self._matrix = self._makeMatrix() + if copy: + return self._matrix.copy() + else: + return self._matrix + + matrix = property(getMatrix, doc="The 4x4 matrix of this transform.") + + def getInverseMatrix(self, copy=False): + """The 4x4 matrix of the inverse of this transform. + + :param bool copy: True (the default) to get a copy of the matrix, + False to get the internal matrix, do not modify! + :return: 4x4 matrix of the inverse of this transform. + """ + if self._inverse is None: + self._inverse = self._makeInverse() + if copy: + return self._inverse.copy() + else: + return self._inverse + + inverseMatrix = property( + getInverseMatrix, + doc="The 4x4 matrix of the inverse of this transform.") + + # Listener + + def _changed(self, source): + """Default self listener reseting matrix cache.""" + self._matrix = None + self._inverse = None + + # Multiplication with vectors + + @staticmethod + def _prepareVector(vector, w): + """Add 4th coordinate (w) to vector if missing.""" + assert len(vector) in (3, 4) + vector = numpy.array(vector, copy=False, dtype=numpy.float32) + if len(vector) == 3: + vector = numpy.append(vector, w) + return vector + + def transformPoint(self, point, direct=True, perspectiveDivide=False): + """Apply the transform to a point. + + If len(point) == 3, apply persective divide if possible. + + :param point: Array-like vector of 3 or 4 coordinates. + :param bool direct: Whether to apply the direct (True, the default) + or inverse (False) transform. + :param bool perspectiveDivide: Whether to apply the perspective divide + (True) or not (False, the default). + :return: The transformed point. + :rtype: numpy.ndarray of same length as point. + """ + if direct: + matrix = self.getMatrix(copy=False) + else: + matrix = self.getInverseMatrix(copy=False) + result = numpy.dot(matrix, self._prepareVector(point, 1.)) + + if perspectiveDivide and result[3] != 0.: + result /= result[3] + + if len(point) == 3: + return result[:3] + else: + return result + + def transformDir(self, direction, direct=True): + """Apply the transform to a direction. + + :param direction: Array-like vector of 3 coordinates. + :param bool direct: Whether to apply the direct (True, the default) + or inverse (False) transform. + :return: The transformed direction. + :rtype: numpy.ndarray of length 3. + """ + if direct: + matrix = self.getMatrix(copy=False) + else: + matrix = self.getInverseMatrix(copy=False) + return numpy.dot(matrix[:3, :3], direction[:3]) + + def transformNormal(self, normal, direct=True): + """Apply the transform to a normal: R = (M-1)t * V. + + :param normal: Array-like vector of 3 coordinates. + :param bool direct: Whether to apply the direct (True, the default) + or inverse (False) transform. + :return: The transformed normal. + :rtype: numpy.ndarray of length 3. + """ + if direct: + matrix = self.getInverseMatrix(copy=False).T + else: + matrix = self.getMatrix(copy=False).T + return numpy.dot(matrix[:3, :3], normal[:3]) + + _CUBE_CORNERS = numpy.array(list(itertools.product((0., 1.), repeat=3)), + dtype=numpy.float32) + """Unit cube corners used by :meth:`transformRectangularBox`""" + + def transformBounds(self, bounds, direct=True): + """Apply the transform to an axes-aligned rectangular box. + + :param bounds: Min and max coords of the box for each axes. + :type bounds: 2x3 numpy.ndarray + :param bool direct: Whether to apply the direct (True, the default) + or inverse (False) transform. + :return: Axes-aligned rectangular box including the transformed box. + :rtype: 2x3 numpy.ndarray of float32 + """ + corners = numpy.ones((8, 4), dtype=numpy.float32) + corners[:, :3] = bounds[0] + \ + self._CUBE_CORNERS * (bounds[1] - bounds[0]) + + if direct: + matrix = self.getMatrix(copy=False) + else: + matrix = self.getInverseMatrix(copy=False) + + # Transform corners + cornerstransposed = numpy.dot(matrix, corners.T) + cornerstransposed = cornerstransposed / cornerstransposed[3] + + # Get min/max for each axis + transformedbounds = numpy.empty((2, 3), dtype=numpy.float32) + transformedbounds[0] = cornerstransposed.T[:, :3].min(axis=0) + transformedbounds[1] = cornerstransposed.T[:, :3].max(axis=0) + + return transformedbounds + + +class Inverse(Transform): + """Transform which is the inverse of another one. + + Static: It never gets updated. + """ + + def __init__(self, transform): + """Initializer. + + :param Transform transform: The transform to invert. + """ + + super(Inverse, self).__init__(static=True) + self._matrix = transform.getInverseMatrix(copy=True) + self._inverse = transform.getMatrix(copy=True) + + +class TransformList(Transform, event.HookList): + """List of transforms.""" + + def __init__(self, iterable=()): + Transform.__init__(self) + event.HookList.__init__(self, iterable) + + def _listWillChangeHook(self, methodName, *args, **kwargs): + for item in self: + item.removeListener(self._transformChanged) + + def _listWasChangedHook(self, methodName, *args, **kwargs): + for item in self: + item.addListener(self._transformChanged) + self.notify() + + def _transformChanged(self, source): + """Listen to transform changes of the list and its items.""" + if source is not self: # Avoid infinite recursion + self.notify() + + def _makeMatrix(self): + matrix = numpy.identity(4, dtype=numpy.float32) + for transform in self: + matrix = numpy.dot(matrix, transform.getMatrix(copy=False)) + return matrix + + +class StaticTransformList(Transform): + """Transform that is a snapshot of a list of Transforms + + It does not keep reference to the list of Transforms. + + :param iterable: Iterable of Transform used for initialization + """ + + def __init__(self, iterable=()): + super(StaticTransformList, self).__init__(static=True) + matrix = numpy.identity(4, dtype=numpy.float32) + for transform in iterable: + matrix = numpy.dot(matrix, transform.getMatrix(copy=False)) + self._matrix = matrix # Init matrix once + + +# Affine ###################################################################### + +class Matrix(Transform): + + def __init__(self, matrix=None): + """4x4 Matrix. + + :param matrix: 4x4 array-like matrix or None for identity matrix. + """ + super(Matrix, self).__init__(static=True) + self.setMatrix(matrix) + + def setMatrix(self, matrix=None): + """Update the 4x4 Matrix. + + :param matrix: 4x4 array-like matrix or None for identity matrix. + """ + if matrix is None: + self._matrix = numpy.identity(4, dtype=numpy.float32) + else: + matrix = numpy.array(matrix, copy=True, dtype=numpy.float32) + assert matrix.shape == (4, 4) + self._matrix = matrix + # Reset cached inverse as Transform is declared static + self._inverse = None + self.notify() + + # Redefined here to add a setter + matrix = property(Transform.getMatrix, setMatrix, + doc="The 4x4 matrix of this transform.") + + +class Translate(Transform): + """4x4 translation matrix.""" + + def __init__(self, tx=0., ty=0., tz=0.): + super(Translate, self).__init__() + self._tx, self._ty, self._tz = 0., 0., 0. + self.setTranslate(tx, ty, tz) + + def _makeMatrix(self): + return mat4Translate(self.tx, self.ty, self.tz) + + def _makeInverse(self): + return mat4Translate(-self.tx, -self.ty, -self.tz) + + @property + def tx(self): + return self._tx + + @tx.setter + def tx(self, tx): + self.setTranslate(tx=tx) + + @property + def ty(self): + return self._ty + + @ty.setter + def ty(self, ty): + self.setTranslate(ty=ty) + + @property + def tz(self): + return self._tz + + @tz.setter + def tz(self, tz): + self.setTranslate(tz=tz) + + @property + def translation(self): + return numpy.array((self.tx, self.ty, self.tz), dtype=numpy.float32) + + @translation.setter + def translation(self, translations): + tx, ty, tz = translations + self.setTranslate(tx, ty, tz) + + def setTranslate(self, tx=None, ty=None, tz=None): + if tx is not None: + self._tx = tx + if ty is not None: + self._ty = ty + if tz is not None: + self._tz = tz + self.notify() + + +class Scale(Transform): + """4x4 scale matrix.""" + + def __init__(self, sx=1., sy=1., sz=1.): + super(Scale, self).__init__() + self._sx, self._sy, self._sz = 0., 0., 0. + self.setScale(sx, sy, sz) + + def _makeMatrix(self): + return mat4Scale(self.sx, self.sy, self.sz) + + def _makeInverse(self): + return mat4Scale(1. / self.sx, 1. / self.sy, 1. / self.sz) + + @property + def sx(self): + return self._sx + + @sx.setter + def sx(self, sx): + self.setScale(sx=sx) + + @property + def sy(self): + return self._sy + + @sy.setter + def sy(self, sy): + self.setScale(sy=sy) + + @property + def sz(self): + return self._sz + + @sz.setter + def sz(self, sz): + self.setScale(sz=sz) + + @property + def scale(self): + return numpy.array((self._sx, self._sy, self._sz), dtype=numpy.float32) + + @scale.setter + def scale(self, scales): + sx, sy, sz = scales + self.setScale(sx, sy, sz) + + def setScale(self, sx=None, sy=None, sz=None): + if sx is not None: + assert sx != 0. + self._sx = sx + if sy is not None: + assert sy != 0. + self._sy = sy + if sz is not None: + assert sz != 0. + self._sz = sz + self.notify() + + +class Rotate(Transform): + + def __init__(self, angle=0., ax=0., ay=0., az=1.): + """4x4 rotation matrix. + + :param float angle: The rotation angle in degrees. + :param float ax: The x coordinate of the rotation axis. + :param float ay: The y coordinate of the rotation axis. + :param float az: The z coordinate of the rotation axis. + """ + super(Rotate, self).__init__() + self._angle = 0. + self._axis = None + self.setAngleAxis(angle, (ax, ay, az)) + + @property + def angle(self): + """The rotation angle in degrees.""" + return self._angle + + @angle.setter + def angle(self, angle): + self.setAngleAxis(angle=angle) + + @property + def axis(self): + """The normalized rotation axis as a numpy.ndarray.""" + return self._axis.copy() + + @axis.setter + def axis(self, axis): + self.setAngleAxis(axis=axis) + + def setAngleAxis(self, angle=None, axis=None): + """Update the angle and/or axis of the rotation. + + :param float angle: The rotation angle in degrees. + :param axis: Array-like axis vector (3 coordinates). + """ + if angle is not None: + self._angle = angle + if axis is not None: + assert len(axis) == 3 + axis = numpy.array(axis, copy=True, dtype=numpy.float32) + assert axis.size == 3 + norm = numpy.linalg.norm(axis) + if norm == 0.: # No axis, set rotation angle to 0. + self._angle = 0. + self._axis = numpy.array((0., 0., 1.), dtype=numpy.float32) + else: + self._axis = axis / norm + + if angle is not None or axis is not None: + self.notify() + + @property + def quaternion(self): + """Rotation unit quaternion as (x, y, z, w). + + Where: ||(x, y, z)|| = sin(angle/2), w = cos(angle/2). + """ + if numpy.linalg.norm(self._axis) == 0.: + return numpy.array((0., 0., 0., 1.), dtype=numpy.float32) + + else: + quaternion = numpy.empty((4,), dtype=numpy.float32) + halfangle = 0.5 * numpy.radians(self.angle) + quaternion[0:3] = numpy.sin(halfangle) * self._axis + quaternion[3] = numpy.cos(halfangle) + return quaternion + + @quaternion.setter + def quaternion(self, quaternion): + assert len(quaternion) == 4 + + # Normalize quaternion + quaternion = numpy.array(quaternion, copy=True) + quaternion /= numpy.linalg.norm(quaternion) + + # Get angle + sinhalfangle = numpy.linalg.norm(quaternion[0:3]) + coshalfangle = quaternion[3] + angle = 2. * numpy.arctan2(sinhalfangle, coshalfangle) + + # Axis will be normalized in setAngleAxis + self.setAngleAxis(numpy.degrees(angle), quaternion[0:3]) + + def _makeMatrix(self): + angle = numpy.radians(self.angle, dtype=numpy.float32) + return mat4RotateFromAngleAxis(angle, *self.axis) + + def _makeInverse(self): + return numpy.array(self.getMatrix(copy=False).transpose(), + copy=True, order='C', + dtype=numpy.float32) + + +class Shear(Transform): + + def __init__(self, axis, sx=0., sy=0., sz=0.): + """4x4 shear/skew matrix of 2 axes relative to the third one. + + :param str axis: The axis to keep fixed, in 'x', 'y', 'z' + :param float sx: The shear factor for the x axis. + :param float sy: The shear factor for the y axis. + :param float sz: The shear factor for the z axis. + """ + assert axis in ('x', 'y', 'z') + super(Shear, self).__init__() + self._axis = axis + self._factors = sx, sy, sz + + @property + def axis(self): + """The axis against which other axes are skewed.""" + return self._axis + + @property + def factors(self): + """The shear factors: shearFactor = tan(shearAngle)""" + return self._factors + + def _makeMatrix(self): + return mat4Shear(self.axis, *self.factors) + + def _makeInverse(self): + sx, sy, sz = self.factors + return mat4Shear(self.axis, -sx, -sy, -sz) + + +# Projection ################################################################## + +class _Projection(Transform): + """Base class for projection matrix. + + Handles near and far clipping plane values. + Subclasses must implement :meth:`_makeMatrix`. + + :param float near: Distance to the near plane. + :param float far: Distance to the far plane. + :param bool checkDepthExtent: Toggle checks near > 0 and far > near. + :param size: Viewport's size used to compute the aspect ratio. + :type size: 2-tuple of float (width, height). + """ + + def __init__(self, near, far, checkDepthExtent=False, size=(1., 1.)): + super(_Projection, self).__init__() + self._checkDepthExtent = checkDepthExtent + self._depthExtent = 1, 10 + self.setDepthExtent(near, far) # set _depthExtent + self._size = 1., 1. + self.size = size # set _size + + def setDepthExtent(self, near=None, far=None): + """Set the extent of the visible area along the viewing direction. + + :param float near: The near clipping plane Z coord. + :param float far: The far clipping plane Z coord. + """ + near = float(near) if near is not None else self._depthExtent[0] + far = float(far) if far is not None else self._depthExtent[1] + + if self._checkDepthExtent: + assert near > 0. + assert far > near + + self._depthExtent = near, far + self.notify() + + @property + def near(self): + """Distance to the near plane.""" + return self._depthExtent[0] + + @near.setter + def near(self, near): + if near != self.near: + self.setDepthExtent(near=near) + + @property + def far(self): + """Distance to the far plane.""" + return self._depthExtent[1] + + @far.setter + def far(self, far): + if far != self.far: + self.setDepthExtent(far=far) + + @property + def size(self): + """Viewport size as a 2-tuple of float (width, height).""" + return self._size + + @size.setter + def size(self, size): + assert len(size) == 2 + self._size = tuple(size) + self.notify() + + +class Orthographic(_Projection): + """Orthographic (i.e., parallel) projection which keeps aspect ratio. + + Clipping planes are adjusted to match the aspect ratio of + the :attr:`size` attribute. + + The left, right, bottom and top parameters defines the area which must + always remain visible. + Effective clipping planes are adjusted to keep the aspect ratio. + + :param float left: Coord of the left clipping plane. + :param float right: Coord of the right clipping plane. + :param float bottom: Coord of the bottom clipping plane. + :param float top: Coord of the top clipping plane. + :param float near: Distance to the near plane. + :param float far: Distance to the far plane. + :param size: Viewport's size used to compute the aspect ratio. + :type size: 2-tuple of float (width, height). + """ + + def __init__(self, left=0., right=1., bottom=1., top=0., near=-1., far=1., + size=(1., 1.)): + self._left, self._right = left, right + self._bottom, self._top = bottom, top + super(Orthographic, self).__init__(near, far, checkDepthExtent=False, + size=size) + # _update called when setting size + + def _makeMatrix(self): + return mat4Orthographic( + self.left, self.right, self.bottom, self.top, self.near, self.far) + + def _update(self, left, right, bottom, top): + width, height = self.size + aspect = width / height + + orthoaspect = abs(left - right) / abs(bottom - top) + + if orthoaspect >= aspect: # Keep width, enlarge height + newheight = \ + numpy.sign(top - bottom) * abs(left - right) / aspect + bottom = 0.5 * (bottom + top) - 0.5 * newheight + top = bottom + newheight + + else: # Keep height, enlarge width + newwidth = \ + numpy.sign(right - left) * abs(bottom - top) * aspect + left = 0.5 * (left + right) - 0.5 * newwidth + right = left + newwidth + + # Store values + self._left, self._right = left, right + self._bottom, self._top = bottom, top + + def setClipping(self, left=None, right=None, bottom=None, top=None): + """Set the clipping planes of the projection. + + Parameters are adjusted to keep aspect ratio. + If a clipping plane coord is not provided, it uses its current value + + :param float left: Coord of the left clipping plane. + :param float right: Coord of the right clipping plane. + :param float bottom: Coord of the bottom clipping plane. + :param float top: Coord of the top clipping plane. + """ + left = float(left) if left is not None else self.left + right = float(right) if right is not None else self.right + bottom = float(bottom) if bottom is not None else self.bottom + top = float(top) if top is not None else self.top + + self._update(left, right, bottom, top) + self.notify() + + left = property(lambda self: self._left, + doc="Coord of the left clipping plane.") + + right = property(lambda self: self._right, + doc="Coord of the right clipping plane.") + + bottom = property(lambda self: self._bottom, + doc="Coord of the bottom clipping plane.") + + top = property(lambda self: self._top, + doc="Coord of the top clipping plane.") + + @property + def size(self): + """Viewport size as a 2-tuple of float (width, height) or None.""" + return self._size + + @size.setter + def size(self, size): + assert len(size) == 2 + self._size = float(size[0]), float(size[1]) + self._update(self.left, self.right, self.bottom, self.top) + self.notify() + + +class Ortho2DWidget(_Projection): + """Orthographic projection with pixel as unit. + + Provides same coordinates as widgets: + origin: top left, X axis goes left, Y axis goes down. + + :param float near: Z coordinate of the near clipping plane. + :param float far: Z coordinante of the far clipping plane. + :param size: Viewport's size used to compute the aspect ratio. + :type size: 2-tuple of float (width, height). + """ + + def __init__(self, near=-1., far=1., size=(1., 1.)): + + super(Ortho2DWidget, self).__init__(near, far, size) + + def _makeMatrix(self): + width, height = self.size + return mat4Orthographic(0., width, height, 0., self.near, self.far) + + +class Perspective(_Projection): + """Perspective projection matrix defined by FOV and aspect ratio. + + :param float fovy: Vertical field-of-view in degrees. + :param float near: The near clipping plane Z coord (stricly positive). + :param float far: The far clipping plane Z coord (> near). + :param size: Viewport's size used to compute the aspect ratio. + :type size: 2-tuple of float (width, height). + """ + + def __init__(self, fovy=90., near=0.1, far=1., size=(1., 1.)): + + super(Perspective, self).__init__(near, far, checkDepthExtent=True) + self._fovy = 90. + self.fovy = fovy # Set _fovy + self.size = size # Set _ size + + def _makeMatrix(self): + width, height = self.size + return mat4Perspective(self.fovy, width, height, self.near, self.far) + + @property + def fovy(self): + """Vertical field-of-view in degrees.""" + return self._fovy + + @fovy.setter + def fovy(self, fovy): + self._fovy = float(fovy) + self.notify() diff --git a/silx/gui/plot3d/scene/utils.py b/silx/gui/plot3d/scene/utils.py new file mode 100644 index 0000000..930a087 --- /dev/null +++ b/silx/gui/plot3d/scene/utils.py @@ -0,0 +1,516 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2015-2017 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +""" +This module provides functions to generate indices, to check intersection +and to handle planes. +""" + +from __future__ import absolute_import, division, unicode_literals + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "25/07/2016" + + +import logging +import numpy + +from . import event + + +_logger = logging.getLogger(__name__) + + +# numpy ####################################################################### + +def _uniqueAlongLastAxis(a): + """Numpy unique on the last axis of a 2D array + + Implemented here as not in numpy as of writing. + + See adding axis parameter to numpy.unique: + https://github.com/numpy/numpy/pull/3584/files#r6225452 + + :param array_like a: Input array. + :return: Unique elements along the last axis. + :rtype: numpy.ndarray + """ + assert len(a.shape) == 2 + + # Construct a type over last array dimension to run unique on a 1D array + if a.dtype.char in numpy.typecodes['AllInteger']: + # Bit-wise comparison of the 2 indices of a line at once + # Expect a C contiguous array of shape N, 2 + uniquedt = numpy.dtype((numpy.void, a.itemsize * a.shape[-1])) + elif a.dtype.char in numpy.typecodes['Float']: + uniquedt = [('f{i}'.format(i=i), a.dtype) for i in range(a.shape[-1])] + else: + raise TypeError("Unsupported type {dtype}".format(dtype=a.dtype)) + + uniquearray = numpy.unique(numpy.ascontiguousarray(a).view(uniquedt)) + return uniquearray.view(a.dtype).reshape((-1, a.shape[-1])) + + +# conversions ################################################################# + +def triangleToLineIndices(triangleIndices, unicity=False): + """Generates lines indices from triangle indices. + + This is generating lines indices for the edges of the triangles. + + :param triangleIndices: The indices to draw a set of vertices as triangles. + :type triangleIndices: numpy.ndarray + :param bool unicity: If True remove duplicated lines, + else (the default) returns all lines. + :return: The indices to draw the edges of the triangles as lines. + :rtype: 1D numpy.ndarray of uint16 or uint32. + """ + # Makes sure indices ar packed by triangle + triangleIndices = triangleIndices.reshape(-1, 3) + + # Pack line indices by triangle and by edge + lineindices = numpy.empty((len(triangleIndices), 3, 2), + dtype=triangleIndices.dtype) + lineindices[:, 0] = triangleIndices[:, :2] # edge = t0, t1 + lineindices[:, 1] = triangleIndices[:, 1:] # edge =t1, t2 + lineindices[:, 2] = triangleIndices[:, ::2] # edge = t0, t2 + + if unicity: + lineindices = _uniqueAlongLastAxis(lineindices.reshape(-1, 2)) + + # Make sure it is 1D + lineindices.shape = -1 + + return lineindices + + +def verticesNormalsToLines(vertices, normals, scale=1.): + """Return vertices of lines representing normals at given positions. + + :param vertices: Positions of the points. + :type vertices: numpy.ndarray with shape: (nbPoints, 3) + :param normals: Corresponding normals at the points. + :type normals: numpy.ndarray with shape: (nbPoints, 3) + :param float scale: The scale factor to apply to normals. + :returns: Array of vertices to draw corresponding lines. + :rtype: numpy.ndarray with shape: (nbPoints * 2, 3) + """ + linevertices = numpy.empty((len(vertices) * 2, 3), dtype=vertices.dtype) + linevertices[0::2] = vertices + linevertices[1::2] = vertices + scale * normals + return linevertices + + +def unindexArrays(mode, indices, *arrays): + """Convert indexed GL primitives to unindexed ones. + + Given indices in arrays and the OpenGL primitive they represent, + return the unindexed equivalent. + + :param str mode: + Kind of primitive represented by indices. + In: points, lines, line_strip, loop, triangles, triangle_strip, fan. + :param indices: Indices in other arrays + :type indices: numpy.ndarray of dimension 1. + :param arrays: Remaining arguments are arrays to convert + :return: Converted arrays + :rtype: tuple of numpy.ndarray + """ + indices = numpy.array(indices, copy=False) + + assert mode in ('points', + 'lines', 'line_strip', 'loop', + 'triangles', 'triangle_strip', 'fan') + + if mode in ('lines', 'line_strip', 'loop'): + assert len(indices) >= 2 + elif mode in ('triangles', 'triangle_strip', 'fan'): + assert len(indices) >= 3 + + assert indices.min() >= 0 + max_index = indices.max() + for data in arrays: + assert len(data) >= max_index + + if mode == 'line_strip': + unpacked = numpy.empty((2 * (len(indices) - 1),), dtype=indices.dtype) + unpacked[0::2] = indices[:-1] + unpacked[1::2] = indices[1:] + indices = unpacked + + elif mode == 'loop': + unpacked = numpy.empty((2 * len(indices),), dtype=indices.dtype) + unpacked[0::2] = indices + unpacked[1:-1:2] = indices[1:] + unpacked[-1] = indices[0] + indices = unpacked + + elif mode == 'triangle_strip': + unpacked = numpy.empty((3 * (len(indices) - 2),), dtype=indices.dtype) + unpacked[0::3] = indices[:-2] + unpacked[1::3] = indices[1:-1] + unpacked[2::3] = indices[2:] + indices = unpacked + + elif mode == 'fan': + unpacked = numpy.empty((3 * (len(indices) - 2),), dtype=indices.dtype) + unpacked[0::3] = indices[0] + unpacked[1::3] = indices[1:-1] + unpacked[2::3] = indices[2:] + indices = unpacked + + return tuple(numpy.ascontiguousarray(data[indices]) for data in arrays) + + +def trianglesNormal(positions): + """Return normal for each triangle. + + :param positions: Serie of triangle's corners + :type positions: numpy.ndarray of shape (NbTriangles*3, 3) + :return: Normals corresponding to each position. + :rtype: numpy.ndarray of shape (NbTriangles, 3) + """ + assert positions.ndim == 2 + assert positions.shape[1] == 3 + + positions = numpy.array(positions, copy=False).reshape(-1, 3, 3) + + normals = numpy.cross(positions[:, 1] - positions[:, 0], + positions[:, 2] - positions[:, 0]) + + # Normalize normals + if numpy.version.version < '1.8.0': + # Debian 7 support: numpy.linalg.norm has no axis argument + norms = numpy.array(tuple(numpy.linalg.norm(vec) for vec in normals), + dtype=normals.dtype) + else: + norms = numpy.linalg.norm(normals, axis=1) + norms[norms == 0] = 1 + + return normals / norms.reshape(-1, 1) + + +# grid ######################################################################## + +def gridVertices(dim0Array, dim1Array, dtype): + """Generate an array of 2D positions from 2 arrays of 1D coordinates. + + :param dim0Array: 1D array-like of coordinates along the first dimension. + :param dim1Array: 1D array-like of coordinates along the second dimension. + :param numpy.dtype dtype: Data type of the output array. + :return: Array of grid coordinates. + :rtype: numpy.ndarray with shape: (len(dim0Array), len(dim1Array), 2) + """ + grid = numpy.empty((len(dim0Array), len(dim1Array), 2), dtype=dtype) + grid.T[0, :, :] = dim0Array + grid.T[1, :, :] = numpy.array(dim1Array, copy=False)[:, None] + return grid + + +def triangleStripGridIndices(dim0, dim1): + """Generate indices to draw a grid of vertices as a triangle strip. + + Vertices are expected to be stored as row-major (i.e., C contiguous). + + :param int dim0: The number of rows of vertices. + :param int dim1: The number of columns of vertices. + :return: The vertex indices + :rtype: 1D numpy.ndarray of uint32 + """ + assert dim0 >= 2 + assert dim1 >= 2 + + # Filling a row of squares + + # an index before and one after for degenerated triangles + indices = numpy.empty((dim0 - 1, 2 * (dim1 + 1)), dtype=numpy.uint32) + + # Init indices with minimum indices for each row of squares + indices[:] = (dim1 * numpy.arange(dim0 - 1, dtype=numpy.uint32))[:, None] + + # Update indices with offset per row of squares + offset = numpy.arange(dim1, dtype=numpy.uint32) + indices[:, 1:-1:2] += offset + offset += dim1 + indices[:, 2::2] += offset + indices[:, -1] += offset[-1] + + # Remove extra indices for degenerated triangles before returning + return indices.ravel()[1:-1] + + # Alternative: + # indices = numpy.zeros(2 * dim1 * (dim0 - 1) + 2 * (dim0 - 2), + # dtype=numpy.uint32) + # + # offset = numpy.arange(dim1, dtype=numpy.uint32) + # for d0Index in range(dim0 - 1): + # start = 2 * d0Index * (dim1 + 1) + # end = start + 2 * dim1 + # if d0Index != 0: + # indices[start - 2] = offset[-1] + # indices[start - 1] = offset[0] + # indices[start:end:2] = offset + # offset += dim1 + # indices[start + 1:end:2] = offset + # return indices + + +def linesGridIndices(dim0, dim1): + """Generate indices to draw a grid of vertices as lines. + + Vertices are expected to be stored as row-major (i.e., C contiguous). + + :param int dim0: The number of rows of vertices. + :param int dim1: The number of columns of vertices. + :return: The vertex indices. + :rtype: 1D numpy.ndarray of uint32 + """ + # Horizontal and vertical lines + nbsegmentalongdim1 = 2 * (dim1 - 1) + nbsegmentalongdim0 = 2 * (dim0 - 1) + + indices = numpy.empty(nbsegmentalongdim1 * dim0 + + nbsegmentalongdim0 * dim1, + dtype=numpy.uint32) + + # Line indices over dim0 + onedim1line = (numpy.arange(nbsegmentalongdim1, + dtype=numpy.uint32) + 1) // 2 + indices[:dim0 * nbsegmentalongdim1] = \ + (dim1 * numpy.arange(dim0, dtype=numpy.uint32)[:, None] + + onedim1line[None, :]).ravel() + + # Line indices over dim1 + onedim0line = (numpy.arange(nbsegmentalongdim0, + dtype=numpy.uint32) + 1) // 2 + indices[dim0 * nbsegmentalongdim1:] = \ + (numpy.arange(dim1, dtype=numpy.uint32)[:, None] + + dim1 * onedim0line[None, :]).ravel() + + return indices + + +# intersection ################################################################ + +def angleBetweenVectors(refVector, vectors, norm=None): + """Return the angle between 2 vectors. + + :param refVector: Coordinates of the reference vector. + :type refVector: numpy.ndarray of shape: (NCoords,) + :param vectors: Coordinates of the vector(s) to get angle from reference. + :type vectors: numpy.ndarray of shape: (NCoords,) or (NbVector, NCoords) + :param norm: A direction vector giving an orientation to the angles + or None. + :returns: The angles in radians in [0, pi] if norm is None + else in [0, 2pi]. + :rtype: float or numpy.ndarray of shape (NbVectors,) + """ + singlevector = len(vectors.shape) == 1 + if singlevector: # Make it a 2D array for the computation + vectors = vectors.reshape(1, -1) + + assert len(refVector.shape) == 1 + assert len(vectors.shape) == 2 + assert len(refVector) == vectors.shape[1] + + # Normalize vectors + refVector /= numpy.linalg.norm(refVector) + vectors = numpy.array([v / numpy.linalg.norm(v) for v in vectors]) + + dots = numpy.sum(refVector * vectors, axis=-1) + angles = numpy.arccos(numpy.clip(dots, -1., 1.)) + if norm is not None: + signs = numpy.sum(norm * numpy.cross(refVector, vectors), axis=-1) < 0. + angles[signs] = numpy.pi * 2. - angles[signs] + + return angles[0] if singlevector else angles + + +def segmentPlaneIntersect(s0, s1, planeNorm, planePt): + """Compute the intersection of a segment with a plane. + + :param s0: First end of the segment + :type s0: 1D numpy.ndarray-like of length 3 + :param s1: Second end of the segment + :type s1: 1D numpy.ndarray-like of length 3 + :param planeNorm: Normal vector of the plane. + :type planeNorm: numpy.ndarray of shape: (3,) + :param planePt: A point of the plane. + :type planePt: numpy.ndarray of shape: (3,) + :return: The intersection points. The number of points goes + from 0 (no intersection) to 2 (segment in the plane) + :rtype: list of numpy.ndarray + """ + s0, s1 = numpy.asarray(s0), numpy.asarray(s1) + + segdir = s1 - s0 + dotnormseg = numpy.dot(planeNorm, segdir) + if dotnormseg == 0: + # line and plane are parallels + if numpy.dot(planeNorm, planePt - s0) == 0: # segment is in plane + return [s0, s1] + else: # No intersection + return [] + + alpha = - numpy.dot(planeNorm, s0 - planePt) / dotnormseg + if 0. <= alpha <= 1.: # Intersection with segment + return [s0 + alpha * segdir] + else: # intersection outside segment + return [] + + +def boxPlaneIntersect(boxVertices, boxLineIndices, planeNorm, planePt): + """Return intersection points between a box and a plane. + + :param boxVertices: Position of the corners of the box. + :type boxVertices: numpy.ndarray with shape: (8, 3) + :param boxLineIndices: Indices of the box edges. + :type boxLineIndices: numpy.ndarray-like with shape: (12, 2) + :param planeNorm: Normal vector of the plane. + :type planeNorm: numpy.ndarray of shape: (3,) + :param planePt: A point of the plane. + :type planePt: numpy.ndarray of shape: (3,) + :return: The found intersection points + :rtype: numpy.ndarray with 2 dimensions + """ + segments = numpy.take(boxVertices, boxLineIndices, axis=0) + + points = set() # Gather unique intersection points + for seg in segments: + for point in segmentPlaneIntersect(seg[0], seg[1], planeNorm, planePt): + points.add(tuple(point)) + points = numpy.array(list(points)) + + if len(points) <= 2: + return numpy.array(()) + elif len(points) == 3: + return points + else: # len(points) > 3 + # Order point to have a polyline lying on the unit cube's faces + vectors = points - numpy.mean(points, axis=0) + angles = angleBetweenVectors(vectors[0], vectors, planeNorm) + points = numpy.take(points, numpy.argsort(angles), axis=0) + return points + + +# Plane ####################################################################### + +class Plane(event.Notifier): + """Object handling a plane and notifying plane changes. + + :param point: A point on the plane. + :type point: 3-tuple of float. + :param normal: Normal of the plane. + :type normal: 3-tuple of float. + """ + + def __init__(self, point=(0., 0., 0.), normal=(0., 0., 1.)): + super(Plane, self).__init__() + + assert len(point) == 3 + self._point = numpy.array(point, copy=True, dtype=numpy.float32) + assert len(normal) == 3 + self._normal = numpy.array(normal, copy=True, dtype=numpy.float32) + self.notify() + + def setPlane(self, point=None, normal=None): + """Set plane point and normal and notify. + + :param point: A point on the plane. + :type point: 3-tuple of float or None. + :param normal: Normal of the plane. + :type normal: 3-tuple of float or None. + """ + planechanged = False + + if point is not None: + assert len(point) == 3 + point = numpy.array(point, copy=True, dtype=numpy.float32) + if not numpy.all(numpy.equal(self._point, point)): + self._point = point + planechanged = True + + if normal is not None: + assert len(normal) == 3 + normal = numpy.array(normal, copy=True, dtype=numpy.float32) + + norm = numpy.linalg.norm(normal) + if norm != 0.: + normal /= norm + + if not numpy.all(numpy.equal(self._normal, normal)): + self._normal = normal + planechanged = True + + if planechanged: + _logger.debug('Plane updated:\n\tpoint: %s\n\tnormal: %s', + str(self._point), str(self._normal)) + self.notify() + + @property + def point(self): + """A point on the plane.""" + return self._point.copy() + + @point.setter + def point(self, point): + self.setPlane(point=point) + + @property + def normal(self): + """The (normalized) normal of the plane.""" + return self._normal.copy() + + @normal.setter + def normal(self, normal): + self.setPlane(normal=normal) + + @property + def parameters(self): + """Plane equation parameters: a*x + b*y + c*z + d = 0.""" + return numpy.append(self._normal, + - numpy.dot(self._point, self._normal)) + + @parameters.setter + def parameters(self, parameters): + assert len(parameters) == 4 + parameters = numpy.array(parameters, dtype=numpy.float32) + + # Normalize normal + norm = numpy.linalg.norm(parameters[:3]) + if norm != 0: + parameters /= norm + + normal = parameters[:3] + point = - parameters[3] * normal + self.setPlane(point, normal) + + @property + def isPlane(self): + """True if a plane is defined (i.e., ||normal|| != 0).""" + return numpy.any(self.normal != 0.) + + def move(self, step): + """Move the plane of step along the normal.""" + self.point += step * self.normal diff --git a/silx/gui/plot3d/scene/viewport.py b/silx/gui/plot3d/scene/viewport.py new file mode 100644 index 0000000..72e1ea3 --- /dev/null +++ b/silx/gui/plot3d/scene/viewport.py @@ -0,0 +1,505 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2015-2017 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""This module provides a class to control a viewport on the rendering window. + +The :class:`Viewport` describes a Viewport rendering a scene. +The attribute :attr:`scene` is the root group of the scene tree. +:class:`RenderContext` handles the current state during rendering. +""" + +from __future__ import absolute_import, division, unicode_literals + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "25/07/2016" + + +import numpy + +from silx.gui.plot.Colors import rgba + +from ..._glutils import gl + +from . import camera +from . import event +from . import transform +from .function import DirectionalLight, ClippingPlane + + +class RenderContext(object): + """Handle a current rendering context. + + An instance of this class is passed to rendering method through + the scene during render. + + User should NEVER use an instance of this class beyond the method + it is passed to as an argument (i.e., do not keep a reference to it). + + :param Viewport viewport: The viewport doing the rendering. + :param Context glContext: The operating system OpenGL context in use. + """ + + def __init__(self, viewport, glContext): + self._viewport = viewport + self._glContext = glContext + self._transformStack = [viewport.camera.extrinsic] + self._clipPlane = ClippingPlane(normal=(0., 0., 0.)) + + @property + def viewport(self): + """Viewport doing the current rendering""" + return self._viewport + + @property + def glCtx(self): + """The OpenGL context in use""" + return self._glContext + + @property + def objectToCamera(self): + """The current transform from object to camera coords. + + Do not modify. + """ + return self._transformStack[-1] + + @property + def projection(self): + """Projection transform. + + Do not modify. + """ + return self.viewport.camera.intrinsic + + @property + def objectToNDC(self): + """The transform from object to NDC (this includes projection). + + Do not modify. + """ + return transform.StaticTransformList( + (self.projection, self.objectToCamera)) + + def pushTransform(self, transform_, multiply=True): + """Push a :class:`Transform` on the transform stack. + + :param Transform transform_: The transform to add to the stack. + :param bool multiply: + True (the default) to multiply with the top of the stack, + False to push the transform as is without multiplication. + """ + if multiply: + assert len(self._transformStack) >= 1 + transform_ = transform.StaticTransformList( + (self._transformStack[-1], transform_)) + + self._transformStack.append(transform_) + + def popTransform(self): + """Pop the transform on top of the stack. + + :return: The Transform that is popped from the stack. + """ + assert len(self._transformStack) > 1 + return self._transformStack.pop() + + @property + def clipper(self): + """The current clipping plane + """ + return self._clipPlane + + def setClipPlane(self, point=(0., 0., 0.), normal=(0., 0., 0.)): + """Set the clipping plane to use + + For now only handles a single clipping plane. + + :param point: A point of the plane + :type point: 3-tuple of float + :param normal: Normal vector of the plane or (0, 0, 0) for no clipping + :type normal: 3-tuple of float + """ + self._clipPlane = ClippingPlane(point, normal) + + +class Viewport(event.Notifier): + """Rendering a single scene through a camera in part of a framebuffer. + + :param int framebuffer: The framebuffer ID this viewport is rendering into + """ + + def __init__(self, framebuffer=0): + from . import Group # Here to avoid cyclic import + super(Viewport, self).__init__() + self._dirty = True + self._origin = 0, 0 + self._size = 1, 1 + self._framebuffer = int(framebuffer) + self.scene = Group() # The stuff to render, add overlaid scenes? + self.scene._setParent(self) + self.scene.addListener(self._changed) + self._background = 0., 0., 0., 1. + self._camera = camera.Camera(fovy=30., near=1., far=100., + position=(0., 0., 12.)) + self._camera.addListener(self._changed) + self._transforms = transform.TransformList([self._camera]) + + self._light = DirectionalLight(direction=(0., 0., -1.), + ambient=(0.3, 0.3, 0.3), + diffuse=(0.7, 0.7, 0.7)) + self._light.addListener(self._changed) + + @property + def transforms(self): + """Proxy of camera transforms. + + Do not modify the list. + """ + return self._transforms + + def _changed(self, *args, **kwargs): + """Callback handling scene updates""" + self._dirty = True + self.notify() + + @property + def dirty(self): + """True if scene is dirty and needs redisplay.""" + return self._dirty + + def resetDirty(self): + """Mark the scene as not being dirty. + + To call after rendering. + """ + self._dirty = False + + @property + def background(self): + """Background color of the viewport (4-tuple of float in [0, 1]""" + return self._background + + @background.setter + def background(self, color): + color = rgba(color) + if self._background != color: + self._background = color + self._changed() + + @property + def camera(self): + """The camera used to render the scene.""" + return self._camera + + @property + def light(self): + """The light used to render the scene.""" + return self._light + + @property + def origin(self): + """Origin (ox, oy) of the viewport in pixels""" + return self._origin + + @origin.setter + def origin(self, origin): + ox, oy = origin + origin = int(ox), int(oy) + if origin != self._origin: + self._origin = origin + self._changed() + + @property + def size(self): + """Size (width, height) of the viewport in pixels""" + return self._size + + @size.setter + def size(self, size): + w, h = size + size = int(w), int(h) + if size != self._size: + self._size = size + + self.camera.intrinsic.size = size + self._changed() + + @property + def shape(self): + """Shape (height, width) of the viewport in pixels. + + This is a convenient wrapper to the inverse of size. + """ + return self._size[1], self._size[0] + + @shape.setter + def shape(self, shape): + self.size = shape[1], shape[0] + + @property + def framebuffer(self): + """The framebuffer ID this viewport is rendering into (int)""" + return self._framebuffer + + @framebuffer.setter + def framebuffer(self, framebuffer): + self._framebuffer = int(framebuffer) + + def render(self, glContext): + """Perform the rendering of the viewport + + :param Context glContext: The context used for rendering""" + # Get a chance to run deferred delete + glContext.cleanGLGarbage() + + # OpenGL set-up: really need to be done once + ox, oy = self.origin + w, h = self.size + gl.glViewport(ox, oy, w, h) + + gl.glEnable(gl.GL_SCISSOR_TEST) + gl.glScissor(ox, oy, w, h) + + gl.glEnable(gl.GL_BLEND) + gl.glBlendFunc(gl.GL_SRC_ALPHA, gl.GL_ONE_MINUS_SRC_ALPHA) + + gl.glEnable(gl.GL_DEPTH_TEST) + gl.glDepthFunc(gl.GL_LEQUAL) + gl.glDepthRange(0., 1.) + + # gl.glEnable(gl.GL_POLYGON_OFFSET_FILL) + # gl.glPolygonOffset(1., 1.) + + gl.glHint(gl.GL_LINE_SMOOTH_HINT, gl.GL_NICEST) + gl.glEnable(gl.GL_LINE_SMOOTH) + + gl.glClearColor(*self.background) + + # Prepare OpenGL + gl.glClear(gl.GL_COLOR_BUFFER_BIT | + gl.GL_STENCIL_BUFFER_BIT | + gl.GL_DEPTH_BUFFER_BIT) + + ctx = RenderContext(self, glContext) + self.scene.render(ctx) + self.scene.postRender(ctx) + + def adjustCameraDepthExtent(self): + """Update camera depth extent to fit the scene bounds. + + Only near and far planes are updated. + The scene might still not be fully visible + (e.g., if spanning behind the viewpoint with perspective projection). + """ + bounds = self.scene.bounds(transformed=True) + if bounds is None: + bounds = numpy.array(((0., 0., 0.), (1., 1., 1.)), + dtype=numpy.float32) + bounds = self.camera.extrinsic.transformBounds(bounds) + + if isinstance(self.camera.intrinsic, transform.Perspective): + # This needs to be reworked + zbounds = - bounds[:, 2] + zextent = max(numpy.fabs(zbounds[0] - zbounds[1]), 0.0001) + near = max(zextent / 1000., 0.95 * zbounds[1]) + far = max(near + 0.1, 1.05 * zbounds[0]) + + self.camera.intrinsic.setDepthExtent(near, far) + elif isinstance(self.camera.intrinsic, transform.Orthographic): + # Makes sure z bounds are included + border = max(abs(bounds[:, 2])) + self.camera.intrinsic.setDepthExtent(-border, border) + else: + raise RuntimeError('Unsupported camera', self.camera.intrinsic) + + def resetCamera(self): + """Change camera to have the whole scene in the viewing frustum. + + It updates the camera position and depth extent. + Camera sight direction and up are not affected. + """ + bounds = self.scene.bounds(transformed=True) + if bounds is None: + bounds = numpy.array(((0., 0., 0.), (1., 1., 1.)), + dtype=numpy.float32) + self.camera.resetCamera(bounds) + + def orbitCamera(self, direction, angle=1.): + """Rotate the camera around center of the scene. + + :param str direction: Direction of movement relative to image plane. + In: 'up', 'down', 'left', 'right'. + :param float angle: he angle in degrees of the rotation. + """ + bounds = self.scene.bounds(transformed=True) + if bounds is None: + bounds = numpy.array(((0., 0., 0.), (1., 1., 1.)), + dtype=numpy.float32) + center = 0.5 * (bounds[0] + bounds[1]) + self.camera.orbit(direction, center, angle) + + def moveCamera(self, direction, step=0.1): + """Move the camera relative to the image plane. + + :param str direction: Direction relative to image plane. + One of: 'up', 'down', 'left', 'right', + 'forward', 'backward'. + :param float step: The ratio of data to step for each pan. + """ + bounds = self.scene.bounds(transformed=True) + if bounds is None: + bounds = numpy.array(((0., 0., 0.), (1., 1., 1.)), + dtype=numpy.float32) + bounds = self.camera.extrinsic.transformBounds(bounds) + center = 0.5 * (bounds[0] + bounds[1]) + ndcCenter = self.camera.intrinsic.transformPoint( + center, perspectiveDivide=True) + + step *= 2. # NDC has size 2 + + if direction == 'up': + ndcCenter[1] -= step + elif direction == 'down': + ndcCenter[1] += step + + elif direction == 'right': + ndcCenter[0] -= step + elif direction == 'left': + ndcCenter[0] += step + + elif direction == 'forward': + ndcCenter[2] += step + elif direction == 'backward': + ndcCenter[2] -= step + + else: + raise ValueError('Unsupported direction: %s' % direction) + + newCenter = self.camera.intrinsic.transformPoint( + ndcCenter, direct=False, perspectiveDivide=True) + + self.camera.move(direction, numpy.linalg.norm(newCenter - center)) + + def windowToNdc(self, winX, winY, checkInside=True): + """Convert position from window to normalized device coordinates. + + If window coordinates are int, they are moved half a pixel + to be positioned at the center of pixel. + + :param winX: X window coord, origin left. + :param winY: Y window coord, origin top. + :param bool checkInside: If True, returns None if position is + outside viewport. + :return: (x, y) Normalize device coordinates in [-1, 1] or None. + Origin center, x to the right, y goes upward. + """ + ox, oy = self._origin + width, height = self.size + + # If int, move it to the center of pixel + if isinstance(winX, int): + winX += 0.5 + if isinstance(winY, int): + winY += 0.5 + + x, y = winX - ox, winY - oy + + if checkInside and (x < 0. or x > width or y < 0. or y > height): + return None # Out of viewport + + ndcx = 2. * x / float(width) - 1. + ndcy = 1. - 2. * y / float(height) + return ndcx, ndcy + + def ndcToWindow(self, ndcX, ndcY, checkInside=True): + """Convert position from normalized device coordinates (NDC) to window. + + :param float ndcX: X NDC coord. + :param float ndcY: Y NDC coord. + :param bool checkInside: If True, returns None if position is + outside viewport. + :return: (x, y) window coordinates or None. + Origin top-left, x to the right, y goes downward. + """ + if (checkInside and + (ndcX < -1. or ndcX > 1. or ndcY < -1. or ndcY > 1.)): + return None # Outside viewport + + ox, oy = self._origin + width, height = self.size + + winx = ox + width * 0.5 * (ndcX + 1.) + winy = oy + height * 0.5 * (1. - ndcY) + return winx, winy + + def _pickNdcZGL(self, x, y): + """Retrieve depth from depth buffer and return corresponding NDC Z. + + :param int x: In pixels in window coordinates, origin left. + :param int y: In pixels in window coordinates, origin top. + :return: Normalize device Z coordinate of depth in [-1, 1] + or None if outside viewport. + :rtype: float or None + """ + ox, oy = self._origin + width, height = self.size + + x = int(x) + y = height - int(y) # Invert y coord + + if x < ox or x > ox + width or y < oy or y > oy + height: + # Outside viewport + return None + + # Get depth from depth buffer in [0., 1.] + # Bind used framebuffer to get depth + gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, self.framebuffer) + depth = gl.glReadPixels( + x, y, 1, 1, gl.GL_DEPTH_COMPONENT, gl.GL_FLOAT)[0] + gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, 0) + # This is not GL|ES friendly + + # Z in NDC in [-1., 1.] + return float(depth) * 2. - 1. + + def _getXZYGL(self, x, y): + ndc = self.windowToNdc(x, y) + if ndc is None: + return None # Outside viewport + ndcz = self._pickNdcZGL(x, y) + ndcpos = numpy.array((ndc[0], ndc[1], ndcz, 1.), dtype=numpy.float32) + + camerapos = self.camera.intrinsic.transformPoint( + ndcpos, direct=False, perspectiveDivide=True) + + scenepos = self.camera.extrinsic.transformPoint(camerapos, + direct=False) + return scenepos[:3] + + def pick(self, x, y): + pass + # ndcX, ndcY = self.windowToNdc(x, y) + # ndcNearPt = ndcX, ndcY, -1. + # ndcFarPT = ndcX, ndcY, 1. diff --git a/silx/gui/plot3d/scene/window.py b/silx/gui/plot3d/scene/window.py new file mode 100644 index 0000000..3c63c7a --- /dev/null +++ b/silx/gui/plot3d/scene/window.py @@ -0,0 +1,421 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2015-2017 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""This module provides a class for Viewports rendering on the screen. + +The :class:`Window` renders a list of Viewports in the current framebuffer. +The rendering can be performed in an off-screen framebuffer that is only +updated when the scene has changed and not each time Qt is requiring a repaint. + +The :class:`Context` and :class:`ContextGL2` represent the operating system +OpenGL context and handle OpenGL resources. +""" + +from __future__ import absolute_import, division, unicode_literals + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "10/01/2017" + + +import weakref +import numpy + +from ..._glutils import gl +from ... import _glutils + +from . import event + + +class Context(object): + """Correspond to an operating system OpenGL context. + + User should NEVER use an instance of this class beyond the method + it is passed to as an argument (i.e., do not keep a reference to it). + + :param glContextHandle: System specific OpenGL context handle. + """ + + def __init__(self, glContextHandle): + self._context = glContextHandle + self._isCurrent = False + self._devicePixelRatio = 1.0 + + @property + def isCurrent(self): + """Whether this OpenGL context is the current one or not.""" + return self._isCurrent + + def setCurrent(self, isCurrent=True): + """Set the state of the OpenGL context to reflect OpenGL state. + + This should not be called from the scene graph, only in the + wrapper that handle the OpenGL context to reflect its state. + + :param bool isCurrent: The state of the system OpenGL context. + """ + self._isCurrent = bool(isCurrent) + + @property + def devicePixelRatio(self): + """Ratio between device and device independent pixels (float) + + This is useful for font rendering. + """ + return self._devicePixelRatio + + @devicePixelRatio.setter + def devicePixelRatio(self, ratio): + assert ratio > 0 + self._devicePixelRatio = float(ratio) + + def __enter__(self): + self.setCurrent(True) + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.setCurrent(False) + + @property + def glContext(self): + """The handle to the OpenGL context provided by the system.""" + return self._context + + def cleanGLGarbage(self): + """This is releasing OpenGL resource that are no longer used.""" + pass + + +class ContextGL2(Context): + """Handle a system GL2 context. + + User should NEVER use an instance of this class beyond the method + it is passed to as an argument (i.e., do not keep a reference to it). + + :param glContextHandle: System specific OpenGL context handle. + """ + def __init__(self, glContextHandle): + super(ContextGL2, self).__init__(glContextHandle) + + self._programs = {} # GL programs already compiled + self._vbos = {} # GL Vbos already set + self._vboGarbage = [] # Vbos waiting to be discarded + + # programs + + def prog(self, vertexShaderSrc, fragmentShaderSrc): + """Cache program within context. + + WARNING: No clean-up. + """ + assert self.isCurrent + key = vertexShaderSrc, fragmentShaderSrc + prog = self._programs.get(key, None) + if prog is None: + prog = _glutils.Program(vertexShaderSrc, fragmentShaderSrc) + self._programs[key] = prog + return prog + + # VBOs + + def makeVbo(self, data=None, sizeInBytes=None, + usage=None, target=None): + """Create a VBO in this context with the data. + + Current limitations: + + - One array per VBO + - Do not support sharing VertexBuffer across VboAttrib + + Automatically discards the VBO when the returned + :class:`VertexBuffer` istance is deleted. + + :param numpy.ndarray data: 2D array of data to store in VBO or None. + :param int sizeInBytes: Size of the VBO or None. + It should be <= data.nbytes if both are given. + :param usage: OpenGL usage define in VertexBuffer._USAGES. + :param target: OpenGL target in VertexBuffer._TARGETS. + :return: The VertexBuffer created in this context. + """ + assert self.isCurrent + vbo = _glutils.VertexBuffer(data, sizeInBytes, usage, target) + vboref = weakref.ref(vbo, self._deadVbo) + # weakref is hashable as far as target is + self._vbos[vboref] = vbo.name + return vbo + + def makeVboAttrib(self, data, usage=None, target=None): + """Create a VBO from data and returns the associated VBOAttrib. + + Automatically discards the VBO when the returned + :class:`VBOAttrib` istance is deleted. + + :param numpy.ndarray data: 2D array of data to store in VBO or None. + :param usage: OpenGL usage define in VertexBuffer._USAGES. + :param target: OpenGL target in VertexBuffer._TARGETS. + :returns: A VBOAttrib instance created in this context. + """ + assert self.isCurrent + vbo = self.makeVbo(data, usage=usage, target=target) + + assert len(data.shape) <= 2 + dimension = 1 if len(data.shape) == 1 else data.shape[1] + + return _glutils.VertexBufferAttrib( + vbo, + type_=_glutils.numpyToGLType(data.dtype), + size=data.shape[0], + dimension=dimension, + offset=0, + stride=0) + + def _deadVbo(self, vboRef): + """Callback handling dead VBOAttribs.""" + vboid = self._vbos.pop(vboRef) + if self.isCurrent: + # Direct delete if context is active + gl.glDeleteBuffers(vboid) + else: + # Deferred VBO delete if context is not active + self._vboGarbage.append(vboid) + + def cleanGLGarbage(self): + """Delete OpenGL resources that are pending for destruction. + + This requires the associated OpenGL context to be active. + This is meant to be called before rendering. + """ + assert self.isCurrent + if self._vboGarbage: + vboids = self._vboGarbage + gl.glDeleteBuffers(vboids) + self._vboGarbage = [] + + +class Window(event.Notifier): + """OpenGL Framebuffer where to render viewports + + :param str mode: Rendering mode to use: + + - 'direct' to render everything for each render call + - 'framebuffer' to cache viewport rendering in a texture and + update the texture only when needed. + """ + + _position = numpy.array(((-1., -1., 0., 0.), + (1., -1., 1., 0.), + (-1., 1., 0., 1.), + (1., 1., 1., 1.)), + dtype=numpy.float32) + + _shaders = (""" + attribute vec4 position; + varying vec2 textureCoord; + + void main(void) { + gl_Position = vec4(position.x, position.y, 0., 1.); + textureCoord = position.zw; + } + """, + """ + uniform sampler2D texture; + varying vec2 textureCoord; + + void main(void) { + gl_FragColor = texture2D(texture, textureCoord); + gl_FragColor.a = 1.0; + } + """) + + def __init__(self, mode='framebuffer'): + super(Window, self).__init__() + self._dirty = True + self._size = 0, 0 + self._contexts = {} # To map system GL context id to Context objects + self._viewports = event.NotifierList() + self._viewports.addListener(self._updated) + self._framebufferid = 0 + self._framebuffers = {} # Cache of framebuffers + + assert mode in ('direct', 'framebuffer') + self._isframebuffer = mode == 'framebuffer' + + @property + def dirty(self): + """True if this object or any attached viewports is dirty.""" + for viewport in self._viewports: + if viewport.dirty: + return True + return self._dirty + + @property + def size(self): + """Size (width, height) of the window in pixels""" + return self._size + + @size.setter + def size(self, size): + w, h = size + size = int(w), int(h) + if size != self._size: + self._size = size + self._dirty = True + self.notify() + + @property + def shape(self): + """Shape (height, width) of the window in pixels. + + This is a convenient wrapper to the reverse of size. + """ + return self._size[1], self._size[0] + + @shape.setter + def shape(self, shape): + self.size = shape[1], shape[0] + + @property + def viewports(self): + """List of viewports to render in the corresponding framebuffer""" + return self._viewports + + @viewports.setter + def viewports(self, iterable): + self._viewports.removeListener(self._updated) + self._viewports = event.NotifierList(iterable) + self._viewports.addListener(self._updated) + self._updated(self) + + def _updated(self, source, *args, **kwargs): + self._dirty = True + self.notify(*args, **kwargs) + + framebufferid = property(lambda self: self._framebufferid, + doc="Framebuffer ID used to perform rendering") + + def grab(self, glcontext): + """Returns the raster of the scene as an RGB numpy array + + :returns: OpenGL scene RGB bitmap + :rtype: numpy.ndarray of uint8 of dimension (height, width, 3) + """ + height, width = self.shape + image = numpy.empty((height, width, 3), dtype=numpy.uint8) + + previousFramebuffer = gl.glGetInteger(gl.GL_FRAMEBUFFER_BINDING) + gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, self.framebufferid) + gl.glPixelStorei(gl.GL_PACK_ALIGNMENT, 1) + gl.glReadPixels( + 0, 0, width, height, gl.GL_RGB, gl.GL_UNSIGNED_BYTE, image) + gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, previousFramebuffer) + + # glReadPixels gives bottom to top, + # while images are stored as top to bottom + image = numpy.flipud(image) + + return numpy.array(image, copy=False, order='C') + + def render(self, glcontext, devicePixelRatio): + """Perform the rendering of attached viewports + + :param glcontext: System identifier of the OpenGL context + :param float devicePixelRatio: + Ratio between device and device-independent pixels + """ + if glcontext not in self._contexts: + self._contexts[glcontext] = ContextGL2(glcontext) # New context + + with self._contexts[glcontext] as context: + context.devicePixelRatio = devicePixelRatio + if self._isframebuffer: + self._renderWithOffscreenFramebuffer(context) + else: + self._renderDirect(context) + + self._dirty = False + + def _renderDirect(self, context): + """Perform the direct rendering of attached viewports + + :param Context context: Object wrapping OpenGL context + """ + for viewport in self._viewports: + viewport.framebuffer = self.framebufferid + viewport.render(context) + viewport.resetDirty() + + def _renderWithOffscreenFramebuffer(self, context): + """Renders viewports in a texture and render this texture on screen. + + The texture is updated only if viewport or size has changed. + + :param ContextGL2 context: Object wrappign OpenGL context + """ + if self.dirty or context not in self._framebuffers: + # Need to redraw framebuffer content + + if (context not in self._framebuffers or + self._framebuffers[context].shape != self.shape): + # Need to rebuild framebuffer + + if context in self._framebuffers: + self._framebuffers[context].discard() + + fbo = _glutils.FramebufferTexture(gl.GL_RGBA, + shape=self.shape, + minFilter=gl.GL_NEAREST, + magFilter=gl.GL_NEAREST, + wrap=gl.GL_CLAMP_TO_EDGE) + self._framebuffers[context] = fbo + self._framebufferid = fbo.name + + # Render in framebuffer + with self._framebuffers[context]: + self._renderDirect(context) + + # Render framebuffer texture to screen + fbo = self._framebuffers[context] + height, width = fbo.shape + + program = context.prog(*self._shaders) + program.use() + + gl.glViewport(0, 0, width, height) + gl.glDisable(gl.GL_BLEND) + gl.glDisable(gl.GL_DEPTH_TEST) + gl.glDisable(gl.GL_SCISSOR_TEST) + # gl.glScissor(0, 0, width, height) + gl.glClearColor(0., 0., 0., 0.) + gl.glClear(gl.GL_COLOR_BUFFER_BIT) + gl.glUniform1i(program.uniforms['texture'], fbo.texture.texUnit) + gl.glEnableVertexAttribArray(program.attributes['position']) + gl.glVertexAttribPointer(program.attributes['position'], + 4, + gl.GL_FLOAT, + gl.GL_FALSE, + 0, + self._position) + fbo.texture.bind() + gl.glDrawArrays(gl.GL_TRIANGLE_STRIP, 0, len(self._position)) + gl.glBindTexture(gl.GL_TEXTURE_2D, 0) |