# /*##########################################################################
#
# Copyright (c) 2015-2023 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
# ###########################################################################*/
__authors__ = ["T. Vincent"]
__license__ = "MIT"
__date__ = "24/04/2018"
from collections import abc
import ctypes
from functools import reduce
import logging
import string
import numpy
from silx._utils import NP_OPTIONAL_COPY
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 ####################################################################
[docs]
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.asarray(array)
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 or NP_OPTIONAL_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
[docs]
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()
[docs]
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 or NP_OPTIONAL_COPY)
[docs]
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,
)
[docs]
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()
[docs]
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 or NP_OPTIONAL_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.0 # Avoid NaNs
return self.__bounds.copy()
[docs]
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 #######################################################################
[docs]
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.0, 1.0, 1.0, 1.0),
indices=None,
mode="lines",
width=1.0,
):
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)",
)
[docs]
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)
[docs]
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.0, 1.0, 1.0, 1.0), indices=None, width=1.0):
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()
[docs]
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)
[docs]
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)
[docs]
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)
[docs]
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.0, 0.0),
(1.0, 0.0, 0.0),
(1.0, 1.0, 0.0),
(0.0, 1.0, 0.0),
# Corners with z=1
(0.0, 0.0, 1.0),
(1.0, 0.0, 1.0),
(1.0, 1.0, 1.0),
(0.0, 1.0, 1.0),
),
dtype=numpy.float32,
)
def __init__(self, stroke=(1.0, 1.0, 1.0, 1.0), fill=(1.0, 1.0, 1.0, 0.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.0
self._stroke = Lines(
self._vertices, indices=self._lineIndices, colors=rgba(stroke), mode="lines"
)
self._stroke.visible = self.strokeColor[-1] != 0.0
self.strokeWidth = 1.0
self._children = [self._stroke, self._fill]
self._size = 1.0, 1.0, 1.0
[docs]
@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 or NP_OPTIONAL_COPY)
[docs]
@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 or NP_OPTIONAL_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.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.0
self.notify()
@property
def fillCulling(self):
return self._fill.culling
@fillCulling.setter
def fillCulling(self, culling):
self._fill.culling = culling
[docs]
class Axes(Lines):
"""3D RGB orthogonal axes"""
_vertices = numpy.array(
(
(0.0, 0.0, 0.0),
(1.0, 0.0, 0.0),
(0.0, 0.0, 0.0),
(0.0, 1.0, 0.0),
(0.0, 0.0, 0.0),
(0.0, 0.0, 1.0),
),
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.0)
self._size = 1.0, 1.0, 1.0
@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()
[docs]
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.0, 0.0),
(1.0, 0.0, 0.0),
(0.0, 0.0, 0.0),
(0.0, 1.0, 0.0),
(0.0, 0.0, 0.0),
(0.0, 0.0, 1.0),
# Box corners with z=0
(1.0, 0.0, 0.0),
(1.0, 1.0, 0.0),
(0.0, 1.0, 0.0),
# Box corners with z=1
(0.0, 0.0, 1.0),
(1.0, 0.0, 1.0),
(1.0, 1.0, 1.0),
(0.0, 1.0, 1.0),
),
dtype=numpy.float32,
)
_axesColors = numpy.array(
(
(1.0, 0.0, 0.0, 1.0),
(1.0, 0.0, 0.0, 1.0),
(0.0, 1.0, 0.0, 1.0),
(0.0, 1.0, 0.0, 1.0),
(0.0, 0.0, 1.0, 1.0),
(0.0, 0.0, 1.0, 1.0),
),
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.0, 1.0, 1.0, 1.0)):
self._color = (1.0, 1.0, 1.0, 1.0)
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.0
)
self._size = 1.0, 1.0, 1.0
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()
[docs]
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.0, 0.0), normal=(0.0, 0.0, 1.0)):
super(PlaneInGroup, self).__init__()
self._cache = None, None # Store bounds, vertices
self._outline = None
self._color = None
self.color = 1.0, 1.0, 1.0, 1.0 # Set _color
self._width = 2.0
self._strokeVisible = True
self._plane = utils.Plane(point, normal)
self._plane.addListener(self._planeChanged)
[docs]
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.0
_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
[docs]
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)
[docs]
def renderGL2(self, ctx):
if self.isValid:
super(PlaneInGroup, self).renderGL2(ctx)
[docs]
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.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.0, 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)
[docs]
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.0, size=1.0, 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)
[docs]
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.0, 1.0, 1.0, 1.0), size=1.0, 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", "", ""
[docs]
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)
[docs]
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)
[docs]
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.0,
shape=None,
sizes=1.0,
indices=None,
minValue=None,
maxValue=None,
):
if isinstance(values, abc.Iterable):
values = numpy.asarray(values)
# 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
[docs]
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 #####################################################################
[docs]
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.0, colors=(1.0, 1.0, 1.0, 1.0)):
self.__bounds = None
super(Spheres, self).__init__(
"points", None, position=positions, radius=radius, color=colors
)
[docs]
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 ######################################################################
[docs]
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()
[docs]
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)
[docs]
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)
[docs]
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, 0.0), (1.0, 0.0), (0.0, 1.0), (1.0, 1.0)), 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.0
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 or NP_OPTIONAL_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.0 / shape[1], 1.0 / 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)
[docs]
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 or NP_OPTIONAL_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 ##################################################################
[docs]
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 or NP_OPTIONAL_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.asarray(data, dtype=numpy.float32)
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?
[docs]
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
[docs]
def prepareGL2(self, ctx):
if self._epsilon is None:
depthbits = gl.glGetInteger(gl.GL_DEPTH_BITS)
self._epsilon = 1.0 / (1 << (depthbits - 1))
[docs]
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.0)
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.0, 1.0 - 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.0)
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.0, 1.0 - self._epsilon)
child.render(ctx)
gl.glDepthMask(gl.GL_TRUE)
gl.glDepthRange(0.0, 1.0)
# gl.glDepthFunc(gl.GL_LEQUAL)
# TODO use epsilon for all rendering?
# TODO issue with picking in depth buffer!
[docs]
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)
[docs]
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)
[docs]
class GroupBBox(core.PrivateGroup):
"""A group displaying a bounding box around the children."""
def __init__(self, children=(), color=(1.0, 1.0, 1.0, 1.0)):
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.0, 1.0, 1.0, 0.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.0
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.0, 0.0), (1.0, 1.0, 1.0)
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)
[docs]
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 ##############################################################
[docs]
class ClipPlane(PlaneInGroup):
"""A clipping plane attached to a box"""
[docs]
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)
[docs]
def postRender(self, ctx):
if self.visible:
# Disable clip planes
ctx.setClipPlane()