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 | 258 | ||||
-rw-r--r-- | silx/gui/plot3d/scene/camera.py | 353 | ||||
-rw-r--r-- | silx/gui/plot3d/scene/core.py | 343 | ||||
-rw-r--r-- | silx/gui/plot3d/scene/cutplane.py | 390 | ||||
-rw-r--r-- | silx/gui/plot3d/scene/event.py | 225 | ||||
-rw-r--r-- | silx/gui/plot3d/scene/function.py | 654 | ||||
-rw-r--r-- | silx/gui/plot3d/scene/interaction.py | 701 | ||||
-rw-r--r-- | silx/gui/plot3d/scene/primitives.py | 2524 | ||||
-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 | 535 | ||||
-rw-r--r-- | silx/gui/plot3d/scene/transform.py | 1027 | ||||
-rw-r--r-- | silx/gui/plot3d/scene/utils.py | 662 | ||||
-rw-r--r-- | silx/gui/plot3d/scene/viewport.py | 603 | ||||
-rw-r--r-- | silx/gui/plot3d/scene/window.py | 430 |
17 files changed, 0 insertions, 9148 deletions
diff --git a/silx/gui/plot3d/scene/__init__.py b/silx/gui/plot3d/scene/__init__.py deleted file mode 100644 index 9671725..0000000 --- a/silx/gui/plot3d/scene/__init__.py +++ /dev/null @@ -1,34 +0,0 @@ -# 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 Base, 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 deleted file mode 100644 index e35e5e1..0000000 --- a/silx/gui/plot3d/scene/axes.py +++ /dev/null @@ -1,258 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2016-2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -"""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. - - def _updateBoxAndAxes(self): - """Update bbox and axes position and size according to children. - - Overridden from GroupBBox - """ - super(LabelledAxes, self)._updateBoxAndAxes() - - bounds = self._group.bounds(dataBounds=True) - if bounds is not None: - tx, ty, tz = (bounds[1] - bounds[0]) / 2. - else: - tx, ty, tz = 0.5, 0.5, 0.5 - - self._xlabel.transforms[-1].tx = tx - self._ylabel.transforms[-1].ty = ty - self._zlabel.transforms[-1].tz = tz - - @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 deleted file mode 100644 index 90de7ed..0000000 --- a/silx/gui/plot3d/scene/camera.py +++ /dev/null @@ -1,353 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2015-2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# 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 (width, height). - :type size: 2-tuple of float - :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 radius == 0.: # bounds are all collapsed - radius = 1. - - 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 deleted file mode 100644 index 43838fe..0000000 --- a/silx/gui/plot3d/scene/core.py +++ /dev/null @@ -1,343 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2015-2019 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# 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 root(self): - """The root node of the scene. - - If attached to a :class:`Viewport`, this is the item right under it - """ - path = self.path - return path[1] if isinstance(path[0], Viewport) else path[0] - - @property - def objectToNDCTransform(self): - """Transform from object to normalized device coordinates. - - 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 deleted file mode 100644 index 88147df..0000000 --- a/silx/gui/plot3d/scene/cutplane.py +++ /dev/null @@ -1,390 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2016-2020 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__ = "11/01/2018" - -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; - uniform vec3 texCoordsOffset; - - 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 + texCoordsOffset; - 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 - $sceneDecl - $lightingFunction - - void main(void) - { - $scenePreCall(vCameraPosition); - - float value = texture3D(data, vTexCoords).r; - vec4 color = $colormapCall(value); - color.a *= alpha; - - gl_FragColor = $lightingCall(color, vPosition, vNormal); - - $scenePostCall(vCameraPosition); - } - """)) - - 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 - self.textureOffset = 0., 0., 0. - """Offset to add to texture coordinates""" - - 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( - sceneDecl=ctx.fragDecl, - scenePreCall=ctx.fragCallPre, - scenePostCall=ctx.fragCallPost, - 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.glUniform3f(program.uniforms['texCoordsOffset'], *self.textureOffset) - - gl.glUniform1i(program.uniforms['data'], self._texture.texUnit) - - ctx.setupProgram(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 - self.notify() - - def prepareGL2(self, ctx): - if self.isValid: - - contourVertices = self.contourVertices - - 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._mesh.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) - - needTextureOffset = False - if self.interpolation == 'nearest': - # If cut plane is co-linear with array bin edges add texture offset - planePt = self.plane.point - 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(planePt[index]) == planePt[index]): - needTextureOffset = True - break - - if needTextureOffset: - self._mesh.textureOffset = self.plane.normal * 1e-6 - else: - self._mesh.textureOffset = 0., 0., 0. - - 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 = Box.getVertices(copy=True) - boxVertices = bounds[0] + boxVertices * (bounds[1] - bounds[0]) - lineIndices = Box.getLineIndices(copy=False) - 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 deleted file mode 100644 index 98f8f8b..0000000 --- a/silx/gui/plot3d/scene/event.py +++ /dev/null @@ -1,225 +0,0 @@ -# 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__ = "17/07/2018" - - -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.warning('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 deleted file mode 100644 index 2deb785..0000000 --- a/silx/gui/plot3d/scene/function.py +++ /dev/null @@ -1,654 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2015-2020 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__ = "17/07/2018" - - -import contextlib -import logging -import string -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 Fog(event.Notifier, ProgramFunction): - """Linear fog over the whole scene content. - - The background of the viewport is used as fog color, - otherwise it defaults to white. - """ - # TODO: add more controls (set fog range), add more fog modes - - _fragDecl = """ - /* (1/(far - near) or 0, near) z in [0 (camera), -inf[ */ - uniform vec2 fogExtentInfo; - - /* Color to use as fog color */ - uniform vec3 fogColor; - - vec4 fog(vec4 color, vec4 cameraPosition) { - /* d = (pos - near) / (far - near) */ - float distance = fogExtentInfo.x * (cameraPosition.z/cameraPosition.w - fogExtentInfo.y); - float fogFactor = clamp(distance, 0.0, 1.0); - vec3 rgb = mix(color.rgb, fogColor, fogFactor); - return vec4(rgb.r, rgb.g, rgb.b, color.a); - } - """ - - _fragDeclNoop = """ - vec4 fog(vec4 color, vec4 cameraPosition) { - return color; - } - """ - - def __init__(self): - super(Fog, self).__init__() - self._isOn = True - - @property - def isOn(self): - """True to enable fog, False to disable (bool)""" - return self._isOn - - @isOn.setter - def isOn(self, isOn): - isOn = bool(isOn) - if self._isOn != isOn: - self._isOn = bool(isOn) - self.notify() - - @property - def fragDecl(self): - return self._fragDecl if self.isOn else self._fragDeclNoop - - @property - def fragCall(self): - return "fog" - - @staticmethod - def _zExtentCamera(viewport): - """Return (far, near) planes Z in camera coordinates. - - :param Viewport viewport: - :return: (far, near) position in camera coords (from 0 to -inf) - """ - # Provide scene z extent in camera coords - bounds = viewport.camera.extrinsic.transformBounds( - viewport.scene.bounds(transformed=True, dataBounds=True)) - return bounds[:, 2] - - def setupProgram(self, context, program): - if not self.isOn: - return - - far, near = context.cache(key='zExtentCamera', - factory=self._zExtentCamera, - viewport=context.viewport) - extent = far - near - gl.glUniform2f(program.uniforms['fogExtentInfo'], - 0.9/extent if extent != 0. else 0., - near) - - # Use background color as fog color - bgColor = context.viewport.background - if bgColor is None: - bgColor = 1., 1., 1. - gl.glUniform3f(program.uniforms['fogColor'], *bgColor[:3]) - - -class ClippingPlane(ProgramFunction): - """Description of a clipping plane and rendering. - - 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): - - _declTemplate = string.Template(""" - uniform sampler2D cmap_texture; - uniform int cmap_normalization; - uniform float cmap_parameter; - uniform float cmap_min; - uniform float cmap_oneOverRange; - uniform vec4 nancolor; - - const float oneOverLog10 = 0.43429448190325176; - - vec4 colormap(float value) { - float data = value; /* Keep original input value for isnan test */ - - if (cmap_normalization == 1) { /* Log10 mapping */ - if (value > 0.0) { - value = clamp(cmap_oneOverRange * - (oneOverLog10 * log(value) - cmap_min), - 0.0, 1.0); - } else { - value = 0.0; - } - } else if (cmap_normalization == 2) { /* Sqrt mapping */ - if (value > 0.0) { - value = clamp(cmap_oneOverRange * (sqrt(value) - cmap_min), - 0.0, 1.0); - } else { - value = 0.0; - } - } else if (cmap_normalization == 3) { /*Gamma correction mapping*/ - value = pow( - clamp(cmap_oneOverRange * (value - cmap_min), 0.0, 1.0), - cmap_parameter); - } else if (cmap_normalization == 4) { /* arcsinh mapping */ - /* asinh = log(x + sqrt(x*x + 1) for compatibility with GLSL 1.20 */ - value = clamp(cmap_oneOverRange * (log(value + sqrt(value*value + 1.0)) - cmap_min), 0.0, 1.0); - } else { /* Linear mapping */ - value = clamp(cmap_oneOverRange * (value - cmap_min), 0.0, 1.0); - } - - $discard - - vec4 color; - if (data != data) { /* isnan alternative for compatibility with GLSL 1.20 */ - color = nancolor; - } else { - color = texture2D(cmap_texture, vec2(value, 0.5)); - } - return color; - } - """) - - _discardCode = """ - if (value == 0.) { - discard; - } - """ - - call = "colormap" - - NORMS = 'linear', 'log', 'sqrt', 'gamma', 'arcsinh' - """Tuple of supported normalizations.""" - - _COLORMAP_TEXTURE_UNIT = 1 - """Texture unit to use for storing the colormap""" - - def __init__(self, colormap=None, norm='linear', gamma=0., 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: see :attr:`NORMS`. - :param float gamma: Gamma normalization parameter - :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 = None - self._norm = 'linear' - self._gamma = -1. - self._range = 1., 10. - self._displayValuesBelowMin = True - self._nancolor = numpy.array((1., 1., 1., 0.), dtype=numpy.float32) - - self._texture = None - self._textureToDiscard = None - - 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 values through properties to perform asserts and updates - self.colormap = colormap - self.norm = norm - self.gamma = gamma - self.range_ = range_ - - @property - def decl(self): - """Source code of the function declaration""" - return self._declTemplate.substitute( - discard="" if self.displayValuesBelowMin else self._discardCode) - - @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 - - if self._texture is not None and self._texture.name is not None: - self._textureToDiscard = self._texture - - data = numpy.empty( - (16, self._colormap.shape[0], self._colormap.shape[1]), - dtype=self._colormap.dtype) - data[:] = self._colormap - - format_ = gl.GL_RGBA if data.shape[-1] == 4 else gl.GL_RGB - - self._texture = _glutils.Texture( - format_, data, format_, - texUnit=self._COLORMAP_TEXTURE_UNIT, - minFilter=gl.GL_NEAREST, - magFilter=gl.GL_NEAREST, - wrap=gl.GL_CLAMP_TO_EDGE) - - self.notify() - - @property - def nancolor(self): - """RGBA color to use for Not-A-Number values as 4 float in [0., 1.]""" - return self._nancolor - - @nancolor.setter - def nancolor(self, color): - color = numpy.clip(numpy.array(color, dtype=numpy.float32), 0., 1.) - assert color.ndim == 1 - assert len(color) == 4 - if not numpy.array_equal(self._nancolor, color): - self._nancolor = color - self.notify() - - @property - def norm(self): - """Normalization to use for colormap mapping. - - One of 'linear' (the default), 'log' for log10 mapping or 'sqrt'. - Invalid values (e.g., negative values with 'log' or 'sqrt') are mapped to 0. - """ - return self._norm - - @norm.setter - def norm(self, norm): - if norm != self._norm: - assert norm in self.NORMS - self._norm = norm - if norm in ('log', 'sqrt'): - self.range_ = self.range_ # To test for positive range_ - self.notify() - - @property - def gamma(self): - """Gamma correction normalization parameter (float >= 0.)""" - return self._gamma - - @gamma.setter - def gamma(self, gamma): - if gamma != self._gamma: - assert gamma >= 0. - self._gamma = gamma - 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.warning( - "Log normalization and negative range: updating range.") - minPos = numpy.finfo(numpy.float32).tiny - range_ = max(range_[0], minPos), max(range_[1], minPos) - elif self.norm == 'sqrt' and (range_[0] < 0. or range_[1] < 0.): - _logger.warning( - "Sqrt normalization and negative range: updating range.") - range_ = max(range_[0], 0.), max(range_[1], 0.) - - if range_ != self._range: - self._range = range_ - self.notify() - - @property - def displayValuesBelowMin(self): - """True to display values below colormap min, False to discard them. - """ - return self._displayValuesBelowMin - - @displayValuesBelowMin.setter - def displayValuesBelowMin(self, displayValuesBelowMin): - displayValuesBelowMin = bool(displayValuesBelowMin) - if self._displayValuesBelowMin != displayValuesBelowMin: - self._displayValuesBelowMin = displayValuesBelowMin - 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 - - self._texture.bind() - - gl.glUniform1i(program.uniforms['cmap_texture'], - self._texture.texUnit) - - min_, max_ = self.range_ - param = 0. - if self._norm == 'log': - min_, max_ = numpy.log10(min_), numpy.log10(max_) - normID = 1 - elif self._norm == 'sqrt': - min_, max_ = numpy.sqrt(min_), numpy.sqrt(max_) - normID = 2 - elif self._norm == 'gamma': - # Keep min_, max_ as is - param = self._gamma - normID = 3 - elif self._norm == 'arcsinh': - min_, max_ = numpy.arcsinh(min_), numpy.arcsinh(max_) - normID = 4 - else: # Linear - normID = 0 - - gl.glUniform1i(program.uniforms['cmap_normalization'], normID) - gl.glUniform1f(program.uniforms['cmap_parameter'], param) - gl.glUniform1f(program.uniforms['cmap_min'], min_) - gl.glUniform1f(program.uniforms['cmap_oneOverRange'], - (1. / (max_ - min_)) if max_ != min_ else 0.) - gl.glUniform4f(program.uniforms['nancolor'], *self._nancolor) - - def prepareGL2(self, context): - if self._textureToDiscard is not None: - self._textureToDiscard.discard() - self._textureToDiscard = None - - self._texture.prepare() diff --git a/silx/gui/plot3d/scene/interaction.py b/silx/gui/plot3d/scene/interaction.py deleted file mode 100644 index 14a54dc..0000000 --- a/silx/gui/plot3d/scene/interaction.py +++ /dev/null @@ -1,701 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2015-2019 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# 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 import qt -from silx.gui.plot.Interaction import \ - StateMachine, State, LEFT_BTN, RIGHT_BTN # , MIDDLE_BTN - -from . import transform - - -_logger = logging.getLogger(__name__) - - -class ClickOrDrag(StateMachine): - """Click or drag interaction for a given button. - - """ - #TODO: merge this class with silx.gui.plot.Interaction.ClickOrDrag - - 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 - - -class CameraSelectRotate(ClickOrDrag): - """Camera rotation using an arcball-like interaction.""" - - def __init__(self, viewport, orbitAroundCenter=True, button=RIGHT_BTN, - selectCB=None): - self._viewport = viewport - self._orbitAroundCenter = orbitAroundCenter - self._selectCB = selectCB - self._reset() - super(CameraSelectRotate, self).__init__(button) - - def _reset(self): - self._origin, self._center = None, None - self._startExtrinsic = None - - 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): - 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() - - -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 - - -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 - - -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.currentEventHandler: - requestFocus = eventHandler.handleEvent('press', x, y, btn) - if requestFocus: - self.goto('focus', eventHandler, btn) - break - - def _processEvent(self, *args): - for eventHandler in self.machine.currentEventHandler: - 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=(), ctrlEventHandlers=None): - self.defaultEventHandlers = eventHandlers - self.ctrlEventHandlers = ctrlEventHandlers - self.currentEventHandler = self.defaultEventHandlers - - states = { - 'idle': FocusManager.Idle, - 'focus': FocusManager.Focus - } - super(FocusManager, self).__init__(states, 'idle') - - def onKeyPress(self, key): - if key == qt.Qt.Key_Control and self.ctrlEventHandlers is not None: - self.currentEventHandler = self.ctrlEventHandlers - - def onKeyRelease(self, key): - if key == qt.Qt.Key_Control: - self.currentEventHandler = self.defaultEventHandlers - - def cancel(self): - for handler in self.currentEventHandler: - handler.cancel() - - -class RotateCameraControl(FocusManager): - """Combine wheel and rotate state machine for left button - and pan when ctrl is pressed - """ - def __init__(self, viewport, - orbitAroundCenter=False, - mode='center', scaleTransform=None, - selectCB=None): - handlers = (CameraWheel(viewport, mode, scaleTransform), - CameraSelectRotate( - viewport, orbitAroundCenter, LEFT_BTN, selectCB)) - ctrlHandlers = (CameraWheel(viewport, mode, scaleTransform), - CameraSelectPan(viewport, LEFT_BTN, selectCB)) - super(RotateCameraControl, self).__init__(handlers, ctrlHandlers) - - -class PanCameraControl(FocusManager): - """Combine wheel, selectPan and rotate state machine for left button - and rotate when ctrl is pressed""" - def __init__(self, viewport, - orbitAroundCenter=False, - mode='center', scaleTransform=None, - selectCB=None): - handlers = (CameraWheel(viewport, mode, scaleTransform), - CameraSelectPan(viewport, LEFT_BTN, selectCB)) - ctrlHandlers = (CameraWheel(viewport, mode, scaleTransform), - CameraSelectRotate( - viewport, orbitAroundCenter, LEFT_BTN, selectCB)) - super(PanCameraControl, self).__init__(handlers, ctrlHandlers) - - -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), - CameraSelectRotate( - viewport, orbitAroundCenter, RIGHT_BTN, selectCB)) - super(CameraControl, self).__init__(handlers) - - -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() - - -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 - - -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), - CameraSelectRotate(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', - orbitAroundCenter=False, - scaleTransform=None): - handlers = (CameraWheel(viewport, mode, scaleTransform), - PlanePan(viewport, plane, LEFT_BTN)) - ctrlHandlers = (CameraWheel(viewport, mode, scaleTransform), - CameraSelectRotate( - viewport, orbitAroundCenter, LEFT_BTN)) - super(PanPlaneZoomOnWheelControl, self).__init__(handlers, ctrlHandlers) diff --git a/silx/gui/plot3d/scene/primitives.py b/silx/gui/plot3d/scene/primitives.py deleted file mode 100644 index 7f35c3c..0000000 --- a/silx/gui/plot3d/scene/primitives.py +++ /dev/null @@ -1,2524 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2015-2021 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__ = "24/04/2018" - -try: - from collections import abc -except ImportError: # Python2 support - import collections as abc -import ctypes -from functools import reduce -import logging -import string - -import numpy - -from silx.gui.colors import rgba - -from ... import _glutils -from ..._glutils import gl - -from . import event -from . import core -from . import transform -from . import utils -from .function import Colormap - -_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 str attrib0: Name of the attribute that MUST be an array. - :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, - attrib0='position', - **attributes): - super(Geometry, self).__init__() - - self._attrib0 = str(attrib0) - - self._vbos = {} # Store current vbos - self._unsyncAttributes = [] # Store attributes to copy to vbos - self.__bounds = None # Cache object's bounds - # Attribute names defining the object bounds - self.__boundsAttributeNames = (self._attrib0,) - - 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 - - if nbvertices != 0: - assert nbvertices >= mincheck - if modulocheck != 0: - assert (nbvertices % modulocheck) == 0 - - @property - def drawMode(self): - """Kind of primitive to render, in :attr:`_MODES` (str)""" - return self._mode - - @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, abc.Iterable): - array = (array, ) - - # Makes sure it is an array - array = numpy.array(array, copy=False) - - dtype = None - if array.dtype.kind == 'f' and array.dtype.itemsize != 4: - # Cast to float32 - _logger.info('Cast array to float32') - dtype = numpy.float32 - elif array.dtype.itemsize > 4: - # Cast (u)int64 to (u)int32 - if array.dtype.kind == 'i': - _logger.info('Cast array to int32') - dtype = numpy.int32 - elif array.dtype.kind == 'u': - _logger.info('Cast array to uint32') - dtype = numpy.uint32 - - 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 - - @property - def attrib0(self): - """Attribute name that MUST be an array (str)""" - return self._attrib0 - - 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.debug('Not checking attribute %s dimensions', name) - else: - checks = self._ATTR_INFO[name] - - if (array.ndim == 1 and checks['lastDim'] == (1,) and - len(array) > 1): - array = array.reshape((len(array), 1)) - - # Checks - assert array.ndim in checks['dims'], "Attr %s" % name - assert array.shape[-1] in checks['lastDim'], "Attr %s" % name - - # Makes sure attrib0 is considered as an array of values - if name == self.attrib0 and array.ndim == 1: - array.shape = 1, -1 - - # Check length against another attribute array - # Causes problems when updating - # nbVertices = self.nbVertices - # if array.ndim == 2 and nbVertices is not None: - # assert len(array) == nbVertices - - self._attributes[name] = array - if array.ndim == 2: # Store this in a VBO - self._unsyncAttributes.append(name) - - if name in self.boundsAttributeNames: # 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 array.ndim == 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 - self.notify() - - 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) - - @property - def boundsAttributeNames(self): - """Tuple of attribute names defining the bounds of the object. - - Attributes name are taken in the given order to compute the - (x, y, z) the bounding box, e.g.:: - - geometry.boundsAttributeNames = 'position' - geometry.boundsAttributeNames = 'x', 'y', 'z' - """ - return self.__boundsAttributeNames - - @boundsAttributeNames.setter - def boundsAttributeNames(self, names): - self.__boundsAttributeNames = tuple(str(name) for name in names) - self.__bounds = None - self.notify() - - def _bounds(self, dataBounds=False): - if self.__bounds is None: - if len(self.boundsAttributeNames) == 0: - return None # No bounds - - self.__bounds = numpy.zeros((2, 3), dtype=numpy.float32) - - # Coordinates defined in one or more attributes - index = 0 - for name in self.boundsAttributeNames: - if index == 3: - _logger.error("Too many attributes defining bounds") - break - - attribute = self._attributes[name] - assert attribute.ndim in (1, 2) - if attribute.ndim == 1: # Single value - min_ = attribute - max_ = attribute - elif len(attribute) > 0: # Array of values, compute min/max - min_ = numpy.nanmin(attribute, axis=0) - max_ = numpy.nanmax(attribute, axis=0) - else: - min_, max_ = numpy.zeros((2, attribute.shape[1]), dtype=numpy.float32) - - toCopy = min(len(min_), 3-index) - if toCopy != len(min_): - _logger.error("Attribute defining bounds" - " has too many dimensions") - - self.__bounds[0, index:index+toCopy] = min_[:toCopy] - self.__bounds[1, index:index+toCopy] = max_[:toCopy] - - index += toCopy - - 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; - - $sceneDecl - $lightingFunction - - void main(void) - { - $scenePreCall(vCameraPosition); - gl_FragColor = $lightingCall(vColor, vPosition, vNormal); - $scenePostCall(vCameraPosition); - } - """)) - - 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( - sceneDecl=ctx.fragDecl, - scenePreCall=ctx.fragCallPre, - scenePostCall=ctx.fragCallPost, - 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.setupProgram(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; - - $sceneDecl - $lightingFunction - - void main(void) - { - $scenePreCall(vCameraPosition); - - /* Discard off dash fragments */ - float lineDist = distance(vOriginFragCoord, gl_FragCoord.xy); - if (mod(lineDist, dash.x + dash.y) > dash.x) { - discard; - } - gl_FragColor = $lightingCall(vColor, vPosition, vNormal); - - $scenePostCall(vCameraPosition); - } - """)) - - 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( - sceneDecl=context.fragDecl, - scenePreCall=context.fragCallPre, - scenePostCall=context.fragCallPost, - 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.setupProgram(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, 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 = 1., 1., 1. - - @classmethod - def getLineIndices(cls, copy=True): - """Returns 2D array of Box lines indices - - :param copy: True (default) to get a copy, - False to get internal array (Do not modify!) - :rtype: numpy.ndarray - """ - return numpy.array(cls._lineIndices, copy=copy) - - @classmethod - def getVertices(cls, copy=True): - """Returns 2D array of Box corner coordinates. - - :param copy: True (default) to get a copy, - False to get internal array (Do not modify!) - :rtype: numpy.ndarray - """ - return numpy.array(cls._vertices, copy=copy) - - @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.) - self._size = 1., 1., 1. - - @property - def size(self): - """Size of the axes (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.setAttribute( - 'position', - self._vertices * numpy.array(size, dtype=numpy.float32)) - self.notify() - - -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._size = 1., 1., 1. - 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 - - @property - def size(self): - """Size of the axes (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.setAttribute( - 'position', - self._vertices * numpy.array(size, dtype=numpy.float32)) - self.notify() - - -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._strokeVisible = True - - 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 - - @property - def strokeVisible(self): - """Whether surrounding stroke is visible or not (bool).""" - return self._strokeVisible - - @strokeVisible.setter - def strokeVisible(self, visible): - self._strokeVisible = bool(visible) - if self._outline is not None: - self._outline.visible = self._strokeVisible - - # 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 = Box.getVertices(copy=True) - boxVertices = bounds[0] + boxVertices * (bounds[1] - bounds[0]) - lineIndices = Box.getLineIndices(copy=False) - 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._outline.visible = self._strokeVisible - 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) - - -class BoundedGroup(core.Group): - """Group with data bounds""" - - _shape = None # To provide a default value without overriding __init__ - - @property - def shape(self): - """Data shape (depth, height, width) of this group or None""" - return self._shape - - @shape.setter - def shape(self, shape): - if shape is None: - self._shape = None - else: - depth, height, width = shape - self._shape = float(depth), float(height), float(width) - - @property - def size(self): - """Data size (width, height, depth) of this group or None""" - shape = self.shape - if shape is None: - return None - else: - return shape[2], shape[1], shape[0] - - @size.setter - def size(self, size): - if size is None: - self.shape = None - else: - self.shape = size[2], size[1], size[0] - - def _bounds(self, dataBounds=False): - if dataBounds and self.size is not None: - return numpy.array(((0., 0., 0.), self.size), - dtype=numpy.float32) - else: - return super(BoundedGroup, self)._bounds(dataBounds) - - -# Points ###################################################################### - -class _Points(Geometry): - """Base class to render a set of points.""" - - DIAMOND = 'd' - CIRCLE = 'o' - SQUARE = 's' - PLUS = '+' - X_MARKER = 'x' - ASTERISK = '*' - H_LINE = '_' - V_LINE = '|' - - SUPPORTED_MARKERS = (DIAMOND, CIRCLE, SQUARE, PLUS, - X_MARKER, ASTERISK, H_LINE, V_LINE) - """List of supported markers: - - - 'd' diamond - - 'o' circle - - 's' square - - '+' cross - - 'x' x-cross - - '*' asterisk - - '_' horizontal line - - '|' vertical line - """ - - _MARKER_FUNCTIONS = { - DIAMOND: """ - float alphaSymbol(vec2 coord, float size) { - vec2 centerCoord = abs(coord - vec2(0.5, 0.5)); - float f = centerCoord.x + centerCoord.y; - return clamp(size * (0.5 - f), 0.0, 1.0); - } - """, - CIRCLE: """ - float alphaSymbol(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); - } - """, - SQUARE: """ - float alphaSymbol(vec2 coord, float size) { - return 1.0; - } - """, - PLUS: """ - float alphaSymbol(vec2 coord, float size) { - vec2 d = abs(size * (coord - vec2(0.5, 0.5))); - if (min(d.x, d.y) < 0.5) { - return 1.0; - } else { - return 0.0; - } - } - """, - X_MARKER: """ - float alphaSymbol(vec2 coord, float size) { - vec2 pos = floor(size * coord) + 0.5; - vec2 d_x = abs(pos.x + vec2(- pos.y, pos.y - size)); - if (min(d_x.x, d_x.y) <= 0.5) { - return 1.0; - } else { - return 0.0; - } - } - """, - ASTERISK: """ - float alphaSymbol(vec2 coord, float size) { - /* Combining +, x and circle */ - vec2 d_plus = abs(size * (coord - vec2(0.5, 0.5))); - vec2 pos = floor(size * coord) + 0.5; - vec2 d_x = abs(pos.x + vec2(- pos.y, pos.y - size)); - if (min(d_plus.x, d_plus.y) < 0.5) { - return 1.0; - } else if (min(d_x.x, d_x.y) <= 0.5) { - float r = distance(coord, vec2(0.5, 0.5)); - return clamp(size * (0.5 - r), 0.0, 1.0); - } else { - return 0.0; - } - } - """, - H_LINE: """ - float alphaSymbol(vec2 coord, float size) { - float dy = abs(size * (coord.y - 0.5)); - if (dy < 0.5) { - return 1.0; - } else { - return 0.0; - } - } - """, - V_LINE: """ - float alphaSymbol(vec2 coord, float size) { - float dx = abs(size * (coord.x - 0.5)); - if (dx < 0.5) { - return 1.0; - } else { - return 0.0; - } - } - """ - } - - _shaders = (string.Template(""" - #version 120 - - attribute float x; - attribute float y; - attribute float z; - attribute $valueType value; - attribute float size; - - uniform mat4 matrix; - uniform mat4 transformMat; - - varying vec4 vCameraPosition; - varying $valueType vValue; - varying float vSize; - - void main(void) - { - vValue = value; - - vec4 positionVec4 = vec4(x, y, z, 1.0); - gl_Position = matrix * positionVec4; - vCameraPosition = transformMat * positionVec4; - - gl_PointSize = size; - vSize = size; - } - """), - string.Template(""" - #version 120 - - varying vec4 vCameraPosition; - varying float vSize; - varying $valueType vValue; - - $valueToColorDecl - $sceneDecl - $alphaSymbolDecl - - void main(void) - { - $scenePreCall(vCameraPosition); - - float alpha = alphaSymbol(gl_PointCoord, vSize); - - gl_FragColor = $valueToColorCall(vValue); - gl_FragColor.a *= alpha; - if (gl_FragColor.a == 0.0) { - discard; - } - - $scenePostCall(vCameraPosition); - } - """)) - - _ATTR_INFO = { - 'x': {'dims': (1, 2), 'lastDim': (1,)}, - 'y': {'dims': (1, 2), 'lastDim': (1,)}, - 'z': {'dims': (1, 2), 'lastDim': (1,)}, - 'size': {'dims': (1, 2), 'lastDim': (1,)}, - } - - def __init__(self, x, y, z, value, size=1., indices=None): - super(_Points, self).__init__('points', indices, - x=x, - y=y, - z=z, - value=value, - size=size, - attrib0='x') - self.boundsAttributeNames = 'x', 'y', 'z' - self._marker = 'o' - - @property - def marker(self): - """The marker symbol used to display the scatter plot (str) - - See :attr:`SUPPORTED_MARKERS` for the list of supported marker string. - """ - return self._marker - - @marker.setter - def marker(self, marker): - marker = str(marker) - assert marker in self.SUPPORTED_MARKERS - if marker != self._marker: - self._marker = marker - self.notify() - - def _shaderValueDefinition(self): - """Type definition, fragment shader declaration, fragment shader call - """ - raise NotImplementedError( - "This method must be implemented in subclass") - - def _renderGL2PreDrawHook(self, ctx, program): - """Override in subclass to run code before calling gl draw""" - pass - - def renderGL2(self, ctx): - valueType, valueToColorDecl, valueToColorCall = \ - self._shaderValueDefinition() - vertexShader = self._shaders[0].substitute( - valueType=valueType) - fragmentShader = self._shaders[1].substitute( - sceneDecl=ctx.fragDecl, - scenePreCall=ctx.fragCallPre, - scenePostCall=ctx.fragCallPost, - valueType=valueType, - valueToColorDecl=valueToColorDecl, - valueToColorCall=valueToColorCall, - alphaSymbolDecl=self._MARKER_FUNCTIONS[self.marker]) - program = ctx.glCtx.prog(vertexShader, fragmentShader, - attrib0=self.attrib0) - program.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) - - program.setUniformMatrix('matrix', ctx.objectToNDC.matrix) - program.setUniformMatrix('transformMat', - ctx.objectToCamera.matrix, - safe=True) - - ctx.setupProgram(program) - - self._renderGL2PreDrawHook(ctx, program) - - self._draw(program) - - -class Points(_Points): - """A set of data points with an associated value and size.""" - - _ATTR_INFO = _Points._ATTR_INFO.copy() - _ATTR_INFO.update({'value': {'dims': (1, 2), 'lastDim': (1,)}}) - - def __init__(self, x, y, z, value=0., size=1., - indices=None, colormap=None): - super(Points, self).__init__(x=x, - y=y, - z=z, - indices=indices, - size=size, - value=value) - - self._colormap = colormap or Colormap() # Default colormap - self._colormap.addListener(self._cmapChanged) - - @property - def colormap(self): - """The colormap used to render the image""" - return self._colormap - - def _cmapChanged(self, source, *args, **kwargs): - """Broadcast colormap changes""" - self.notify(*args, **kwargs) - - def _shaderValueDefinition(self): - """Type definition, fragment shader declaration, fragment shader call - """ - return 'float', self.colormap.decl, self.colormap.call - - def _renderGL2PreDrawHook(self, ctx, program): - """Set-up colormap before calling gl draw""" - self.colormap.setupProgram(ctx, program) - - -class ColorPoints(_Points): - """A set of points with an associated color and size.""" - - _ATTR_INFO = _Points._ATTR_INFO.copy() - _ATTR_INFO.update({'value': {'dims': (1, 2), 'lastDim': (3, 4)}}) - - def __init__(self, x, y, z, color=(1., 1., 1., 1.), size=1., - indices=None): - super(ColorPoints, self).__init__(x=x, - y=y, - z=z, - indices=indices, - size=size, - value=color) - - def _shaderValueDefinition(self): - """Type definition, fragment shader declaration, fragment shader call - """ - return 'vec4', '', '' - - def setColor(self, color, copy=True): - """Set colors - - :param color: Single RGBA color or - 2D array of color of length number of points - :param bool copy: True to copy colors (default), - False to use provided array (Do not modify!) - """ - self.setAttribute('value', color, copy=copy) - - def getColor(self, copy=True): - """Returns the color or array of colors of the points. - - :param copy: True to get a copy (default), - False to return internal array (Do not modify!) - :return: Color or array of colors - :rtype: numpy.ndarray - """ - return self.getAttribute('value', copy=copy) - - -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 gl_FragColor; - - $sceneDecl - - void main(void) - { - $scenePreCall(vCameraPosition); - - gl_FragColor = vec4(0.5 * vNormValue + 0.5, 0.0, 0.0, 1.0); - - $scenePostCall(vCameraPosition); - } - """)) - - _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, abc.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( - sceneDecl=ctx.fragDecl, - scenePreCall=ctx.fragCallPre, - scenePostCall=ctx.fragCallPost) - 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.setupProgram(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; - - $sceneDecl - $lightingFunction - - void main(void) - { - $scenePreCall(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; - - $scenePostCall(vCameraPosition); - } - """)) - - _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( - sceneDecl=ctx.fragDecl, - scenePreCall=ctx.fragCallPre, - scenePostCall=ctx.fragCallPost, - lightingFunction=ctx.viewport.light.fragmentDef, - lightingCall=ctx.viewport.light.fragmentCall) - prog = ctx.glCtx.prog(self._shaders[0], fragment) - 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.setupProgram(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; - - $sceneDecl - $lightingFunction - - void main(void) - { - $scenePreCall(vCameraPosition); - - gl_FragColor = $lightingCall(vColor, vPosition, vNormal); - - $scenePostCall(vCameraPosition); - } - """)) - - def __init__(self, - positions, - colors, - normals=None, - mode='triangles', - indices=None, - copy=True): - assert mode in self._TRIANGLE_MODES - super(Mesh3D, self).__init__(mode, indices, - position=positions, - normal=normals, - color=colors, - copy=copy) - - 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( - sceneDecl=ctx.fragDecl, - scenePreCall=ctx.fragCallPre, - scenePostCall=ctx.fragCallPost, - 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.setupProgram(prog) - - self._draw(prog) - - if self.culling is not None: - gl.glDisable(gl.GL_CULL_FACE) - - -class ColormapMesh3D(Geometry): - """A 3D mesh with color computed from a colormap""" - - _shaders = (""" - attribute vec3 position; - attribute vec3 normal; - attribute float value; - - uniform mat4 matrix; - uniform mat4 transformMat; - //uniform mat3 matrixInvTranspose; - - varying vec4 vCameraPosition; - varying vec3 vPosition; - varying vec3 vNormal; - varying float vValue; - - void main(void) - { - vCameraPosition = transformMat * vec4(position, 1.0); - //vNormal = matrixInvTranspose * normalize(normal); - vPosition = position; - vNormal = normal; - vValue = value; - gl_Position = matrix * vec4(position, 1.0); - } - """, - string.Template(""" - uniform float alpha; - - varying vec4 vCameraPosition; - varying vec3 vPosition; - varying vec3 vNormal; - varying float vValue; - - $colormapDecl - $sceneDecl - $lightingFunction - - void main(void) - { - $scenePreCall(vCameraPosition); - - vec4 color = $colormapCall(vValue); - gl_FragColor = $lightingCall(color, vPosition, vNormal); - gl_FragColor.a *= alpha; - - $scenePostCall(vCameraPosition); - } - """)) - - def __init__(self, - position, - value, - colormap=None, - normal=None, - mode='triangles', - indices=None, - copy=True): - super(ColormapMesh3D, self).__init__(mode, indices, - position=position, - normal=normal, - value=value, - copy=copy) - - self._alpha = 1.0 - self._lineWidth = 1.0 - self._lineSmooth = True - self._culling = None - self._colormap = colormap or Colormap() # Default colormap - self._colormap.addListener(self._cmapChanged) - - lineWidth = event.notifyProperty('_lineWidth', converter=float, - doc="Width of the line in pixels.") - - lineSmooth = event.notifyProperty( - '_lineSmooth', - converter=bool, - doc="Smooth line rendering enabled (bool, default: True)") - - alpha = event.notifyProperty( - '_alpha', converter=float, - doc="Transparency of the mesh, float in [0, 1]") - - @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() - - @property - def colormap(self): - """The colormap used to render the image""" - return self._colormap - - def _cmapChanged(self, source, *args, **kwargs): - """Broadcast colormap changes""" - self.notify(*args, **kwargs) - - def renderGL2(self, ctx): - if 'normal' in self._attributes: - self._renderGL2(ctx) - else: # Disable lighting - with self.viewport.light.turnOff(): - self._renderGL2(ctx) - - def _renderGL2(self, ctx): - fragment = self._shaders[1].substitute( - sceneDecl=ctx.fragDecl, - scenePreCall=ctx.fragCallPre, - scenePostCall=ctx.fragCallPost, - 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) - ctx.setupProgram(program) - self.colormap.setupProgram(ctx, program) - - 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) - - program.setUniformMatrix('matrix', ctx.objectToNDC.matrix) - program.setUniformMatrix('transformMat', - ctx.objectToCamera.matrix, - safe=True) - gl.glUniform1f(program.uniforms['alpha'], self._alpha) - - if self.drawMode in self._LINE_MODES: - gl.glLineWidth(self.lineWidth) - with gl.enabled(gl.GL_LINE_SMOOTH, self.lineSmooth): - self._draw(program) - else: - self._draw(program) - - if self.culling is not None: - gl.glDisable(gl.GL_CULL_FACE) - - -# ImageData ################################################################## - -class _Image(Geometry): - """Base class for ImageData and ImageRgba""" - - _shaders = (""" - attribute vec2 position; - - uniform mat4 matrix; - uniform mat4 transformMat; - uniform vec2 dataScale; - - varying vec4 vCameraPosition; - varying vec3 vPosition; - varying vec3 vNormal; - varying vec2 vTexCoords; - - void main(void) - { - vec4 positionVec4 = vec4(position, 0.0, 1.0); - vCameraPosition = transformMat * positionVec4; - vPosition = positionVec4.xyz; - vTexCoords = dataScale * position; - gl_Position = matrix * positionVec4; - } - """, - string.Template(""" - varying vec4 vCameraPosition; - varying vec3 vPosition; - varying vec2 vTexCoords; - uniform sampler2D data; - uniform float alpha; - - $imageDecl - $sceneDecl - $lightingFunction - - void main(void) - { - $scenePreCall(vCameraPosition); - - vec4 color = imageColor(data, vTexCoords); - color.a *= alpha; - if (color.a == 0.) { /* Discard fully transparent pixels */ - discard; - } - - vec3 normal = vec3(0.0, 0.0, 1.0); - gl_FragColor = $lightingCall(color, vPosition, normal); - - $scenePostCall(vCameraPosition); - } - """)) - - _UNIT_SQUARE = numpy.array(((0., 0.), (1., 0.), (0., 1.), (1., 1.)), - dtype=numpy.float32) - - def __init__(self, data, copy=True): - super(_Image, self).__init__(mode='triangle_strip', - position=self._UNIT_SQUARE) - - self._texture = None - self._update_texture = True - self._update_texture_filter = False - self._data = None - self.setData(data, copy) - self._alpha = 1. - self._interpolation = 'linear' - - self.isBackfaceVisible = True - - def setData(self, data, copy=True): - assert isinstance(data, numpy.ndarray) - - if copy: - data = numpy.array(data, copy=True) - - self._data = data - self._update_texture = True - # By updating the position rather than always using a unit square - # we benefit from Geometry bounds handling - self.setAttribute('position', self._UNIT_SQUARE * (self._data.shape[1], self._data.shape[0])) - self.notify() - - 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 image, float in [0, 1]""" - return self._alpha - - @alpha.setter - def alpha(self, alpha): - self._alpha = float(alpha) - self.notify() - - def _textureFormat(self): - """Implement this method to provide texture internal format and format - - :return: 2-tuple of gl flags (internalFormat, format) - """ - raise NotImplementedError( - "This method must be implemented in a subclass") - - 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 - if self._data.size == 0: - self._texture = None - else: - internalFormat, format_ = self._textureFormat() - self._texture = _glutils.Texture( - internalFormat, - self._data, - format_, - minFilter=filter_, - magFilter=filter_, - wrap=gl.GL_CLAMP_TO_EDGE) - - if self._update_texture_filter and self._texture is not None: - 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(_Image, self).prepareGL2(ctx) - - def renderGL2(self, ctx): - if self._texture is None: - return # Nothing to render - - with self.viewport.light.turnOff(): - self._renderGL2(ctx) - - def _renderGL2PreDrawHook(self, ctx, program): - """Override in subclass to run code before calling gl draw""" - pass - - def _shaderImageColorDecl(self): - """Returns fragment shader imageColor function declaration""" - raise NotImplementedError( - "This method must be implemented in a subclass") - - def _renderGL2(self, ctx): - fragment = self._shaders[1].substitute( - sceneDecl=ctx.fragDecl, - scenePreCall=ctx.fragCallPre, - scenePostCall=ctx.fragCallPost, - lightingFunction=ctx.viewport.light.fragmentDef, - lightingCall=ctx.viewport.light.fragmentCall, - imageDecl=self._shaderImageColorDecl() - ) - program = ctx.glCtx.prog(self._shaders[0], fragment) - program.use() - - ctx.viewport.light.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 - gl.glUniform2f(program.uniforms['dataScale'], 1./shape[1], 1./shape[0]) - - gl.glUniform1i(program.uniforms['data'], self._texture.texUnit) - - ctx.setupProgram(program) - - self._texture.bind() - - self._renderGL2PreDrawHook(ctx, program) - - self._draw(program) - - if not self.isBackfaceVisible: - gl.glDisable(gl.GL_CULL_FACE) - - -class ImageData(_Image): - """Display a 2x2 data array with a texture.""" - - _imageDecl = string.Template(""" - $colormapDecl - - vec4 imageColor(sampler2D data, vec2 texCoords) { - float value = texture2D(data, texCoords).r; - vec4 color = $colormapCall(value); - return color; - } - """) - - def __init__(self, data, copy=True, colormap=None): - super(ImageData, self).__init__(data, copy=copy) - - self._colormap = colormap or Colormap() # Default colormap - self._colormap.addListener(self._cmapChanged) - - def setData(self, data, copy=True): - data = numpy.array(data, copy=copy, order='C', dtype=numpy.float32) - # TODO support (u)int8|16 - assert data.ndim == 2 - - super(ImageData, self).setData(data, copy=False) - - @property - def colormap(self): - """The colormap used to render the image""" - return self._colormap - - def _cmapChanged(self, source, *args, **kwargs): - """Broadcast colormap changes""" - self.notify(*args, **kwargs) - - def _textureFormat(self): - return gl.GL_R32F, gl.GL_RED - - def _renderGL2PreDrawHook(self, ctx, program): - self.colormap.setupProgram(ctx, program) - - def _shaderImageColorDecl(self): - return self._imageDecl.substitute( - colormapDecl=self.colormap.decl, - colormapCall=self.colormap.call) - - -# ImageRgba ################################################################## - -class ImageRgba(_Image): - """Display a 2x2 RGBA image with a texture. - - Supports images of float in [0, 1] and uint8. - """ - - _imageDecl = """ - vec4 imageColor(sampler2D data, vec2 texCoords) { - vec4 color = texture2D(data, texCoords); - return color; - } - """ - - def __init__(self, data, copy=True): - super(ImageRgba, self).__init__(data, copy=copy) - - def setData(self, data, copy=True): - data = numpy.array(data, copy=copy, order='C') - assert data.ndim == 3 - assert data.shape[2] in (3, 4) - if data.dtype.kind == 'f': - if data.dtype != numpy.dtype(numpy.float32): - _logger.warning("Converting image data to float32") - data = numpy.array(data, dtype=numpy.float32, copy=False) - else: - assert data.dtype == numpy.dtype(numpy.uint8) - - super(ImageRgba, self).setData(data, copy=False) - - def _textureFormat(self): - format_ = gl.GL_RGBA if self._data.shape[2] == 4 else gl.GL_RGB - return format_, format_ - - def _shaderImageColorDecl(self): - return self._imageDecl - - -# 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 GroupNoDepth(core.Group): - """A group rendering its children without writing to the depth buffer - - :param bool mask: True (default) to disable writing in the depth buffer - :param bool notest: True (default) to disable depth test - """ - - def __init__(self, children=(), mask=True, notest=True): - super(GroupNoDepth, self).__init__(children) - self._mask = bool(mask) - self._notest = bool(notest) - - def renderGL2(self, ctx): - if self._mask: - gl.glDepthMask(gl.GL_FALSE) - - with gl.disabled(gl.GL_DEPTH_TEST, disable=self._notest): - super(GroupNoDepth, self).renderGL2(ctx) - - if self._mask: - gl.glDepthMask(gl.GL_TRUE) - - -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(),)) - - # Using 1 of 3 primitives to render axes and/or bounding box - # To avoid z-fighting between axes and bounding box - self._boxWithAxes = BoxWithAxes(color) - self._boxWithAxes.smooth = False - self._boxWithAxes.transforms = self._boxTransforms - - self._box = Box(stroke=color, fill=(1., 1., 1., 0.)) - self._box.strokeSmooth = False - self._box.transforms = self._boxTransforms - self._box.visible = False - - self._axes = Axes() - self._axes.smooth = False - self._axes.transforms = self._boxTransforms - self._axes.visible = False - - self.strokeWidth = 2. - - self._children = [self._boxWithAxes, self._box, self._axes, 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] - size = bounds[1] - bounds[0] - else: - origin, size = (0., 0., 0.), (1., 1., 1.) - - self._boxTransforms[0].translation = origin - - self._boxWithAxes.size = size - self._box.size = size - self._axes.size = size - - 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 and stroke width - - @property - def color(self): - """The RGBA color to use for the box: 4 float in [0, 1]""" - return self._box.strokeColor - - @color.setter - def color(self, color): - self._box.strokeColor = color - self._boxWithAxes.color = color - - @property - def strokeWidth(self): - """The width of the stroke lines in pixels (float)""" - return self._box.strokeWidth - - @strokeWidth.setter - def strokeWidth(self, width): - width = float(width) - self._box.strokeWidth = width - self._boxWithAxes.width = width - self._axes.width = width - - # Toggle axes visibility - - def _updateBoxAndAxesVisibility(self, axesVisible, boxVisible): - """Update visible flags of box and axes primitives accordingly. - - :param bool axesVisible: True to display axes - :param bool boxVisible: True to display bounding box - """ - self._boxWithAxes.visible = boxVisible and axesVisible - self._box.visible = boxVisible and not axesVisible - self._axes.visible = not boxVisible and axesVisible - - @property - def axesVisible(self): - """Whether axes are displayed or not (bool)""" - return self._boxWithAxes.visible or self._axes.visible - - @axesVisible.setter - def axesVisible(self, visible): - self._updateBoxAndAxesVisibility(axesVisible=bool(visible), - boxVisible=self.boxVisible) - - @property - def boxVisible(self): - """Whether bounding box is displayed or not (bool)""" - return self._boxWithAxes.visible or self._box.visible - - @boxVisible.setter - def boxVisible(self, visible): - self._updateBoxAndAxesVisibility(axesVisible=self.axesVisible, - boxVisible=bool(visible)) - - -# 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/test/__init__.py b/silx/gui/plot3d/scene/test/__init__.py deleted file mode 100644 index fc4621e..0000000 --- a/silx/gui/plot3d/scene/test/__init__.py +++ /dev/null @@ -1,43 +0,0 @@ -# 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 deleted file mode 100644 index 9ea0af1..0000000 --- a/silx/gui/plot3d/scene/test/test_transform.py +++ /dev/null @@ -1,91 +0,0 @@ -# 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 deleted file mode 100644 index 4a2d515..0000000 --- a/silx/gui/plot3d/scene/test/test_utils.py +++ /dev/null @@ -1,275 +0,0 @@ -# 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__ = "17/01/2018" - - -import unittest -from silx.utils.testutils 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 deleted file mode 100644 index bacc2e6..0000000 --- a/silx/gui/plot3d/scene/text.py +++ /dev/null @@ -1,535 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2016-2020 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__ = "24/04/2018" - - -import logging -import numpy - -from silx.gui.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._texture.prepare() - 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 deleted file mode 100644 index 43b739b..0000000 --- a/silx/gui/plot3d/scene/transform.py +++ /dev/null @@ -1,1027 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2015-2020 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 - - def transformPoints(self, points, direct=True, perspectiveDivide=False): - """Apply the transform to an array of points. - - :param points: 2D array of N vectors of 3 or 4 coordinates - :param bool direct: Whether to apply the direct (True, the default) - or inverse (False) transform. - :param bool perspectiveDivide: Whether to apply the perspective divide - (True) or not (False, the default). - :return: The transformed points. - :rtype: numpy.ndarray of same shape as points. - """ - if direct: - matrix = self.getMatrix(copy=False) - else: - matrix = self.getInverseMatrix(copy=False) - - points = numpy.array(points, copy=False) - assert points.ndim == 2 - - points = numpy.transpose(points) - - dimension = points.shape[0] - assert dimension in (3, 4) - - if dimension == 3: # Add 4th coordinate - points = numpy.append( - points, - numpy.ones((1, points.shape[1]), dtype=points.dtype), - axis=0) - - result = numpy.transpose(numpy.dot(matrix, points)) - - if perspectiveDivide: - mask = result[:, 3] != 0. - result[mask] /= result[mask, 3][:, numpy.newaxis] - - return result[:, :3] if dimension == 3 else result - - @staticmethod - def _prepareVector(vector, w): - """Add 4th coordinate (w) to vector if missing.""" - 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. - - :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:`transformBounds`""" - - 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 (width, height). - :type size: 2-tuple of float - """ - - 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 can keep aspect ratio. - - Clipping planes are adjusted to match the aspect ratio of - the :attr:`size` attribute if :attr:`keepaspect` is True. - - In this case, 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 (width, height). - :type size: 2-tuple of float - :param bool keepaspect: - True (default) to keep aspect ratio, False otherwise. - """ - - def __init__(self, left=0., right=1., bottom=1., top=0., near=-1., far=1., - size=(1., 1.), keepaspect=True): - self._left, self._right = left, right - self._bottom, self._top = bottom, top - self._keepaspect = bool(keepaspect) - 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): - if self.keepaspect: - 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)""" - return self._size - - @size.setter - def size(self, size): - assert len(size) == 2 - size = float(size[0]), float(size[1]) - if size != self._size: - self._size = size - self._update(self.left, self.right, self.bottom, self.top) - self.notify() - - @property - def keepaspect(self): - """True to keep aspect ratio, False otherwise.""" - return self._keepaspect - - @keepaspect.setter - def keepaspect(self, aspect): - aspect = bool(aspect) - if aspect != self._keepaspect: - self._keepaspect = aspect - 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 (width, height). - :type size: 2-tuple of float - """ - - 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 (width, height). - :type size: 2-tuple of float - """ - - 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 deleted file mode 100644 index c6cd129..0000000 --- a/silx/gui/plot3d/scene/utils.py +++ /dev/null @@ -1,662 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2015-2020 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 triangleStripToTriangles(strip): - """Convert a triangle strip to a set of triangles. - - The order of the corners is inverted for odd triangles. - - :param numpy.ndarray strip: - Array of triangle corners of shape (N, 3). - N must be at least 3. - :return: Equivalent triangles corner as an array of shape (N, 3, 3) - :rtype: numpy.ndarray - """ - strip = numpy.array(strip).reshape(-1, 3) - assert len(strip) >= 3 - - triangles = numpy.empty((len(strip) - 2, 3, 3), dtype=strip.dtype) - triangles[0::2, 0] = strip[0:-2:2] - triangles[0::2, 1] = strip[1:-1:2] - triangles[0::2, 2] = strip[2::2] - - triangles[1::2, 0] = strip[3::2] - triangles[1::2, 1] = strip[2:-1:2] - triangles[1::2, 2] = strip[1:-2:2] - - return triangles - - -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 - 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 - - -def clipSegmentToBounds(segment, bounds): - """Clip segment to volume aligned with axes. - - :param numpy.ndarray segment: (p0, p1) - :param numpy.ndarray bounds: (lower corner, upper corner) - :return: Either clipped (p0, p1) or None if outside volume - :rtype: Union[None,List[numpy.ndarray]] - """ - segment = numpy.array(segment, copy=False) - bounds = numpy.array(bounds, copy=False) - - p0, p1 = segment - # Get intersection points of ray with volume boundary planes - # Line equation: P = offset * delta + p0 - delta = p1 - p0 - deltaNotZero = numpy.array(delta, copy=True) - deltaNotZero[deltaNotZero == 0] = numpy.nan # Invalidated to avoid division by zero - offsets = ((bounds - p0) / deltaNotZero).reshape(-1) - points = offsets.reshape(-1, 1) * delta + p0 - - # Avoid precision errors by using bounds value - points.shape = 2, 3, 3 # Reshape 1 point per bound value - for dim in range(3): - points[:, dim, dim] = bounds[:, dim] - points.shape = -1, 3 # Set back to 2D array - - # Find intersection points that are included in the volume - mask = numpy.logical_and(numpy.all(bounds[0] <= points, axis=1), - numpy.all(points <= bounds[1], axis=1)) - intersections = numpy.unique(offsets[mask]) - if len(intersections) != 2: - return None - - intersections.sort() - # Do p1 first as p0 is need to compute it - if intersections[1] < 1: # clip p1 - segment[1] = intersections[1] * delta + p0 - if intersections[0] > 0: # clip p0 - segment[0] = intersections[0] * delta + p0 - return segment - - -def segmentVolumeIntersect(segment, nbins): - """Get bin indices intersecting with segment - - It should work with N dimensions. - Coordinate convention (z, y, x) or (x, y, z) should not matter - as long as segment and nbins are consistent. - - :param numpy.ndarray segment: - Segment end points as a 2xN array of coordinates - :param numpy.ndarray nbins: - Shape of the volume with same coordinates order as segment - :return: List of bins indices as a 2D array or None if no bins - :rtype: Union[None,numpy.ndarray] - """ - segment = numpy.asarray(segment) - nbins = numpy.asarray(nbins) - - assert segment.ndim == 2 - assert segment.shape[0] == 2 - assert nbins.ndim == 1 - assert segment.shape[1] == nbins.size - - dim = len(nbins) - - bounds = numpy.array((numpy.zeros_like(nbins), nbins)) - segment = clipSegmentToBounds(segment, bounds) - if segment is None: - return None # Segment outside volume - p0, p1 = segment - - # Get intersections - - # Get coordinates of bin edges crossing the segment - clipped = numpy.ceil(numpy.clip(segment, 0, nbins)) - start = numpy.min(clipped, axis=0) - stop = numpy.max(clipped, axis=0) # stop is NOT included - edgesByDim = [numpy.arange(start[i], stop[i]) for i in range(dim)] - - # Line equation: P = t * delta + p0 - delta = p1 - p0 - - # Get bin edge/line intersections as sorted points along the line - # Get corresponding line parameters - t = [] - if numpy.all(0 <= p0) and numpy.all(p0 <= nbins): - t.append([0.]) # p0 within volume, add it - t += [(edgesByDim[i] - p0[i]) / delta[i] for i in range(dim) if delta[i] != 0] - if numpy.all(0 <= p1) and numpy.all(p1 <= nbins): - t.append([1.]) # p1 within volume, add it - t = numpy.concatenate(t) - t.sort(kind='mergesort') - - # Remove duplicates - unique = numpy.ones((len(t),), dtype=bool) - numpy.not_equal(t[1:], t[:-1], out=unique[1:]) - t = t[unique] - - if len(t) < 2: - return None # Not enough intersection points - - # bin edges/line intersection points - points = t.reshape(-1, 1) * delta + p0 - centers = (points[:-1] + points[1:]) / 2. - bins = numpy.floor(centers).astype(numpy.int64) - return bins - - -# 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 - - def segmentIntersection(self, s0, s1): - """Compute the plane intersection with segment [s0, s1]. - - :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 - :return: The intersection points. The number of points goes - from 0 (no intersection) to 2 (segment in the plane) - :rtype: list of 1D numpy.ndarray - """ - if not self.isPlane: - return [] - else: - return segmentPlaneIntersect(s0, s1, self.normal, self.point) diff --git a/silx/gui/plot3d/scene/viewport.py b/silx/gui/plot3d/scene/viewport.py deleted file mode 100644 index 6de640e..0000000 --- a/silx/gui/plot3d/scene/viewport.py +++ /dev/null @@ -1,603 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2015-2019 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# 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__ = "24/04/2018" - - -import string -import numpy - -from silx.gui.colors import rgba - -from ..._glutils import gl - -from . import camera -from . import event -from . import transform -from .function import DirectionalLight, ClippingPlane, Fog - - -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. - """ - - _FRAGMENT_SHADER_SRC = string.Template(""" - void scene_post(vec4 cameraPosition) { - gl_FragColor = $fogCall(gl_FragColor, cameraPosition); - } - """) - - def __init__(self, viewport, glContext): - self._viewport = viewport - self._glContext = glContext - self._transformStack = [viewport.camera.extrinsic] - self._clipPlane = ClippingPlane(normal=(0., 0., 0.)) - - # cache - self.__cache = {} - - def cache(self, key, factory, *args, **kwargs): - """Lazy-loading cache to store values in the context for rendering - - :param key: The key to retrieve - :param factory: A callback taking args and kwargs as arguments - and returning the value to store. - :return: The stored or newly allocated value - """ - if key not in self.__cache: - self.__cache[key] = factory(*args, **kwargs) - return self.__cache[key] - - @property - def viewport(self): - """Viewport doing the current rendering""" - 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 (ClippingPlane)""" - 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) - - def setupProgram(self, program): - """Sets-up uniforms of a program using the context shader functions. - - :param GLProgram program: The program to set-up. - It MUST be in use and using the context function. - """ - self.clipper.setupProgram(self, program) - self.viewport.fog.setupProgram(self, program) - - @property - def fragDecl(self): - """Fragment shader declaration for scene shader functions""" - return '\n'.join(( - self.clipper.fragDecl, - self.viewport.fog.fragDecl, - self._FRAGMENT_SHADER_SRC.substitute( - fogCall=self.viewport.fog.fragCall))) - - @property - def fragCallPre(self): - """Fragment shader call for scene shader functions (to do first) - - It takes the camera position (vec4) as argument. - """ - return self.clipper.fragCall - - @property - def fragCallPost(self): - """Fragment shader call for scene shader functions (to do last) - - It takes the camera position (vec4) as argument. - """ - return "scene_post" - - -class Viewport(event.Notifier): - """Rendering a single scene through a camera in part of a framebuffer. - - :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) - self._fog = Fog() - self._fog.isOn = False - self._fog.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): - """Viewport's background color (4-tuple of float in [0, 1] or None) - - The background color is used to clear to viewport. - If None, the viewport is not cleared - """ - return self._background - - @background.setter - def background(self, color): - if color is not None: - 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 fog(self): - """The fog function used to render the scene""" - return self._fog - - @property - def origin(self): - """Origin (ox, oy) of the viewport in pixels""" - return self._origin - - @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) - - if self.background is None: - gl.glClear(gl.GL_STENCIL_BUFFER_BIT | - gl.GL_DEPTH_BUFFER_BIT) - else: - 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, offset=0): - """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. - :param int offset: Number of pixels to look at around the given pixel - - :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) - - if offset == 0: # Fast path - # glReadPixels is not GL|ES friendly - depth = gl.glReadPixels( - x, y, 1, 1, gl.GL_DEPTH_COMPONENT, gl.GL_FLOAT)[0] - else: - offset = abs(int(offset)) - size = 2*offset + 1 - depthPatch = gl.glReadPixels( - x - offset, y - offset, - size, size, - gl.GL_DEPTH_COMPONENT, gl.GL_FLOAT) - depthPatch = depthPatch.ravel() # Work in 1D - - # TODO cache sortedIndices to avoid computing it each time - # Compute distance of each pixels to the center of the patch - offsetToCenter = numpy.arange(- offset, offset + 1, dtype=numpy.float32) ** 2 - sqDistToCenter = numpy.add.outer(offsetToCenter, offsetToCenter) - - # Use distance to center to sort values from the patch - sortedIndices = numpy.argsort(sqDistToCenter.ravel()) - sortedValues = depthPatch[sortedIndices] - - # Take first depth that is not 1 in the sorted values - hits = sortedValues[sortedValues != 1.] - depth = 1. if len(hits) == 0 else hits[0] - - gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, 0) - - # 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 deleted file mode 100644 index baa76a2..0000000 --- a/silx/gui/plot3d/scene/window.py +++ /dev/null @@ -1,430 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2015-2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# 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, attrib0='position'): - """Cache program within context. - - WARNING: No clean-up. - - :param str vertexShaderSrc: Vertex shader source code - :param str fragmentShaderSrc: Fragment shader source code - :param str attrib0: - Attribute's name to bind to position 0 (default: 'position'). - On some platform, this attribute MUST be active and with an - array attached to it in order for the rendering to occur.... - """ - assert self.isCurrent - key = vertexShaderSrc, fragmentShaderSrc, attrib0 - program = self._programs.get(key, None) - if program is None: - program = _glutils.Program( - vertexShaderSrc, fragmentShaderSrc, attrib0=attrib0) - self._programs[key] = program - return program - - # 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 - as an array of dimension (height, width, 3) - :rtype: numpy.ndarray of uint8 - """ - 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) |