from typing import Sequence, Any, Literal
import numpy as np
import pygfx
from ._positions_base import Graphic
from .features import (
VertexPositions,
MeshIndices,
MeshCmap,
SurfaceData,
surface_data_to_mesh,
VertexColors,
UniformColor,
resolve_cmap_mesh,
VolumeSlicePlane,
PolygonData,
triangulate_polygon,
)
[docs]
class MeshGraphic(Graphic):
_features = {
"positions": VertexPositions,
"indices": MeshIndices,
"colors": (VertexColors, UniformColor),
"cmap": MeshCmap,
}
def __init__(
self,
positions: Any,
indices: Any,
mode: Literal["basic", "phong", "slice"] = "phong",
plane: tuple[float, float, float, float] = (0.0, 0.0, 1.0, 0.0),
colors: str | np.ndarray | Sequence = "w",
mapcoords: Any = None,
cmap: str | dict | pygfx.Texture | pygfx.TextureMap | np.ndarray = None,
clim: tuple[float, float] = None,
isolated_buffer: bool = True,
**kwargs,
):
"""
Create a mesh Graphic.
Parameters
----------
positions: array-like
The 3D positions of the vertices.
indices: array-like
The indices into the positions that make up the triangles. Each 3
subsequent indices form a triangle.
mode: one of "basic", "phong", "slice", default "phong"
* basic: illuminate mesh with only ambient lighting
* phong: phong lighting model, good for most use cases, see https://en.wikipedia.org/wiki/Phong_shading
* slice: display a slice of the mesh at the specified ``plane``
plane: (float, float, float, float), default (0., 0., 1., 0.)
Slice mesh at this plane. Sets (a, b, c, d) in the equation the defines a plane: ax + by + cz + d = 0.
Used only if `mode` = "slice". The plane is defined in world space.
colors: str, array, or iterable, default "w"
A uniform color, or the per-position colors.
mapcoords: array-like
The per-position coordinates to which to apply the colormap (a.k.a. texcoords).
These can e.g. be some domain-specific value, mapped to [0..1].
If ``mapcoords`` and ``cmap`` are given, they are used instead of ``colors``.
cmap: str, optional
Apply a colormap to the mesh, this overrides any argument passed to
"colors". For supported colormaps see the ``cmap`` library
catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/
Both 1D and 2D colormaps are supported, though the mapcoords has to match the dimensionality.
An image can also be used, this is basically a 2D colormap.
isolated_buffer: bool, default True
If True, initialize a buffer with the same shape as the input data and then
set the data, useful if the data arrays are ready-only such as memmaps.
If False, the input array is itself used as the buffer - useful if the
array is large. In almost all cases this should be ``True``.
**kwargs
passed to :class:`.Graphic`
"""
super().__init__(**kwargs)
if isinstance(positions, VertexPositions):
self._positions = positions
else:
self._positions = VertexPositions(
positions, isolated_buffer=isolated_buffer, property_name="positions"
)
if isinstance(positions, MeshIndices):
self._indices = indices
else:
self._indices = MeshIndices(
indices, isolated_buffer=isolated_buffer, property_name="indices"
)
self._cmap = MeshCmap(cmap)
# Apply contrast limits. Would be nice if Pygfx mesh material had clim too! But
# for now we apply it as a pre-processing step.
if clim is None and mapcoords is not None:
clim = mapcoords.min(), mapcoords.max()
if mapcoords is not None:
mapcoords = (mapcoords - clim[0]) / (clim[1] - clim[0])
self._mapcoords = pygfx.Buffer(np.asarray(mapcoords, dtype=np.float32))
else:
self._mapcoords = None
self._clim = clim
uniform_color = "w"
per_vertex_colors = False
if cmap is None:
if colors is None:
uniform_color = "w"
self._colors = UniformColor(uniform_color)
elif isinstance(colors, str) or isinstance(colors, tuple):
uniform_color = colors
self._colors = UniformColor(uniform_color)
elif isinstance(colors, VertexColors):
per_vertex_colors = True
self._colors = colors
else:
per_vertex_colors = True
self._colors = VertexColors(
colors, n_colors=self._positions.value.shape[0]
)
geometry = pygfx.Geometry(
positions=self._positions.buffer, indices=self._indices._buffer
)
valid_modes = ["basic", "phong", "slice"]
if mode not in valid_modes:
raise ValueError(f"mode must be one of: {valid_modes}\nYou passed: {mode}")
self._mode = mode
material_cls = getattr(pygfx, f"Mesh{mode.capitalize()}Material")
if mode == "slice":
self._plane = VolumeSlicePlane(plane)
add_kwargs = {"plane": self._plane.value}
else:
# for basic and phong, maybe later we can add more of the properties
add_kwargs = {}
material = material_cls(
color_mode="uniform",
color=uniform_color,
pick_write=True,
**add_kwargs,
)
# Set all the data
if per_vertex_colors:
geometry.colors = self._colors.buffer
if self._mapcoords is not None:
geometry.texcoords = self._mapcoords
if cmap is not None:
material.map = resolve_cmap_mesh(cmap)
# Decide on color mode
# uniform = None #: Use the uniform color (usually ``material.color``).
# vertex = None #: Use the per-vertex color specified in the geometry (usually ``geometry.colors``).
# face = None #: Use the per-face color specified in the geometry (usually ``geometry.colors``).
# vertex_map = None #: Use per-vertex texture coords (``geometry.texcoords``), and sample these in ``material.map``.
# face_map = None #: Use per-face texture coords (``geometry.texcoords``), and sample these in ``material.map``.
if mapcoords is not None and cmap is not None:
material.color_mode = "vertex_map"
elif per_vertex_colors:
material.color_mode = "vertex"
else:
material.color_mode = "uniform"
world_object: pygfx.Mesh = pygfx.Mesh(geometry=geometry, material=material)
self._set_world_object(world_object)
@property
def mode(self) -> Literal["basic", "phong", "slice"]:
"""get mesh rendering mode"""
return self._mode
@property
def positions(self) -> VertexPositions:
"""Get or set the vertex positions"""
return self._positions
@positions.setter
def positions(self, new_positions):
self._positions[:] = new_positions
@property
def indices(self) -> MeshIndices:
"""Get or set the vertex indices"""
return self._indices
@indices.setter
def indices(self, mew_indices):
self._indices[:] = mew_indices
@property
def mapcoords(self) -> np.ndarray | None:
"""get or set the mapcoords"""
if self._mapcoords is not None:
return self._mapcoords.data
@mapcoords.setter
def mapcoords(self, new_mapcoords: np.ndarray | None):
if new_mapcoords is None:
self.world_object.geometry.texcoords = None
self._mapcoords = None
return
if new_mapcoords.shape == self._mapcoords.data.shape:
self._mapcoords.data[:] = new_mapcoords
self._mapcoords.update_full()
else:
# allocate new buffer
self._mapcoords = pygfx.Buffer(np.asarray(new_mapcoords, dtype=np.float32))
self.world_object.geometry.texcoords = self._mapcoords
@property
def clim(self) -> tuple[float, float] | None:
"""get or set the colormap limits"""
return self._clim
@clim.setter
def clim(self, new_clim: tuple[float, float]):
if len(new_clim) != 2:
raise ValueError("clim must be a: tuple[float, float]")
self._clim = tuple(new_clim)
self.mapcoords = (self.mapcoords - self.clim[0]) / (self.clim[1] - self.clim[0])
@property
def colors(self) -> VertexColors | pygfx.Color:
"""Get or set the colors"""
if isinstance(self._colors, VertexColors):
return self._colors
elif isinstance(self._colors, UniformColor):
return self._colors.value
@colors.setter
def colors(self, value: str | np.ndarray | Sequence[float] | Sequence[str]):
if isinstance(self._colors, VertexColors):
self._colors[:] = value
elif isinstance(self._colors, UniformColor):
self._colors.set_value(self, value)
@property
def cmap(self) -> str | dict | pygfx.Texture | pygfx.TextureMap | np.ndarray | None:
"""get or set the cmap"""
if self._cmap is not None:
return self._cmap.value
@cmap.setter
def cmap(
self,
new_cmap: str | dict | pygfx.Texture | pygfx.TextureMap | np.ndarray | None,
):
self._cmap.set_value(self, new_cmap)
@property
def plane(self) -> tuple[float, float, float, float] | None:
"""Get or set the current slice plane. Valid only for ``"slice"`` render mode."""
if self.mode != "slice":
return
return self._plane.value
@plane.setter
def plane(self, value: tuple[float, float, float, float]):
if self.mode != "slice":
raise TypeError("`plane` property is only valid for `slice` render mode.")
self._plane.set_value(self, value)
[docs]
class SurfaceGraphic(MeshGraphic):
_features = {
"data": SurfaceData,
"colors": (VertexColors, UniformColor),
"cmap": MeshCmap,
}
def __init__(
self,
data: np.ndarray,
mode: Literal["basic", "phong", "slice"] = "phong",
colors: str | np.ndarray | Sequence = "w",
mapcoords: Any = None,
cmap: str | dict | pygfx.Texture | pygfx.TextureMap | np.ndarray = None,
clim: tuple[float, float] | None = None,
**kwargs,
):
"""
Create a Surface mesh Graphic
Parameters
----------
data: array-like
A height-map (an image where the values indicate height, i.e. z values).
Can also be a [m, n, 3] to explicitly specify the x and y values in addition to the z values.
[m, n, 3] is a dstack of (x, y, z) values that form a grid on the xy plane.
mode: one of "basic", "phong", "slice", default "phong"
* basic: illuminate mesh with only ambient lighting
* phong: phong lighting model, good for most use cases, see https://en.wikipedia.org/wiki/Phong_shading
colors: str, array, or iterable, default "w"
A uniform color, or the per-position colors.
mapcoords: array-like
The per-position coordinates to which to apply the colormap (a.k.a. texcoords).
These can e.g. be some domain-specific value (mapped to [0..1] using ``clim``).
If not given, they will be the depth (z-coordinate) of the surface.
cmap: str, optional
Apply a colormap to the mesh, this overrides any argument passed to
"colors". For supported colormaps see the ``cmap`` library
catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/
Both 1D and 2D colormaps are supported, though the mapcoords has to match the dimensionality.
clim: tuple[float, float]
The colormap limits. If the mapcoords has values between e.g. 5 and 90, you want to set the clim
to e.g. (5, 90) or (0, 100) to determine how the values map onto the colormap.
**kwargs
passed to :class:`.Graphic`
"""
self._data = SurfaceData(data)
positions, indices = surface_data_to_mesh(data)
cmap_tex_view = resolve_cmap_mesh(cmap)
if (cmap_tex_view is not None) and (mapcoords is None):
if cmap_tex_view.texture.dim == 1: # 1d
mapcoords = positions[:, 2]
elif cmap_tex_view.texture.dim == 2:
mapcoords = np.column_stack((positions[:, 0], positions[:, 1])).astype(
np.float32
)
super().__init__(
positions,
indices,
mode=mode,
colors=colors,
mapcoords=mapcoords,
cmap=cmap,
clim=clim,
**kwargs,
)
@property
def data(self) -> np.ndarray:
"""get or set the surface data"""
return self._data.value
@data.setter
def data(self, new_data: np.ndarray):
self._data.set_value(self, new_data)
[docs]
class PolygonGraphic(MeshGraphic):
_features = {
"data": SurfaceData,
"colors": (VertexColors, UniformColor),
"cmap": MeshCmap,
}
def __init__(
self,
data: np.ndarray,
mode: Literal["basic", "phong"] = "basic",
colors: str | np.ndarray | Sequence = "w",
mapcoords: Any = None,
cmap: str | dict | pygfx.Texture | pygfx.TextureMap | np.ndarray = None,
clim: tuple[float, float] | None = None,
**kwargs,
):
"""
Create a polygon mesh graphic.
The data are always in the 'xy' plane. Set a rotation to display the polygon in another plane or in 3D space.
Parameters
----------
data: array-like
The polygon vertices, must be of shape: [n_vertices, 2]
mode: one of "basic", "phong", "slice", default "phong"
* basic: illuminate mesh with only ambient lighting
* phong: phong lighting model, good for most use cases, see https://en.wikipedia.org/wiki/Phong_shading
colors: str, array, or iterable, default "w"
A uniform color, or the per-position colors.
mapcoords: array-like
The per-position coordinates to which to apply the colormap (a.k.a. texcoords).
These can e.g. be some domain-specific value (mapped to [0..1] using ``clim``).
If not given, they will be the depth (z-coordinate) of the surface.
cmap: str, optional
Apply a colormap to the mesh, this overrides any argument passed to
"colors". For supported colormaps see the ``cmap`` library
catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/
Both 1D and 2D colormaps are supported, though the mapcoords has to match the dimensionality.
clim: tuple[float, float]
The colormap limits. If the mapcoords has values between e.g. 5 and 90, you want to set the clim
to e.g. (5, 90) or (0, 100) to determine how the values map onto the colormap.
**kwargs
passed to :class:`.Graphic`
"""
positions, indices = triangulate_polygon(data)
self._data = PolygonData(positions)
super().__init__(
positions,
indices,
mode=mode,
colors=colors,
mapcoords=mapcoords,
cmap=cmap,
clim=clim,
**kwargs,
)
@property
def data(self) -> np.ndarray:
"""get or set the polygon vertex data"""
return self._data.value
@data.setter
def data(self, new_data: np.ndarray | Sequence):
self._data.set_value(self, new_data)
@property
def clim(self) -> tuple[float, float] | None:
"""get or set the colormap limits"""
return self._clim
@clim.setter
def clim(self, new_clim: tuple[float, float]):
if len(new_clim) != 2:
raise ValueError("clim must be a: tuple[float, float]")
self._clim = tuple(new_clim)
self.mapcoords = (self.mapcoords - self.clim[0]) / (self.clim[1] - self.clim[0])