From af1bd57aa93b1835bf05fb8f77428b62539e8470 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sat, 29 Nov 2025 17:27:27 -0500 Subject: [PATCH 01/14] mesh with gfeatures --- fastplotlib/graphics/features/__init__.py | 6 +- fastplotlib/graphics/features/_base.py | 6 + fastplotlib/graphics/features/_mesh.py | 118 +++++++++++ .../{_positions_graphics.py => _positions.py} | 2 - fastplotlib/graphics/mesh.py | 185 ++++++++++++------ 5 files changed, 258 insertions(+), 59 deletions(-) create mode 100644 fastplotlib/graphics/features/_mesh.py rename fastplotlib/graphics/features/{_positions_graphics.py => _positions.py} (99%) diff --git a/fastplotlib/graphics/features/__init__.py b/fastplotlib/graphics/features/__init__.py index f745f10c8..87f61ed93 100644 --- a/fastplotlib/graphics/features/__init__.py +++ b/fastplotlib/graphics/features/__init__.py @@ -1,10 +1,11 @@ -from ._positions_graphics import ( +from ._positions import ( VertexColors, UniformColor, SizeSpace, VertexPositions, VertexCmap, ) +from ._mesh import MeshVertexPositions, MeshIndices, MeshCmap, resolve_cmap_mesh from ._line import Thickness from ._scatter import ( VertexMarkers, @@ -71,6 +72,9 @@ "SizeSpace", "VertexPositions", "VertexCmap", + "MeshVertexPositions", + "MeshIndices", + "MeshCmap", "Thickness", "VertexMarkers", "UniformMarker", diff --git a/fastplotlib/graphics/features/_base.py b/fastplotlib/graphics/features/_base.py index 5dec9f1e5..779310476 100644 --- a/fastplotlib/graphics/features/_base.py +++ b/fastplotlib/graphics/features/_base.py @@ -289,6 +289,12 @@ def _update_range( # the first dimension corresponding to n_datapoints key: int | np.ndarray[int | bool] | slice = key[0] + if isinstance(key, slice): + if key == slice(None): + # directly update full, don't need to figure out chunks + self.buffer.update_full() + return + offset, size = self._parse_offset_size(key, upper_bound) self.buffer.update_range(offset=offset, size=size) diff --git a/fastplotlib/graphics/features/_mesh.py b/fastplotlib/graphics/features/_mesh.py new file mode 100644 index 000000000..38f3529af --- /dev/null +++ b/fastplotlib/graphics/features/_mesh.py @@ -0,0 +1,118 @@ +from typing import Any, Sequence + +import numpy as np +import pygfx + +from ._base import ( + GraphicFeature, + GraphicFeatureEvent, + to_gpu_supported_dtype, + block_reentrance, +) + +from ._positions import VertexPositions +from ...utils.functions import get_cmap + + +def resolve_cmap_mesh(cmap) -> pygfx.TextureMap | None: + """Turn a user-provided in a pygfx.TextureMap, supporting 1D, 2D and 3D data.""" + + if cmap is None: + pygfx_cmap = None + elif isinstance(cmap, pygfx.TextureMap): + pygfx_cmap = cmap + elif isinstance(cmap, pygfx.Texture): + pygfx_cmap = pygfx.TextureMap(cmap) + elif isinstance(cmap, (str, dict)): + pygfx_cmap = pygfx.cm.create_colormap(get_cmap(cmap)) + else: + map = np.asarray(cmap) + if map.ndim == 2: # 1D plus color + pygfx_cmap = pygfx.cm.create_colormap(cmap) + else: + tex = pygfx.Texture(map, dim=map.ndim - 1) + pygfx_cmap = pygfx.TextureMap(tex) + + return pygfx_cmap + + +class MeshVertexPositions(VertexPositions): + """Manages mesh vertex positions, same as VertexPosition but data must be of shape [n, 3]""" + def _fix_data(self, data): + if data.ndim != 2 or data.shape[1] !=3: + raise ValueError( + f"mesh vertex positions must be of shape: [n_vertices, 3], you passed an array of shape: {data.shape}" + ) + + return to_gpu_supported_dtype(data) + + +class MeshIndices(VertexPositions): + event_info_spec = [ + { + "dict key": "key", + "type": "slice, index (int) or numpy-like fancy index", + "description": "key at which vertex indices were indexed/sliced", + }, + { + "dict key": "value", + "type": "int | float | array-like", + "description": "new data values for indices that were changed", + }, + ] + + def __init__( + self, data: Any, isolated_buffer: bool = True, property_name: str = "indices" + ): + """ + Manages the vertex indices buffer shown in the graphic. + Supports fancy indexing if the data array also supports it. + """ + + data = self._fix_data(data) + super().__init__( + data, isolated_buffer=isolated_buffer, property_name=property_name + ) + + def _fix_data(self, data): + if data.ndim != 2 or data.shape[1] not in (3, 4): + raise ValueError( + f"indices must be of shape: [n_vertices, 3] or [n_vertices, 4], " + f"you passed an array of shape: {data.shape}" + ) + + return data.astype("i4") + + +class MeshCmap(GraphicFeature): + event_info_spec = [ + { + "dict key": "value", + "type": "str | dict | pygfx.TextureMap | pygfx.Texture | np.ndarray", + "description": "new cmap", + }, + ] + + def __init__( + self, + value: str | dict | pygfx.TextureMap | pygfx.Texture | np.ndarray | None, + property_name: str = "cmap", + ): + """Manages a mesh colormap""" + + self._value = value + super().__init__(property_name=property_name) + + @property + def value(self) -> str | dict | pygfx.TextureMap | pygfx.Texture | np.ndarray | None: + return self._value + + @block_reentrance + def set_value( + self, graphic, value: str | dict | pygfx.TextureMap | pygfx.Texture | np.ndarray | None + ): + graphic.world_object.material.map = resolve_cmap_mesh(value) + self._value = value + + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) + self._call_event_handlers(event) diff --git a/fastplotlib/graphics/features/_positions_graphics.py b/fastplotlib/graphics/features/_positions.py similarity index 99% rename from fastplotlib/graphics/features/_positions_graphics.py rename to fastplotlib/graphics/features/_positions.py index ae57e77d7..295d22417 100644 --- a/fastplotlib/graphics/features/_positions_graphics.py +++ b/fastplotlib/graphics/features/_positions.py @@ -245,8 +245,6 @@ def __init__( ) def _fix_data(self, data): - # data = to_gpu_supported_dtype(data) - if data.ndim == 1: # if user provides a 1D array, assume these are y-values data = np.column_stack([np.arange(data.size, dtype=data.dtype), data]) diff --git a/fastplotlib/graphics/mesh.py b/fastplotlib/graphics/mesh.py index a44038371..480ece04b 100644 --- a/fastplotlib/graphics/mesh.py +++ b/fastplotlib/graphics/mesh.py @@ -1,63 +1,38 @@ -from typing import Sequence, Any +from typing import Sequence, Any, Literal import numpy as np import pygfx from ._positions_base import Graphic -from .selectors import ( - LinearRegionSelector, - LinearSelector, - RectangleSelector, - PolygonSelector, -) from .features import ( - BufferManager, - VertexPositions, + MeshVertexPositions, + MeshIndices, + MeshCmap, VertexColors, UniformColor, - VertexCmap, + resolve_cmap_mesh, + VolumeSlicePlane, ) -from ..utils.functions import get_cmap -from ..utils import quick_min_max - - -def resolve_cmap(cmap): - """Turn a user-provided in a pygfx.TextureMap, supporting 1D, 2D and 3D data.""" - if cmap is None: - pygfx_cmap = None - elif isinstance(cmap, pygfx.TextureMap): - pygfx_cmap = cmap - elif isinstance(cmap, pygfx.Texture): - pygfx_cmap = pygfx.TextureMap(cmap) - elif isinstance(cmap, (str, dict)): - pygfx_cmap = pygfx.cm.create_colormap(get_cmap(cmap)) - else: - map = np.asarray(cmap) - if map.ndim == 2: # 1D plus color - pygfx_cmap = pygfx.cm.create_colormap(cmap) - else: - tex = pygfx.Texture(map, dim=map.ndim - 1) - pygfx_cmap = pygfx.TextureMap(tex) - - return pygfx_cmap class MeshGraphic(Graphic): _features = { - "positions": VertexPositions, - "indices": BufferManager, - "mapcoords": (BufferManager, None), + "positions": MeshVertexPositions, + "indices": MeshIndices, "colors": (VertexColors, UniformColor), + "cmap": MeshCmap, } def __init__( self, positions: Any, indices: Any, + mode: Literal["basic", "phong", "slice"], + plane: tuple[float, float, float, float] = (0, 0, 1, 0), colors: str | np.ndarray | Sequence = "w", mapcoords: Any = None, - cmap: str = None, + cmap: str | dict | pygfx.Texture | pygfx.TextureMap | np.ndarray = None, isolated_buffer: bool = True, **kwargs, ): @@ -86,6 +61,7 @@ def __init__( "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. **kwargs passed to :class:`.Graphic` @@ -94,32 +70,30 @@ def __init__( super().__init__(**kwargs) - if isinstance(positions, VertexPositions): + if isinstance(positions, MeshVertexPositions): self._positions = positions else: - self._positions = VertexPositions( + self._positions = MeshVertexPositions( positions, isolated_buffer=isolated_buffer, property_name="positions" ) - if isinstance(positions, BufferManager): + if isinstance(positions, MeshIndices): self._indices = indices else: - self._indices = BufferManager( + self._indices = MeshIndices( indices, isolated_buffer=isolated_buffer, property_name="indices" ) - if mapcoords is None: - self._mapcoords = None - elif isinstance(mapcoords, BufferManager): - self._mapcoords = mapcoords + self._cmap = MeshCmap(cmap) + + if mapcoords is not None: + self._mapcoords = pygfx.Buffer(np.asarray(mapcoords, dtype=np.float32)) else: - self._mapcoords = mapcoords = BufferManager( - mapcoords, isolated_buffer=isolated_buffer, property_name="mapcoords" - ) + self._mapcoords = None uniform_color = "w" per_vertex_colors = False - pygfx_cmap = resolve_cmap(cmap) + if cmap is None: if colors is None: uniform_color = "w" @@ -130,7 +104,6 @@ def __init__( elif isinstance(colors, VertexColors): per_vertex_colors = True self._colors = colors - self._colors._shared += 1 else: per_vertex_colors = True self._colors = VertexColors( @@ -140,19 +113,35 @@ def __init__( geometry = pygfx.Geometry( positions=self._positions.buffer, indices=self._indices._buffer ) - material = pygfx.MeshPhongMaterial( + + 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 mapcoords is not None: - geometry.texcoords = self._mapcoords.buffer - if pygfx_cmap is not None: - material.map = pygfx_cmap + if self._mapcoords is not None: + geometry.texcoords = self._mapcoords + elif self._cmap.value is not None: + material.map = resolve_cmap_mesh(self.cmap) # Decide on color mode # uniform = None #: Use the uniform color (usually ``material.color``). @@ -160,7 +149,7 @@ def __init__( # 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 pygfx_cmap is not None: + if mapcoords is not None and self._cmap.value is not None: material.color_mode = "vertex_map" elif per_vertex_colors: material.color_mode = "vertex" @@ -170,3 +159,87 @@ def __init__( world_object: pygfx.Mesh = pygfx.Mesh(geometry=geometry, material=material) self._set_world_object(world_object) + + @property + def mode(self) -> Literal["basic", "phong", "slice"]: + return self._mode + + @property + def positions(self) -> MeshVertexPositions: + """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: + 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 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: + 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 displayed plane in the volume. 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) + From 084bed07bcb936d6f35df491f65c3a0132f5704c Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 30 Nov 2025 06:16:32 -0500 Subject: [PATCH 02/14] surface graphic works --- .../{mesh => surface_ripple}/image_surface.py | 1 - examples/{mesh => surface_ripple}/mesh.py | 5 +- examples/surface_ripple/surface_earth.py | 82 ++++++ examples/surface_ripple/surface_ellipsoid.py | 55 ++++ examples/surface_ripple/surface_gaussian.py | 36 +++ .../surface_height.py} | 6 +- examples/surface_ripple/surface_ripple.py | 56 ++++ .../surface_ripple/surface_sphere_ripple.py | 79 ++++++ fastplotlib/graphics/__init__.py | 3 +- fastplotlib/graphics/features/__init__.py | 3 +- fastplotlib/graphics/features/_mesh.py | 88 ++++++ fastplotlib/graphics/mesh.py | 112 +++++++- fastplotlib/layouts/_graphic_methods_mixin.py | 256 ++++++------------ 13 files changed, 597 insertions(+), 185 deletions(-) rename examples/{mesh => surface_ripple}/image_surface.py (97%) rename examples/{mesh => surface_ripple}/mesh.py (88%) create mode 100644 examples/surface_ripple/surface_earth.py create mode 100644 examples/surface_ripple/surface_ellipsoid.py create mode 100644 examples/surface_ripple/surface_gaussian.py rename examples/{mesh/surface.py => surface_ripple/surface_height.py} (82%) create mode 100644 examples/surface_ripple/surface_ripple.py create mode 100644 examples/surface_ripple/surface_sphere_ripple.py diff --git a/examples/mesh/image_surface.py b/examples/surface_ripple/image_surface.py similarity index 97% rename from examples/mesh/image_surface.py rename to examples/surface_ripple/image_surface.py index c089f0f90..36332e69b 100644 --- a/examples/mesh/image_surface.py +++ b/examples/surface_ripple/image_surface.py @@ -10,7 +10,6 @@ import imageio.v3 as iio import fastplotlib as fpl -import numpy as np import scipy.ndimage im = iio.imread("imageio:astronaut.png") diff --git a/examples/mesh/mesh.py b/examples/surface_ripple/mesh.py similarity index 88% rename from examples/mesh/mesh.py rename to examples/surface_ripple/mesh.py index b22ae61db..150ac9cf1 100644 --- a/examples/mesh/mesh.py +++ b/examples/surface_ripple/mesh.py @@ -9,7 +9,6 @@ # sphinx_gallery_pygfx_docs = 'screenshot' import fastplotlib as fpl -import numpy as np import pygfx as gfx @@ -21,9 +20,9 @@ positions = geo.positions.data indices = geo.indices.data -mesh = figure[0, 0].add_mesh(positions, indices, colors="magenta") - +mesh = fpl.MeshGraphic(positions, indices, colors="magenta") +figure[0, 0].add_graphic(mesh) figure[0, 0].axes.grids.xy.visible = True figure[0, 0].camera.show_object(mesh.world_object, (1, 1, -1), up=(0, 0, 1)) diff --git a/examples/surface_ripple/surface_earth.py b/examples/surface_ripple/surface_earth.py new file mode 100644 index 000000000..22d964a93 --- /dev/null +++ b/examples/surface_ripple/surface_earth.py @@ -0,0 +1,82 @@ +""" +Earth sphere animation +====================== + +Example showing how to create a sphere with an image of the Earth and rotate it around its 23.44° axis of rotation +with respect to the ecliptic (the xz plane in the visualization). + +""" + +# test_example = false +# sphinx_gallery_pygfx_docs = 'animate 8s' + +import fastplotlib as fpl +import numpy as np +import imageio.v3 as iio +import pylinalg as la + + +figure = fpl.Figure(size=(700, 560), cameras='3d', controller_types='orbit') + +# create a sphere from spherical coordinates +# see this for reference: https://mathworld.wolfram.com/SphericalCoordinates.html +# phi and theta are swapped in this example w.r.t. the wolfram alpha description +radius = 10 +nx = 101 +phi = np.linspace(0, np.pi * 2, num=nx, dtype=np.float32) +ny = 51 +theta = np.linspace(0, np.pi, num=ny, dtype=np.float32) + +phi_grid, theta_grid = np.meshgrid(phi, theta) + +# convert to cartesian coordinates +theta_grid_sin = np.sin(theta_grid) +x = radius * np.cos(phi_grid) * theta_grid_sin * -1 +y = radius * np.cos(theta_grid) +z = radius * np.sin(phi_grid) * theta_grid_sin + +# get texture coords to map the image onto the mesh positions +u = phi_grid / (np.pi * 2) +v = 1 - (theta_grid / np.pi) +texcoords = np.dstack([u, v]).reshape(-1, 2) + +# get an image of the earth from nasa +image = iio.imread("https://svs.gsfc.nasa.gov/vis/a000000/a003600/a003615/flat_earth_Largest_still.0330.jpg") +# images coordinate systems are typically inverted in y, so flip the image +image = np.ascontiguousarray(np.flipud(image)) + +# create a sphere +sphere = figure[0, 0].add_surface(np.dstack([x, y, z]), mode="phong", colors="magenta", cmap=image, mapcoords=texcoords) + +# display xz plane as a grid +figure[0, 0].axes.grids.xz.visible = True +figure.show() + +# view from top right angle +figure[0, 0].camera.show_object(sphere.world_object, (-0.5, -0.25, -1), up=(0, 1, 0)) +figure[0, 0].camera.zoom = 1.25 + +# create quaternion for 23.44 degrees axial tilt +axial_tilt = la.quat_from_euler((np.radians(23.44), 0), order="XY") + +# a line to indicate the axial tilt +figure[0, 0].add_line(np.array([[0, -20, 0], [0, 20, 0]]), rotation=axial_tilt, colors="magenta") + +rot = 1 +def rotate(): + # rotate by 1 degree + global rot + rot += 1 + rot_quat = la.quat_from_euler((0, np.radians(rot)), order="XY") + + # apply rotation w.r.t. axial tilt + sphere.rotation = la.quat_mul(axial_tilt, rot_quat) + +figure[0, 0].add_animations(rotate) + + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/surface_ripple/surface_ellipsoid.py b/examples/surface_ripple/surface_ellipsoid.py new file mode 100644 index 000000000..9bd2c2f04 --- /dev/null +++ b/examples/surface_ripple/surface_ellipsoid.py @@ -0,0 +1,55 @@ +""" +Ellipsoid surface +====================== + +Simple example of a sphere surface mesh with a colormap indicating z values. + +""" + +# test_example = false +# sphinx_gallery_pygfx_docs = 'screenshot' + +import fastplotlib as fpl +import numpy as np + +figure = fpl.Figure(size=(700, 560), cameras='3d', controller_types='orbit') + +# create an ellipsoid from spherical coordinates +# see this for reference: https://mathworld.wolfram.com/SphericalCoordinates.html +# phi and theta are swapped in this example w.r.t. the wolfram alpha description +radius = 10 + +nx = 101 +phi = np.linspace(0, np.pi * 2, num=nx, dtype=np.float32) +ny = 51 +theta = np.linspace(0, np.pi, num=ny, dtype=np.float32) + +phi_grid, theta_grid = np.meshgrid(phi, theta) + +# convert to cartesian coordinates +theta_grid_sin = np.sin(theta_grid) +x = radius * np.cos(phi_grid) * theta_grid_sin * -1 +y = radius * np.cos(theta_grid) + +# elongate along z axis +z = radius * 2 * np.sin(phi_grid) * theta_grid_sin + +sphere = figure[0, 0].add_surface( + np.dstack([x, y, z]), + mode="phong", + cmap="bwr", # by default, providing a colormap name will map the colors to z values +) + +# display xz plane as a grid +figure[0, 0].axes.grids.xy.visible = True +figure.show() + +# view from top right angle +figure[0, 0].camera.show_object(sphere.world_object, (1, 1, -1), up=(0, 0, 1)) + + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/surface_ripple/surface_gaussian.py b/examples/surface_ripple/surface_gaussian.py new file mode 100644 index 000000000..f5d1d0cac --- /dev/null +++ b/examples/surface_ripple/surface_gaussian.py @@ -0,0 +1,36 @@ +""" +Gaussian kernel as a surface +============================ + +Example showing a gaussian kernel as a surface mesh +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' + +import fastplotlib as fpl +import numpy as np + + +figure = fpl.Figure(size=(700, 560), cameras='3d', controller_types='orbit') + +def gaus2d(x=0, y=0, mx=0, my=0, sx=1, sy=1): + return 1. / (2. * np.pi * sx * sy) * np.exp(-((x - mx)**2. / (2. * sx**2.) + (y - my)**2. / (2. * sy**2.))) + + +r = np.linspace(0, 10, num=200) +x, y = np.meshgrid(r, r) +z = gaus2d(x, y, mx=5, my=5, sx=1, sy=1) * 50 + +mesh = figure[0, 0].add_surface(np.dstack([x, y, z]), mode="phong", colors="magenta", cmap='jet') + +# figure[0, 0].axes.grids.xy.visible = True +figure[0, 0].camera.show_object(mesh.world_object, (-2, 2, -2), up=(0, 0, 1)) +figure.show() + + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/mesh/surface.py b/examples/surface_ripple/surface_height.py similarity index 82% rename from examples/mesh/surface.py rename to examples/surface_ripple/surface_height.py index 12d8d9b1f..eacce631a 100644 --- a/examples/mesh/surface.py +++ b/examples/surface_ripple/surface_height.py @@ -21,12 +21,10 @@ y = np.cos(t*2) z = (x.reshape(1, -1) * x.reshape(-1, 1)) * 50 # 100x100 - -mesh = figure[0, 0].add_surface(z, colors="magenta", cmap='jet') - +surface = figure[0, 0].add_surface(z, cmap="bwr") # figure[0, 0].axes.grids.xy.visible = True -figure[0, 0].camera.show_object(mesh.world_object, (-2, 2, -3), up=(0, 0, 1)) +figure[0, 0].camera.show_object(surface.world_object, (-2, 2, -3), up=(0, 0, 1)) figure.show() diff --git a/examples/surface_ripple/surface_ripple.py b/examples/surface_ripple/surface_ripple.py new file mode 100644 index 000000000..c9d45e870 --- /dev/null +++ b/examples/surface_ripple/surface_ripple.py @@ -0,0 +1,56 @@ +""" +Surface animation +================= + +Example of a surface ripple animation by setting the z-height data on every render. + +""" + +# test_example = false +# sphinx_gallery_pygfx_docs = 'animate 6s' + +import fastplotlib as fpl +import numpy as np + + +figure = fpl.Figure(size=(700, 560), cameras='3d', controller_types='orbit') + + +def create_ripple(shape=(100, 100), phase=0.0, freq=np.pi / 4, ampl=1.0): + m, n = shape + y, x = np.ogrid[-m / 2:m / 2, -n / 2:n / 2] + r = np.sqrt(x ** 2 + y ** 2) + z = (ampl * np.sin(freq * r + phase)) / np.sqrt(r + 1) + + return z * 8 + +z = create_ripple() + +# set the clim vmax +max_z = create_ripple(phase=(np.pi / 4) - (np.pi / 2)).max() + +surface = figure[0, 0].add_surface(z, mode="basic", cmap="viridis", clim=(-max_z, max_z)) + +figure[0, 0].camera.show_object(surface.world_object, (-1, 3, -1), up=(0, 0, 1)) +figure.show() + +figure[0, 0].camera.zoom = 1.15 + +phase = 0.0 +def animate(): + global phase + + z = create_ripple(phase=phase) + + surface.data = z + + phase -= 0.1 + +figure[0, 0].add_animations(animate) + + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/surface_ripple/surface_sphere_ripple.py b/examples/surface_ripple/surface_sphere_ripple.py new file mode 100644 index 000000000..d98622e10 --- /dev/null +++ b/examples/surface_ripple/surface_sphere_ripple.py @@ -0,0 +1,79 @@ +""" +Sphere ripple animation +======================= + +Example of a sphere with a ripple effect by setting the data on every render. + +""" + +# test_example = false +# sphinx_gallery_pygfx_docs = 'animate 6s' + +import fastplotlib as fpl +import numpy as np + +figure = fpl.Figure(size=(700, 560), cameras='3d', controller_types='orbit') + +# create an ellipsoid from spherical coordinates +# see this for reference: https://mathworld.wolfram.com/SphericalCoordinates.html +# phi and theta are swapped in this example w.r.t. the wolfram alpha description +radius = 10 +nx = 250 +phi = np.linspace(0, np.pi * 2, num=nx, dtype=np.float32) +ny = 250 +theta = np.linspace(0, np.pi, num=ny, dtype=np.float32) + +phi_grid, theta_grid = np.meshgrid(phi, theta) + +# convert to cartesian coordinates +theta_grid_sin = np.sin(theta_grid) +x = radius * np.cos(phi_grid) * theta_grid_sin * -1 +y = radius * np.cos(theta_grid) + +ripple_amplitude = 1.0 +ripple_frequency = 20.0 +ripple = ripple_amplitude * np.sin(ripple_frequency * theta_grid) + +z_ref = radius * np.sin(phi_grid) * theta_grid_sin +z = z_ref * (1 + ripple / radius) + +sphere = figure[0, 0].add_surface( + np.dstack([x, y, z]), + mode="phong", + colors="red", + cmap="jet", +) + +# display xz plane as a grid +figure[0, 0].axes.grids.xy.visible = True +figure.show() + +figure[0, 0].camera.show_object(sphere.world_object, (10, 1, -1), up=(0, 0, 1)) +figure[0, 0].camera.zoom = 1.3 + + +start = 0 +def animate(): + global start + theta = np.linspace(start, start + np.pi, num=ny, dtype=np.float32) + _, theta_grid = np.meshgrid(phi, theta) + ripple = ripple_amplitude * np.sin(ripple_frequency * theta_grid) + + z = z_ref * (1 + ripple / radius) + + sphere.data = np.dstack([x, y, z]) + + start += 0.005 + + if start > np.pi * 2: + start = 0 + + +figure[0, 0].add_animations(animate) + + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/fastplotlib/graphics/__init__.py b/fastplotlib/graphics/__init__.py index 04fa95001..6e1878f6b 100644 --- a/fastplotlib/graphics/__init__.py +++ b/fastplotlib/graphics/__init__.py @@ -4,7 +4,7 @@ from .image import ImageGraphic from .image_volume import ImageVolumeGraphic from ._vectors import VectorsGraphic -from .mesh import MeshGraphic +from .mesh import MeshGraphic, SurfaceGraphic from .text import TextGraphic from .line_collection import LineCollection, LineStack @@ -17,6 +17,7 @@ "ImageVolumeGraphic", "VectorsGraphic", "MeshGraphic", + "SurfaceGraphic", "TextGraphic", "LineCollection", "LineStack", diff --git a/fastplotlib/graphics/features/__init__.py b/fastplotlib/graphics/features/__init__.py index 87f61ed93..1cf7d654b 100644 --- a/fastplotlib/graphics/features/__init__.py +++ b/fastplotlib/graphics/features/__init__.py @@ -5,7 +5,7 @@ VertexPositions, VertexCmap, ) -from ._mesh import MeshVertexPositions, MeshIndices, MeshCmap, resolve_cmap_mesh +from ._mesh import MeshVertexPositions, MeshIndices, MeshCmap, SurfaceData, resolve_cmap_mesh, surface_data_to_mesh from ._line import Thickness from ._scatter import ( VertexMarkers, @@ -75,6 +75,7 @@ "MeshVertexPositions", "MeshIndices", "MeshCmap", + "SurfaceData", "Thickness", "VertexMarkers", "UniformMarker", diff --git a/fastplotlib/graphics/features/_mesh.py b/fastplotlib/graphics/features/_mesh.py index 38f3529af..9816f9aa5 100644 --- a/fastplotlib/graphics/features/_mesh.py +++ b/fastplotlib/graphics/features/_mesh.py @@ -116,3 +116,91 @@ def set_value( event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) self._call_event_handlers(event) + + +def surface_data_to_mesh(data: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + """ + surface data to mesh positions and indices + + expects data that is of shape: [m, n, 3] or [m, n] + """ + + data = np.asarray(data) + + if data.ndim == 2: + # "image" of z values passed + # [m, n] -> [n_vertices, 3] + y = np.arange(data.shape[0]).reshape(data.shape[0], 1).repeat(data.shape[1], axis=1) + x = np.arange(data.shape[1]).reshape(1, data.shape[1]).repeat(data.shape[0], axis=0) + positions = np.column_stack((x.ravel(), y.ravel(), data.ravel())) + else: + if data.ndim != 3: + raise ValueError( + f"expect data that is of shape: [m, n, 3], [m, n]\n" + f"you passed: {data.shape}" + ) + if data.shape[2] != 3: + raise ValueError( + f"expect data that is of shape: [m, n, 3], [m, n]\n" + f"you passed: {data.shape}" + ) + + # [m, n, 3] -> [n_vertices, 3] + positions = data.reshape(-1, 3) + + # Create faces + w = data.shape[1] + i = np.arange(data.shape[0] - 1) + j = np.arange(w - 1) + + j, i = np.meshgrid(j, i, indexing="ij") + start = j.ravel() + w * i.ravel() + + indices = np.column_stack([start, start + 1, start + w + 1, start + w]) + + return positions, indices + + +class SurfaceData(GraphicFeature): + event_info_spec = [ + { + "dict key": "value", + "type": "np.ndarray", + "description": "new data", + }, + ] + + def __init__(self, value: np.ndarray | Sequence, property_name: str = "data"): + self._value = np.asarray(value, dtype=np.float32) + super().__init__(property_name=property_name) + + @property + def value(self) -> np.ndarray: + return self._value + + @block_reentrance + def set_value(self, graphic, value: np.ndarray): + positions, indices = surface_data_to_mesh(value) + + graphic.world_object.geometry.positions.data[:] = positions + graphic.world_object.geometry.indices.data[:] = indices + + graphic.world_object.geometry.positions.update_full() + graphic.world_object.geometry.indices.update_full() + + # if cmap is a 1D texture we need to set the texcoords again using new z values + if graphic.world_object.material.map is not None: + if graphic.world_object.material.map.texture.dim == 1: + mapcoords = positions[:, 2] + + if graphic.clim is None: + clim = mapcoords.min(), mapcoords.max() + else: + clim = graphic.clim + mapcoords = (mapcoords - clim[0]) / (clim[1] - clim[0]) + graphic.mapcoords = mapcoords + + self._value = value + + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) + self._call_event_handlers(event) diff --git a/fastplotlib/graphics/mesh.py b/fastplotlib/graphics/mesh.py index 480ece04b..7302adf6a 100644 --- a/fastplotlib/graphics/mesh.py +++ b/fastplotlib/graphics/mesh.py @@ -9,6 +9,8 @@ MeshVertexPositions, MeshIndices, MeshCmap, + SurfaceData, + surface_data_to_mesh, VertexColors, UniformColor, resolve_cmap_mesh, @@ -28,7 +30,7 @@ def __init__( self, positions: Any, indices: Any, - mode: Literal["basic", "phong", "slice"], + mode: Literal["basic", "phong", "slice"] = "phong", plane: tuple[float, float, float, float] = (0, 0, 1, 0), colors: str | np.ndarray | Sequence = "w", mapcoords: Any = None, @@ -140,8 +142,8 @@ def __init__( geometry.colors = self._colors.buffer if self._mapcoords is not None: geometry.texcoords = self._mapcoords - elif self._cmap.value is not None: - material.map = resolve_cmap_mesh(self.cmap) + if cmap is not None: + material.map = resolve_cmap_mesh(cmap) # Decide on color mode # uniform = None #: Use the uniform color (usually ``material.color``). @@ -149,7 +151,7 @@ def __init__( # 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 self._cmap.value is not None: + if mapcoords is not None and cmap is not None: material.color_mode = "vertex_map" elif per_vertex_colors: material.color_mode = "vertex" @@ -243,3 +245,105 @@ def plane(self, value: tuple[float, float, float, float]): self._plane.set_value(self, value) + +class SurfaceGraphic(MeshGraphic): + _features = { + "data": MeshVertexPositions, + "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. + + 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) + + if clim is not None: + if len(clim) != 2: + raise ValueError("clim must be a: tuple[float, float]") + + self._clim = tuple(clim) + else: + self._clim = None + + 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 + ) + # 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() + mapcoords = (mapcoords - clim[0]) / (clim[1] - clim[0]) + + super().__init__(positions, indices, mode=mode, colors=colors, mapcoords=mapcoords, cmap=cmap, **kwargs) + + @property + def data(self) -> np.ndarray: + return self._data.value + + @data.setter + def data(self, new_data: np.ndarray): + self._data.set_value(self, new_data) + + @property + def clim(self) -> tuple[float, float] | None: + 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]) + +class Polygon(Graphic): + pass diff --git a/fastplotlib/layouts/_graphic_methods_mixin.py b/fastplotlib/layouts/_graphic_methods_mixin.py index 403cd0a22..3a49483b8 100644 --- a/fastplotlib/layouts/_graphic_methods_mixin.py +++ b/fastplotlib/layouts/_graphic_methods_mixin.py @@ -8,7 +8,6 @@ from ..graphics import * from ..graphics._base import Graphic -from ..graphics.mesh import resolve_cmap as resolve_mesh_cmap class GraphicMethodsMixin: @@ -35,7 +34,7 @@ def add_image( interpolation: str = "nearest", cmap_interpolation: str = "linear", isolated_buffer: bool = True, - **kwargs, + **kwargs ) -> ImageGraphic: """ @@ -83,7 +82,7 @@ def add_image( interpolation, cmap_interpolation, isolated_buffer, - **kwargs, + **kwargs ) def add_image_volume( @@ -102,7 +101,7 @@ def add_image_volume( emissive: str | tuple | numpy.ndarray = (0, 0, 0), shininess: int = 30, isolated_buffer: bool = True, - **kwargs, + **kwargs ) -> ImageVolumeGraphic: """ @@ -185,7 +184,7 @@ def add_image_volume( emissive, shininess, isolated_buffer, - **kwargs, + **kwargs ) def add_line_collection( @@ -202,7 +201,7 @@ def add_line_collection( metadatas: Union[Sequence[Any], numpy.ndarray] = None, isolated_buffer: bool = True, kwargs_lines: list[dict] = None, - **kwargs, + **kwargs ) -> LineCollection: """ @@ -271,7 +270,7 @@ def add_line_collection( metadatas, isolated_buffer, kwargs_lines, - **kwargs, + **kwargs ) def add_line( @@ -284,7 +283,7 @@ def add_line( cmap_transform: Union[numpy.ndarray, Sequence] = None, isolated_buffer: bool = True, size_space: str = "screen", - **kwargs, + **kwargs ) -> LineGraphic: """ @@ -335,7 +334,7 @@ def add_line( cmap_transform, isolated_buffer, size_space, - **kwargs, + **kwargs ) def add_line_stack( @@ -353,7 +352,7 @@ def add_line_stack( separation: float = 10.0, separation_axis: str = "y", kwargs_lines: list[dict] = None, - **kwargs, + **kwargs ) -> LineStack: """ @@ -430,26 +429,33 @@ def add_line_stack( separation, separation_axis, kwargs_lines, - **kwargs, + **kwargs ) def add_mesh( self, positions: Any, indices: Any, - mapcoords: Any = None, + mode: Literal["basic", "phong", "slice"] = "phong", + plane: tuple[float, float, float, float] = (0, 0, 1, 0), colors: Union[str, numpy.ndarray, Sequence] = "w", - cmap: str = None, + mapcoords: Any = None, + cmap: ( + str + | dict + | pygfx.resources._texture.Texture + | pygfx.resources._texturemap.TextureMap + | numpy.ndarray + ) = None, isolated_buffer: bool = True, - **kwargs, + **kwargs ) -> MeshGraphic: """ - Create a mesh Graphic + Create a mesh Graphic. Parameters ---------- - positions: array-like The 3D positions of the vertices. @@ -470,6 +476,7 @@ def add_mesh( "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. **kwargs passed to :class:`.Graphic` @@ -480,159 +487,13 @@ def add_mesh( MeshGraphic, positions, indices, + mode, + plane, colors, mapcoords, cmap, isolated_buffer, - **kwargs, - ) - - def add_surface( - self, - data: Any, - colors: Union[str, numpy.ndarray, Sequence] = "w", - mapcoords: Any = None, - cmap: str = None, - clim: tuple[float, float] | None = None, - isolated_buffer: bool = True, - **kwargs, - ) -> MeshGraphic: - """ - - Create a mesh Graphic - - Parameters - ---------- - data: array-like - A height-map (an image where the values indicate height, i.e. z values). - Can also be a 3-tuple to explicitly specify the x and y values in addition to the z values. - - 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` - - - """ - - def check_z(z): - if z.ndim != 2: - raise ValueError("Z must be a 2D array.") - - # In VisVis (https://github.com/almarklein/visvis/blob/main/visvis/functions/surf.py) - # the tuple can be 2-element or 4-element, to pass a color per z-value. - # I disabled that by commenting related logic. Maybe it can be enabled someday. - - if isinstance(data, (tuple, list)): - if len(data) == 1: - z = numpy.asanyarray(data[0]) - check_z(z) - y = numpy.arange(z.shape[0]) - x = numpy.arange(z.shape[1]) - c = None - # elif len(data) == 2: - # z = numpy.asanyarray(data[0]) - # c = numpy.asanyarray(data[1]) - # check_z(z) - # y = numpy.arange(z.shape[0]) - # x = numpy.arange(z.shape[1]) - elif len(args) == 3: - x = numpy.asanyarray(data[0]) - y = numpy.asanyarray(data[1]) - z = numpy.asanyarray(data[2]) - check_z(z) - c = None - # elif len(args) == 4: - # x = numpy.asanyarray(data[0]) - # y = numpy.asanyarray(data[1]) - # z = numpy.asanyarray(data[2]) - # c = numpy.asanyarray(data[3]) - # check_z(z) - else: - raise ValueError( - "Surface tuple has invalid number of elements (need 1-4)." - ) - else: - z = numpy.asanyarray(data) - check_z(z) - y = numpy.arange(z.shape[0]) - x = numpy.arange(z.shape[1]) - c = None - - # Set y vertices - if y.shape == (z.shape[0],): - y = y.reshape(z.shape[0], 1).repeat(z.shape[1], axis=1) - elif y.shape != z.shape: - raise ValueError( - "Y must have same shape as Z, or be 1D with length of rows of Z." - ) - - # Set x vertices - if x.shape == (z.shape[1],): - x = x.reshape(1, z.shape[1]).repeat(z.shape[0], axis=0) - elif x.shape != z.shape: - raise ValueError( - "X must have same shape as Z, or be 1D with length of columns of Z." - ) - - # Set vertices - positions = numpy.column_stack((x.ravel(), y.ravel(), z.ravel())) - - # Create texcoords - cmap = resolve_mesh_cmap(cmap) - if mapcoords is None: - if cmap.texture.dim == 1: # 1d - mapcoords = z.ravel() - elif cmap.texture.dim == 2: - mapcoords = numpy.column_stack((x.ravel(), y.ravel())).astype( - numpy.float32 - ) - # 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() - mapcoords = (mapcoords - clim[0]) / (clim[1] - clim[0]) - else: - raise ValueError("C must have same shape as Z, or be 3D array.") - - # Create faces - w = z.shape[1] - i = numpy.arange(z.shape[0] - 1) - indices = numpy.row_stack( - [ - numpy.column_stack( - (j + w * i, j + 1 + w * i, j + 1 + w * (i + 1), j + w * (i + 1)) - ) - for j in range(w - 1) - ] - ) - indices = indices.astype("i4") - - return self._create_graphic( - MeshGraphic, - positions, - indices, - colors, - mapcoords, - cmap, - isolated_buffer, - **kwargs, + **kwargs ) def add_scatter( @@ -658,7 +519,7 @@ def add_scatter( uniform_size: bool = False, size_space: str = "screen", isolated_buffer: bool = True, - **kwargs, + **kwargs ) -> ScatterGraphic: """ @@ -786,7 +647,60 @@ def add_scatter( uniform_size, size_space, isolated_buffer, - **kwargs, + **kwargs + ) + + def add_surface( + self, + data: numpy.ndarray, + mode: Literal["basic", "phong", "slice"] = "phong", + colors: Union[str, numpy.ndarray, Sequence] = "w", + mapcoords: Any = None, + cmap: ( + str + | dict + | pygfx.resources._texture.Texture + | pygfx.resources._texturemap.TextureMap + | numpy.ndarray + ) = None, + clim: tuple[float, float] | None = None, + **kwargs + ) -> SurfaceGraphic: + """ + + 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. + + 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` + + + """ + return self._create_graphic( + SurfaceGraphic, data, mode, colors, mapcoords, cmap, clim, **kwargs ) def add_text( @@ -799,7 +713,7 @@ def add_text( screen_space: bool = True, offset: tuple[float] = (0, 0, 0), anchor: str = "middle-center", - **kwargs, + **kwargs ) -> TextGraphic: """ @@ -850,7 +764,7 @@ def add_text( screen_space, offset, anchor, - **kwargs, + **kwargs ) def add_vectors( @@ -860,7 +774,7 @@ def add_vectors( color: Union[str, Sequence[float], numpy.ndarray] = "w", size: float = None, vector_shape_options: dict = None, - **kwargs, + **kwargs ) -> VectorsGraphic: """ @@ -905,5 +819,5 @@ def add_vectors( color, size, vector_shape_options, - **kwargs, + **kwargs ) From d4d36c6d2dd38578ac4ae534baa333e606fb2964 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 30 Nov 2025 23:43:57 -0500 Subject: [PATCH 03/14] update, add PolygonGraphic, works --- .../{surface_ripple => mesh}/image_surface.py | 0 examples/{surface_ripple => mesh}/mesh.py | 0 examples/mesh/polygons.py | 51 ++++++++++ .../{surface_ripple => mesh}/surface_earth.py | 0 .../surface_ellipsoid.py | 2 +- .../surface_gaussian.py | 0 .../surface_height.py | 0 .../surface_ripple.py | 0 .../surface_sphere_ripple.py | 0 fastplotlib/graphics/__init__.py | 3 +- fastplotlib/graphics/features/__init__.py | 2 +- fastplotlib/graphics/features/_mesh.py | 82 +++++++++++++++- fastplotlib/graphics/features/utils.py | 5 +- fastplotlib/graphics/mesh.py | 97 ++++++++++++++++--- fastplotlib/layouts/_graphic_methods_mixin.py | 35 +++++++ 15 files changed, 255 insertions(+), 22 deletions(-) rename examples/{surface_ripple => mesh}/image_surface.py (100%) rename examples/{surface_ripple => mesh}/mesh.py (100%) create mode 100644 examples/mesh/polygons.py rename examples/{surface_ripple => mesh}/surface_earth.py (100%) rename examples/{surface_ripple => mesh}/surface_ellipsoid.py (98%) rename examples/{surface_ripple => mesh}/surface_gaussian.py (100%) rename examples/{surface_ripple => mesh}/surface_height.py (100%) rename examples/{surface_ripple => mesh}/surface_ripple.py (100%) rename examples/{surface_ripple => mesh}/surface_sphere_ripple.py (100%) diff --git a/examples/surface_ripple/image_surface.py b/examples/mesh/image_surface.py similarity index 100% rename from examples/surface_ripple/image_surface.py rename to examples/mesh/image_surface.py diff --git a/examples/surface_ripple/mesh.py b/examples/mesh/mesh.py similarity index 100% rename from examples/surface_ripple/mesh.py rename to examples/mesh/mesh.py diff --git a/examples/mesh/polygons.py b/examples/mesh/polygons.py new file mode 100644 index 000000000..b93c4840a --- /dev/null +++ b/examples/mesh/polygons.py @@ -0,0 +1,51 @@ +""" +Polygons +====================== + +An example with polygons. + +""" + +# test_example = True +# sphinx_gallery_pygfx_docs = 'screenshot' + +import fastplotlib as fpl +import numpy as np +from cmap import Colormap + +figure = fpl.Figure(size=(700, 560)) + +def make_circle(center, radius: float, n_points: int = 75) -> np.ndarray: + theta = np.linspace(0, 2 * np.pi, n_points, endpoint=False) + xs = radius * np.sin(theta) + ys = radius * np.cos(theta) + + return np.column_stack([xs, ys]) + np.asarray(center)[None] + + +circle_data = make_circle(center=(0, 0), radius=5) +octogon_data = make_circle(center=(15, 0), radius=7, n_points=8) + +rectangle_data = np.array([ + [10, 10], + [15, 10], + [15, 15], + [10, 15] +]) + +triangle_data = np.array([ + [0, 10], + [5, 10], + [2.5, 15], + [0, 10], +]) + + +figure[0, 0].add_polygon(circle_data, name="circle") +figure[0, 0].add_polygon(octogon_data, colors=Colormap("jet").lut(8), name="octogon") +figure[0, 0].add_polygon(rectangle_data, colors=["r", "r", "cyan", "y"], name="rectangle") +figure[0, 0].add_polygon(triangle_data, colors="m") + +figure.show() + +fpl.loop.run() diff --git a/examples/surface_ripple/surface_earth.py b/examples/mesh/surface_earth.py similarity index 100% rename from examples/surface_ripple/surface_earth.py rename to examples/mesh/surface_earth.py diff --git a/examples/surface_ripple/surface_ellipsoid.py b/examples/mesh/surface_ellipsoid.py similarity index 98% rename from examples/surface_ripple/surface_ellipsoid.py rename to examples/mesh/surface_ellipsoid.py index 9bd2c2f04..e1c873f75 100644 --- a/examples/surface_ripple/surface_ellipsoid.py +++ b/examples/mesh/surface_ellipsoid.py @@ -1,6 +1,6 @@ """ Ellipsoid surface -====================== +================= Simple example of a sphere surface mesh with a colormap indicating z values. diff --git a/examples/surface_ripple/surface_gaussian.py b/examples/mesh/surface_gaussian.py similarity index 100% rename from examples/surface_ripple/surface_gaussian.py rename to examples/mesh/surface_gaussian.py diff --git a/examples/surface_ripple/surface_height.py b/examples/mesh/surface_height.py similarity index 100% rename from examples/surface_ripple/surface_height.py rename to examples/mesh/surface_height.py diff --git a/examples/surface_ripple/surface_ripple.py b/examples/mesh/surface_ripple.py similarity index 100% rename from examples/surface_ripple/surface_ripple.py rename to examples/mesh/surface_ripple.py diff --git a/examples/surface_ripple/surface_sphere_ripple.py b/examples/mesh/surface_sphere_ripple.py similarity index 100% rename from examples/surface_ripple/surface_sphere_ripple.py rename to examples/mesh/surface_sphere_ripple.py diff --git a/fastplotlib/graphics/__init__.py b/fastplotlib/graphics/__init__.py index 6e1878f6b..3d01e4a35 100644 --- a/fastplotlib/graphics/__init__.py +++ b/fastplotlib/graphics/__init__.py @@ -4,7 +4,7 @@ from .image import ImageGraphic from .image_volume import ImageVolumeGraphic from ._vectors import VectorsGraphic -from .mesh import MeshGraphic, SurfaceGraphic +from .mesh import MeshGraphic, SurfaceGraphic, PolygonGraphic from .text import TextGraphic from .line_collection import LineCollection, LineStack @@ -18,6 +18,7 @@ "VectorsGraphic", "MeshGraphic", "SurfaceGraphic", + "PolygonGraphic", "TextGraphic", "LineCollection", "LineStack", diff --git a/fastplotlib/graphics/features/__init__.py b/fastplotlib/graphics/features/__init__.py index 1cf7d654b..752f812e8 100644 --- a/fastplotlib/graphics/features/__init__.py +++ b/fastplotlib/graphics/features/__init__.py @@ -5,7 +5,7 @@ VertexPositions, VertexCmap, ) -from ._mesh import MeshVertexPositions, MeshIndices, MeshCmap, SurfaceData, resolve_cmap_mesh, surface_data_to_mesh +from ._mesh import MeshVertexPositions, MeshIndices, MeshCmap, SurfaceData, PolygonData, resolve_cmap_mesh, surface_data_to_mesh, triangulate_polygon from ._line import Thickness from ._scatter import ( VertexMarkers, diff --git a/fastplotlib/graphics/features/_mesh.py b/fastplotlib/graphics/features/_mesh.py index 9816f9aa5..dd996de0f 100644 --- a/fastplotlib/graphics/features/_mesh.py +++ b/fastplotlib/graphics/features/_mesh.py @@ -12,6 +12,7 @@ from ._positions import VertexPositions from ...utils.functions import get_cmap +from ...utils.triangulation import triangulate def resolve_cmap_mesh(cmap) -> pygfx.TextureMap | None: @@ -75,7 +76,9 @@ def __init__( ) def _fix_data(self, data): - if data.ndim != 2 or data.shape[1] not in (3, 4): + if data.shape == (3,): + pass + elif data.ndim != 2 or data.shape[1] not in (3, 4): raise ValueError( f"indices must be of shape: [n_vertices, 3] or [n_vertices, 4], " f"you passed an array of shape: {data.shape}" @@ -166,7 +169,7 @@ class SurfaceData(GraphicFeature): { "dict key": "value", "type": "np.ndarray", - "description": "new data", + "description": "new surface data", }, ] @@ -204,3 +207,78 @@ def set_value(self, graphic, value: np.ndarray): event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) self._call_event_handlers(event) + + +def triangulate_polygon(data: np.ndarray | Sequence): + """vertices of shape [n_vertices , 2] -> positions, indices""" + data = np.asarray(data, dtype=np.float32) + + err_msg = f"polygon vertex data must be of shape [n_vertices, 2], you passed: {data}" + + if data.ndim != 2: + raise ValueError(err_msg) + if data.shape[1] != 2: + raise ValueError(err_msg) + + if len(data) >= 3: + indices = triangulate(data) + else: + indices = np.arange((0, 3), np.int32) + + data = np.column_stack([data, np.zeros(data.shape[0], dtype=np.float32)]) + + return data, indices + + + +class PolygonData(GraphicFeature): + event_info_spec = [ + { + "dict key": "value", + "type": "np.ndarray", + "description": "new polygon vertex data", + }, + ] + + def __init__(self, value: np.ndarray, property_name: str = "data"): + self._value = np.asarray(value, dtype=np.float32) + super().__init__(property_name=property_name) + + @property + def value(self) -> np.ndarray: + return self._value + + @block_reentrance + def set_value(self, graphic, value: np.ndarray | Sequence): + value = np.asarray(value, dtype=np.float32) + + positions, indices = triangulate_polygon(value) + + geometry = graphic.geometry + + # Need larger buffer? + if len(value) > geometry.positions.nitems: + arr = np.zeros((geometry.positions.nitems * 2, 3), np.float32) + geometry.positions = pygfx.Buffer(arr) + if len(indices) > geometry.indices.nitems: + arr = np.zeros((geometry.indices.nitems * 2, 3), np.int32) + geometry.indices = pygfx.Buffer(arr) + + geometry.positions.data[: len(value)] = value + geometry.positions.data[len(value):] = value[-1] if len(value) else (0, 0, 0) + geometry.positions.draw_range = 0, len(value) + geometry.positions.update_full() + + geometry.indices.data[: len(indices)] = indices + geometry.indices.data[len(indices):] = 0 + geometry.indices.draw_range = 0, len(indices) + geometry.indices.update_full() + + # send event + if len(self._event_handlers) < 1: + return + + event = GraphicFeatureEvent(self._property_name, {"value": self.value}) + + # calls any events + self._call_event_handlers(event) diff --git a/fastplotlib/graphics/features/utils.py b/fastplotlib/graphics/features/utils.py index 408610e1e..aa4022052 100644 --- a/fastplotlib/graphics/features/utils.py +++ b/fastplotlib/graphics/features/utils.py @@ -34,8 +34,9 @@ def parse_colors( elif colors.ndim == 2: if not (colors.shape[1] in (3, 4) and colors.shape[0] == n_colors): raise ValueError( - "Valid array color arguments must be a single RGBA array or a stack of " - "RGB or RGBA arrays for each datapoint in the shape [n_datapoints, 3] or [n_datapoints, 4]" + f"Valid array color arguments must be a single RGBA array or a stack of " + f"RGB or RGBA arrays for each datapoint in the shape [n_datapoints, 3] or [n_datapoints, 4].\n" + f"n_datapoints is: {n_colors}, you passed a colors array of shape: {colors.shape}" ) data = colors else: diff --git a/fastplotlib/graphics/mesh.py b/fastplotlib/graphics/mesh.py index 7302adf6a..e128bd330 100644 --- a/fastplotlib/graphics/mesh.py +++ b/fastplotlib/graphics/mesh.py @@ -15,6 +15,8 @@ UniformColor, resolve_cmap_mesh, VolumeSlicePlane, + PolygonData, + triangulate_polygon, ) @@ -248,7 +250,7 @@ def plane(self, value: tuple[float, float, float, float]): class SurfaceGraphic(MeshGraphic): _features = { - "data": MeshVertexPositions, + "data": SurfaceData, "colors": (VertexColors, UniformColor), "cmap": MeshCmap, } @@ -309,18 +311,18 @@ def __init__( 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 - ) - # 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() - mapcoords = (mapcoords - clim[0]) / (clim[1] - clim[0]) + 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 + ) + # 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() + mapcoords = (mapcoords - clim[0]) / (clim[1] - clim[0]) super().__init__(positions, indices, mode=mode, colors=colors, mapcoords=mapcoords, cmap=cmap, **kwargs) @@ -345,5 +347,70 @@ def clim(self, new_clim: tuple[float, float]): self.mapcoords = (self.mapcoords - self.clim[0]) / (self.clim[1] - self.clim[0]) -class Polygon(Graphic): - pass + +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 + + Parameters + ---------- + data + mode + colors + mapcoords + cmap + clim + kwargs + """ + pass + + positions, indices = triangulate_polygon(data) + + self._data = PolygonData(positions) + + if clim is not None: + if len(clim) != 2: + raise ValueError("clim must be a: tuple[float, float]") + + self._clim = tuple(clim) + else: + self._clim = None + + super().__init__(positions, indices, mode=mode, colors=colors, mapcoords=mapcoords, cmap=cmap, **kwargs) + + @property + def data(self) -> np.ndarray: + 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: + 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]) diff --git a/fastplotlib/layouts/_graphic_methods_mixin.py b/fastplotlib/layouts/_graphic_methods_mixin.py index 3a49483b8..dc6baf2d2 100644 --- a/fastplotlib/layouts/_graphic_methods_mixin.py +++ b/fastplotlib/layouts/_graphic_methods_mixin.py @@ -496,6 +496,41 @@ def add_mesh( **kwargs ) + def add_polygon( + self, + data: numpy.ndarray, + mode: Literal["basic", "phong"] = "basic", + colors: Union[str, numpy.ndarray, Sequence] = "w", + mapcoords: Any = None, + cmap: ( + str + | dict + | pygfx.resources._texture.Texture + | pygfx.resources._texturemap.TextureMap + | numpy.ndarray + ) = None, + clim: tuple[float, float] | None = None, + **kwargs + ) -> PolygonGraphic: + """ + + Create a polygon mesh graphic + + Parameters + ---------- + data + mode + colors + mapcoords + cmap + clim + kwargs + + """ + return self._create_graphic( + PolygonGraphic, data, mode, colors, mapcoords, cmap, clim, **kwargs + ) + def add_scatter( self, data: Any, From ad51df6804c97663c424d9ede5cbd45bc1ff512a Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 30 Nov 2025 23:45:16 -0500 Subject: [PATCH 04/14] black --- fastplotlib/graphics/features/__init__.py | 11 ++++- fastplotlib/graphics/features/_mesh.py | 38 ++++++++++++------ fastplotlib/graphics/mesh.py | 25 ++++++++++-- fastplotlib/layouts/_graphic_methods_mixin.py | 40 +++++++++---------- 4 files changed, 78 insertions(+), 36 deletions(-) diff --git a/fastplotlib/graphics/features/__init__.py b/fastplotlib/graphics/features/__init__.py index 752f812e8..cb0b3ab2e 100644 --- a/fastplotlib/graphics/features/__init__.py +++ b/fastplotlib/graphics/features/__init__.py @@ -5,7 +5,16 @@ VertexPositions, VertexCmap, ) -from ._mesh import MeshVertexPositions, MeshIndices, MeshCmap, SurfaceData, PolygonData, resolve_cmap_mesh, surface_data_to_mesh, triangulate_polygon +from ._mesh import ( + MeshVertexPositions, + MeshIndices, + MeshCmap, + SurfaceData, + PolygonData, + resolve_cmap_mesh, + surface_data_to_mesh, + triangulate_polygon, +) from ._line import Thickness from ._scatter import ( VertexMarkers, diff --git a/fastplotlib/graphics/features/_mesh.py b/fastplotlib/graphics/features/_mesh.py index dd996de0f..a125e5723 100644 --- a/fastplotlib/graphics/features/_mesh.py +++ b/fastplotlib/graphics/features/_mesh.py @@ -39,8 +39,9 @@ def resolve_cmap_mesh(cmap) -> pygfx.TextureMap | None: class MeshVertexPositions(VertexPositions): """Manages mesh vertex positions, same as VertexPosition but data must be of shape [n, 3]""" + def _fix_data(self, data): - if data.ndim != 2 or data.shape[1] !=3: + if data.ndim != 2 or data.shape[1] != 3: raise ValueError( f"mesh vertex positions must be of shape: [n_vertices, 3], you passed an array of shape: {data.shape}" ) @@ -97,9 +98,9 @@ class MeshCmap(GraphicFeature): ] def __init__( - self, - value: str | dict | pygfx.TextureMap | pygfx.Texture | np.ndarray | None, - property_name: str = "cmap", + self, + value: str | dict | pygfx.TextureMap | pygfx.Texture | np.ndarray | None, + property_name: str = "cmap", ): """Manages a mesh colormap""" @@ -107,12 +108,16 @@ def __init__( super().__init__(property_name=property_name) @property - def value(self) -> str | dict | pygfx.TextureMap | pygfx.Texture | np.ndarray | None: + def value( + self, + ) -> str | dict | pygfx.TextureMap | pygfx.Texture | np.ndarray | None: return self._value @block_reentrance def set_value( - self, graphic, value: str | dict | pygfx.TextureMap | pygfx.Texture | np.ndarray | None + self, + graphic, + value: str | dict | pygfx.TextureMap | pygfx.Texture | np.ndarray | None, ): graphic.world_object.material.map = resolve_cmap_mesh(value) self._value = value @@ -133,8 +138,16 @@ def surface_data_to_mesh(data: np.ndarray) -> tuple[np.ndarray, np.ndarray]: if data.ndim == 2: # "image" of z values passed # [m, n] -> [n_vertices, 3] - y = np.arange(data.shape[0]).reshape(data.shape[0], 1).repeat(data.shape[1], axis=1) - x = np.arange(data.shape[1]).reshape(1, data.shape[1]).repeat(data.shape[0], axis=0) + y = ( + np.arange(data.shape[0]) + .reshape(data.shape[0], 1) + .repeat(data.shape[1], axis=1) + ) + x = ( + np.arange(data.shape[1]) + .reshape(1, data.shape[1]) + .repeat(data.shape[0], axis=0) + ) positions = np.column_stack((x.ravel(), y.ravel(), data.ravel())) else: if data.ndim != 3: @@ -213,7 +226,9 @@ def triangulate_polygon(data: np.ndarray | Sequence): """vertices of shape [n_vertices , 2] -> positions, indices""" data = np.asarray(data, dtype=np.float32) - err_msg = f"polygon vertex data must be of shape [n_vertices, 2], you passed: {data}" + err_msg = ( + f"polygon vertex data must be of shape [n_vertices, 2], you passed: {data}" + ) if data.ndim != 2: raise ValueError(err_msg) @@ -230,7 +245,6 @@ def triangulate_polygon(data: np.ndarray | Sequence): return data, indices - class PolygonData(GraphicFeature): event_info_spec = [ { @@ -265,12 +279,12 @@ def set_value(self, graphic, value: np.ndarray | Sequence): geometry.indices = pygfx.Buffer(arr) geometry.positions.data[: len(value)] = value - geometry.positions.data[len(value):] = value[-1] if len(value) else (0, 0, 0) + geometry.positions.data[len(value) :] = value[-1] if len(value) else (0, 0, 0) geometry.positions.draw_range = 0, len(value) geometry.positions.update_full() geometry.indices.data[: len(indices)] = indices - geometry.indices.data[len(indices):] = 0 + geometry.indices.data[len(indices) :] = 0 geometry.indices.draw_range = 0, len(indices) geometry.indices.update_full() diff --git a/fastplotlib/graphics/mesh.py b/fastplotlib/graphics/mesh.py index e128bd330..64abbd452 100644 --- a/fastplotlib/graphics/mesh.py +++ b/fastplotlib/graphics/mesh.py @@ -229,7 +229,10 @@ def cmap(self) -> str | dict | pygfx.Texture | pygfx.TextureMap | np.ndarray | N return self._cmap.value @cmap.setter - def cmap(self, new_cmap: str | dict | pygfx.Texture | pygfx.TextureMap | np.ndarray | None): + def cmap( + self, + new_cmap: str | dict | pygfx.Texture | pygfx.TextureMap | np.ndarray | None, + ): self._cmap.set_value(self, new_cmap) @property @@ -324,7 +327,15 @@ def __init__( clim = mapcoords.min(), mapcoords.max() mapcoords = (mapcoords - clim[0]) / (clim[1] - clim[0]) - super().__init__(positions, indices, mode=mode, colors=colors, mapcoords=mapcoords, cmap=cmap, **kwargs) + super().__init__( + positions, + indices, + mode=mode, + colors=colors, + mapcoords=mapcoords, + cmap=cmap, + **kwargs, + ) @property def data(self) -> np.ndarray: @@ -392,7 +403,15 @@ def __init__( else: self._clim = None - super().__init__(positions, indices, mode=mode, colors=colors, mapcoords=mapcoords, cmap=cmap, **kwargs) + super().__init__( + positions, + indices, + mode=mode, + colors=colors, + mapcoords=mapcoords, + cmap=cmap, + **kwargs, + ) @property def data(self) -> np.ndarray: diff --git a/fastplotlib/layouts/_graphic_methods_mixin.py b/fastplotlib/layouts/_graphic_methods_mixin.py index dc6baf2d2..e13661b85 100644 --- a/fastplotlib/layouts/_graphic_methods_mixin.py +++ b/fastplotlib/layouts/_graphic_methods_mixin.py @@ -34,7 +34,7 @@ def add_image( interpolation: str = "nearest", cmap_interpolation: str = "linear", isolated_buffer: bool = True, - **kwargs + **kwargs, ) -> ImageGraphic: """ @@ -82,7 +82,7 @@ def add_image( interpolation, cmap_interpolation, isolated_buffer, - **kwargs + **kwargs, ) def add_image_volume( @@ -101,7 +101,7 @@ def add_image_volume( emissive: str | tuple | numpy.ndarray = (0, 0, 0), shininess: int = 30, isolated_buffer: bool = True, - **kwargs + **kwargs, ) -> ImageVolumeGraphic: """ @@ -184,7 +184,7 @@ def add_image_volume( emissive, shininess, isolated_buffer, - **kwargs + **kwargs, ) def add_line_collection( @@ -201,7 +201,7 @@ def add_line_collection( metadatas: Union[Sequence[Any], numpy.ndarray] = None, isolated_buffer: bool = True, kwargs_lines: list[dict] = None, - **kwargs + **kwargs, ) -> LineCollection: """ @@ -270,7 +270,7 @@ def add_line_collection( metadatas, isolated_buffer, kwargs_lines, - **kwargs + **kwargs, ) def add_line( @@ -283,7 +283,7 @@ def add_line( cmap_transform: Union[numpy.ndarray, Sequence] = None, isolated_buffer: bool = True, size_space: str = "screen", - **kwargs + **kwargs, ) -> LineGraphic: """ @@ -334,7 +334,7 @@ def add_line( cmap_transform, isolated_buffer, size_space, - **kwargs + **kwargs, ) def add_line_stack( @@ -352,7 +352,7 @@ def add_line_stack( separation: float = 10.0, separation_axis: str = "y", kwargs_lines: list[dict] = None, - **kwargs + **kwargs, ) -> LineStack: """ @@ -429,7 +429,7 @@ def add_line_stack( separation, separation_axis, kwargs_lines, - **kwargs + **kwargs, ) def add_mesh( @@ -448,7 +448,7 @@ def add_mesh( | numpy.ndarray ) = None, isolated_buffer: bool = True, - **kwargs + **kwargs, ) -> MeshGraphic: """ @@ -493,7 +493,7 @@ def add_mesh( mapcoords, cmap, isolated_buffer, - **kwargs + **kwargs, ) def add_polygon( @@ -510,7 +510,7 @@ def add_polygon( | numpy.ndarray ) = None, clim: tuple[float, float] | None = None, - **kwargs + **kwargs, ) -> PolygonGraphic: """ @@ -554,7 +554,7 @@ def add_scatter( uniform_size: bool = False, size_space: str = "screen", isolated_buffer: bool = True, - **kwargs + **kwargs, ) -> ScatterGraphic: """ @@ -682,7 +682,7 @@ def add_scatter( uniform_size, size_space, isolated_buffer, - **kwargs + **kwargs, ) def add_surface( @@ -699,7 +699,7 @@ def add_surface( | numpy.ndarray ) = None, clim: tuple[float, float] | None = None, - **kwargs + **kwargs, ) -> SurfaceGraphic: """ @@ -748,7 +748,7 @@ def add_text( screen_space: bool = True, offset: tuple[float] = (0, 0, 0), anchor: str = "middle-center", - **kwargs + **kwargs, ) -> TextGraphic: """ @@ -799,7 +799,7 @@ def add_text( screen_space, offset, anchor, - **kwargs + **kwargs, ) def add_vectors( @@ -809,7 +809,7 @@ def add_vectors( color: Union[str, Sequence[float], numpy.ndarray] = "w", size: float = None, vector_shape_options: dict = None, - **kwargs + **kwargs, ) -> VectorsGraphic: """ @@ -854,5 +854,5 @@ def add_vectors( color, size, vector_shape_options, - **kwargs + **kwargs, ) From db46af388b452f5415929e8cc8f90c9a710bfe47 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 30 Nov 2025 23:49:33 -0500 Subject: [PATCH 05/14] for docs and test screenshots --- examples/mesh/README.rst | 2 ++ examples/tests/testutils.py | 1 + 2 files changed, 3 insertions(+) create mode 100644 examples/mesh/README.rst diff --git a/examples/mesh/README.rst b/examples/mesh/README.rst new file mode 100644 index 000000000..99e569fed --- /dev/null +++ b/examples/mesh/README.rst @@ -0,0 +1,2 @@ +Mesh Examples +============= diff --git a/examples/tests/testutils.py b/examples/tests/testutils.py index 7b70defdb..18ad8ed41 100644 --- a/examples/tests/testutils.py +++ b/examples/tests/testutils.py @@ -25,6 +25,7 @@ "line/*.py", "line_collection/*.py", "vectors/*.py" + "mesh/*.py", "gridplot/*.py", "window_layouts/*.py", "events/*.py", From 3f666594bad069432b0ef5c3a40f6df232ca0bfa Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 30 Nov 2025 23:49:50 -0500 Subject: [PATCH 06/14] black --- examples/mesh/image_surface.py | 2 +- examples/mesh/mesh.py | 2 +- examples/mesh/polygons.py | 26 +++++++++++++------------- examples/mesh/surface_earth.py | 21 +++++++++++++++++---- examples/mesh/surface_ellipsoid.py | 4 ++-- examples/mesh/surface_gaussian.py | 15 ++++++++++++--- examples/mesh/surface_height.py | 4 ++-- examples/mesh/surface_ripple.py | 16 +++++++++++----- examples/mesh/surface_sphere_ripple.py | 4 +++- 9 files changed, 62 insertions(+), 32 deletions(-) diff --git a/examples/mesh/image_surface.py b/examples/mesh/image_surface.py index 36332e69b..31c872f37 100644 --- a/examples/mesh/image_surface.py +++ b/examples/mesh/image_surface.py @@ -14,7 +14,7 @@ im = iio.imread("imageio:astronaut.png") -figure = fpl.Figure(size=(700, 560), cameras='3d', controller_types='orbit') +figure = fpl.Figure(size=(700, 560), cameras="3d", controller_types="orbit") # Create the height map from the image diff --git a/examples/mesh/mesh.py b/examples/mesh/mesh.py index 150ac9cf1..4c8de088d 100644 --- a/examples/mesh/mesh.py +++ b/examples/mesh/mesh.py @@ -12,7 +12,7 @@ import pygfx as gfx -figure = fpl.Figure(size=(700, 560), cameras='3d', controller_types='orbit') +figure = fpl.Figure(size=(700, 560), cameras="3d", controller_types="orbit") # Load geometry using Pygfx's geometry util diff --git a/examples/mesh/polygons.py b/examples/mesh/polygons.py index b93c4840a..1c287a9e2 100644 --- a/examples/mesh/polygons.py +++ b/examples/mesh/polygons.py @@ -15,6 +15,7 @@ figure = fpl.Figure(size=(700, 560)) + def make_circle(center, radius: float, n_points: int = 75) -> np.ndarray: theta = np.linspace(0, 2 * np.pi, n_points, endpoint=False) xs = radius * np.sin(theta) @@ -26,24 +27,23 @@ def make_circle(center, radius: float, n_points: int = 75) -> np.ndarray: circle_data = make_circle(center=(0, 0), radius=5) octogon_data = make_circle(center=(15, 0), radius=7, n_points=8) -rectangle_data = np.array([ - [10, 10], - [15, 10], - [15, 15], - [10, 15] -]) +rectangle_data = np.array([[10, 10], [15, 10], [15, 15], [10, 15]]) -triangle_data = np.array([ - [0, 10], - [5, 10], - [2.5, 15], - [0, 10], -]) +triangle_data = np.array( + [ + [0, 10], + [5, 10], + [2.5, 15], + [0, 10], + ] +) figure[0, 0].add_polygon(circle_data, name="circle") figure[0, 0].add_polygon(octogon_data, colors=Colormap("jet").lut(8), name="octogon") -figure[0, 0].add_polygon(rectangle_data, colors=["r", "r", "cyan", "y"], name="rectangle") +figure[0, 0].add_polygon( + rectangle_data, colors=["r", "r", "cyan", "y"], name="rectangle" +) figure[0, 0].add_polygon(triangle_data, colors="m") figure.show() diff --git a/examples/mesh/surface_earth.py b/examples/mesh/surface_earth.py index 22d964a93..c2e137bc8 100644 --- a/examples/mesh/surface_earth.py +++ b/examples/mesh/surface_earth.py @@ -16,7 +16,7 @@ import pylinalg as la -figure = fpl.Figure(size=(700, 560), cameras='3d', controller_types='orbit') +figure = fpl.Figure(size=(700, 560), cameras="3d", controller_types="orbit") # create a sphere from spherical coordinates # see this for reference: https://mathworld.wolfram.com/SphericalCoordinates.html @@ -41,12 +41,20 @@ texcoords = np.dstack([u, v]).reshape(-1, 2) # get an image of the earth from nasa -image = iio.imread("https://svs.gsfc.nasa.gov/vis/a000000/a003600/a003615/flat_earth_Largest_still.0330.jpg") +image = iio.imread( + "https://svs.gsfc.nasa.gov/vis/a000000/a003600/a003615/flat_earth_Largest_still.0330.jpg" +) # images coordinate systems are typically inverted in y, so flip the image image = np.ascontiguousarray(np.flipud(image)) # create a sphere -sphere = figure[0, 0].add_surface(np.dstack([x, y, z]), mode="phong", colors="magenta", cmap=image, mapcoords=texcoords) +sphere = figure[0, 0].add_surface( + np.dstack([x, y, z]), + mode="phong", + colors="magenta", + cmap=image, + mapcoords=texcoords, +) # display xz plane as a grid figure[0, 0].axes.grids.xz.visible = True @@ -60,9 +68,13 @@ axial_tilt = la.quat_from_euler((np.radians(23.44), 0), order="XY") # a line to indicate the axial tilt -figure[0, 0].add_line(np.array([[0, -20, 0], [0, 20, 0]]), rotation=axial_tilt, colors="magenta") +figure[0, 0].add_line( + np.array([[0, -20, 0], [0, 20, 0]]), rotation=axial_tilt, colors="magenta" +) rot = 1 + + def rotate(): # rotate by 1 degree global rot @@ -72,6 +84,7 @@ def rotate(): # apply rotation w.r.t. axial tilt sphere.rotation = la.quat_mul(axial_tilt, rot_quat) + figure[0, 0].add_animations(rotate) diff --git a/examples/mesh/surface_ellipsoid.py b/examples/mesh/surface_ellipsoid.py index e1c873f75..6d7cdae7b 100644 --- a/examples/mesh/surface_ellipsoid.py +++ b/examples/mesh/surface_ellipsoid.py @@ -12,7 +12,7 @@ import fastplotlib as fpl import numpy as np -figure = fpl.Figure(size=(700, 560), cameras='3d', controller_types='orbit') +figure = fpl.Figure(size=(700, 560), cameras="3d", controller_types="orbit") # create an ellipsoid from spherical coordinates # see this for reference: https://mathworld.wolfram.com/SphericalCoordinates.html @@ -37,7 +37,7 @@ sphere = figure[0, 0].add_surface( np.dstack([x, y, z]), mode="phong", - cmap="bwr", # by default, providing a colormap name will map the colors to z values + cmap="bwr", # by default, providing a colormap name will map the colors to z values ) # display xz plane as a grid diff --git a/examples/mesh/surface_gaussian.py b/examples/mesh/surface_gaussian.py index f5d1d0cac..5d7e7b777 100644 --- a/examples/mesh/surface_gaussian.py +++ b/examples/mesh/surface_gaussian.py @@ -12,17 +12,26 @@ import numpy as np -figure = fpl.Figure(size=(700, 560), cameras='3d', controller_types='orbit') +figure = fpl.Figure(size=(700, 560), cameras="3d", controller_types="orbit") + def gaus2d(x=0, y=0, mx=0, my=0, sx=1, sy=1): - return 1. / (2. * np.pi * sx * sy) * np.exp(-((x - mx)**2. / (2. * sx**2.) + (y - my)**2. / (2. * sy**2.))) + return ( + 1.0 + / (2.0 * np.pi * sx * sy) + * np.exp( + -((x - mx) ** 2.0 / (2.0 * sx**2.0) + (y - my) ** 2.0 / (2.0 * sy**2.0)) + ) + ) r = np.linspace(0, 10, num=200) x, y = np.meshgrid(r, r) z = gaus2d(x, y, mx=5, my=5, sx=1, sy=1) * 50 -mesh = figure[0, 0].add_surface(np.dstack([x, y, z]), mode="phong", colors="magenta", cmap='jet') +mesh = figure[0, 0].add_surface( + np.dstack([x, y, z]), mode="phong", colors="magenta", cmap="jet" +) # figure[0, 0].axes.grids.xy.visible = True figure[0, 0].camera.show_object(mesh.world_object, (-2, 2, -2), up=(0, 0, 1)) diff --git a/examples/mesh/surface_height.py b/examples/mesh/surface_height.py index eacce631a..1e1db7ffe 100644 --- a/examples/mesh/surface_height.py +++ b/examples/mesh/surface_height.py @@ -13,12 +13,12 @@ import pygfx as gfx -figure = fpl.Figure(size=(700, 560), cameras='3d', controller_types='orbit') +figure = fpl.Figure(size=(700, 560), cameras="3d", controller_types="orbit") t = np.linspace(0, 6, 100).astype(np.float32) x = np.sin(t) -y = np.cos(t*2) +y = np.cos(t * 2) z = (x.reshape(1, -1) * x.reshape(-1, 1)) * 50 # 100x100 surface = figure[0, 0].add_surface(z, cmap="bwr") diff --git a/examples/mesh/surface_ripple.py b/examples/mesh/surface_ripple.py index c9d45e870..ac556bd1b 100644 --- a/examples/mesh/surface_ripple.py +++ b/examples/mesh/surface_ripple.py @@ -13,23 +13,26 @@ import numpy as np -figure = fpl.Figure(size=(700, 560), cameras='3d', controller_types='orbit') +figure = fpl.Figure(size=(700, 560), cameras="3d", controller_types="orbit") def create_ripple(shape=(100, 100), phase=0.0, freq=np.pi / 4, ampl=1.0): m, n = shape - y, x = np.ogrid[-m / 2:m / 2, -n / 2:n / 2] - r = np.sqrt(x ** 2 + y ** 2) + y, x = np.ogrid[-m / 2 : m / 2, -n / 2 : n / 2] + r = np.sqrt(x**2 + y**2) z = (ampl * np.sin(freq * r + phase)) / np.sqrt(r + 1) - return z * 8 + return z * 8 + z = create_ripple() # set the clim vmax max_z = create_ripple(phase=(np.pi / 4) - (np.pi / 2)).max() -surface = figure[0, 0].add_surface(z, mode="basic", cmap="viridis", clim=(-max_z, max_z)) +surface = figure[0, 0].add_surface( + z, mode="basic", cmap="viridis", clim=(-max_z, max_z) +) figure[0, 0].camera.show_object(surface.world_object, (-1, 3, -1), up=(0, 0, 1)) figure.show() @@ -37,6 +40,8 @@ def create_ripple(shape=(100, 100), phase=0.0, freq=np.pi / 4, ampl=1.0): figure[0, 0].camera.zoom = 1.15 phase = 0.0 + + def animate(): global phase @@ -46,6 +51,7 @@ def animate(): phase -= 0.1 + figure[0, 0].add_animations(animate) diff --git a/examples/mesh/surface_sphere_ripple.py b/examples/mesh/surface_sphere_ripple.py index d98622e10..6caa03465 100644 --- a/examples/mesh/surface_sphere_ripple.py +++ b/examples/mesh/surface_sphere_ripple.py @@ -12,7 +12,7 @@ import fastplotlib as fpl import numpy as np -figure = fpl.Figure(size=(700, 560), cameras='3d', controller_types='orbit') +figure = fpl.Figure(size=(700, 560), cameras="3d", controller_types="orbit") # create an ellipsoid from spherical coordinates # see this for reference: https://mathworld.wolfram.com/SphericalCoordinates.html @@ -53,6 +53,8 @@ start = 0 + + def animate(): global start theta = np.linspace(start, start + np.pi, num=ny, dtype=np.float32) From 3ee8c57f87ba1ed2f35cb63c215c063593ca0fe2 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 1 Dec 2025 00:39:27 -0500 Subject: [PATCH 07/14] polygon data updating works --- examples/mesh/polygon_animation.py | 66 ++++++++++++++++++++++++++ examples/mesh/polygons.py | 34 ++++++++----- examples/mesh/surface_gaussian.py | 2 +- fastplotlib/graphics/features/_mesh.py | 10 ++-- 4 files changed, 94 insertions(+), 18 deletions(-) create mode 100644 examples/mesh/polygon_animation.py diff --git a/examples/mesh/polygon_animation.py b/examples/mesh/polygon_animation.py new file mode 100644 index 000000000..251a4aa2c --- /dev/null +++ b/examples/mesh/polygon_animation.py @@ -0,0 +1,66 @@ +""" +Polygon animation +================= + +Polygon animation example that changes the polygon data. Random points are generated by sampling from a +2D gaussian and a polygon is updated to visualize a convex hull for the sampled points. + +""" + +# test_example = false +# sphinx_gallery_pygfx_docs = 'animate 8s' + +import numpy as np +from scipy.spatial import ConvexHull +import fastplotlib as fpl +from time import perf_counter + + +def points_to_hull(points) -> np.ndarray: + hull = ConvexHull(points, qhull_options='Qs') + return points[hull.vertices] + + +figure = fpl.Figure(size=(700, 560)) + +mu = (0, 0) +cov = np.array([[1, 0], [0, 1]]) + +# sample points from a 2d gaussian +samples = np.random.multivariate_normal(mu, cov, size=20) + +# add the convex hull as a polygon +polygon = figure[0, 0].add_polygon(points_to_hull(samples), colors="cyan") +# add the sampled points +scatter = figure[0, 0].add_scatter(samples, sizes=8, colors="magenta") + +t = perf_counter() +def animate(): + global t + # generate new data every ~0.2 seconds + if perf_counter() - t < 0.2: + return + + # generate new samples + samples = np.random.multivariate_normal(mu, cov, size=20) + + # set new scatter data + scatter.data[:, :-1] = samples + # set convex hull with new polygon vertices + polygon.data = points_to_hull(samples) + + t = perf_counter() + + +figure.show() +figure[0, 0].camera.width = 8 +figure[0, 0].camera.height = 8 + +figure.add_animations(animate) + + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/mesh/polygons.py b/examples/mesh/polygons.py index 1c287a9e2..616c2e0fb 100644 --- a/examples/mesh/polygons.py +++ b/examples/mesh/polygons.py @@ -1,6 +1,6 @@ """ Polygons -====================== +======== An example with polygons. @@ -24,28 +24,38 @@ def make_circle(center, radius: float, n_points: int = 75) -> np.ndarray: return np.column_stack([xs, ys]) + np.asarray(center)[None] +# define vertices for some polygons circle_data = make_circle(center=(0, 0), radius=5) octogon_data = make_circle(center=(15, 0), radius=7, n_points=8) - -rectangle_data = np.array([[10, 10], [15, 10], [15, 15], [10, 15]]) - +rectangle_data = np.array([[10, 10], [20, 10], [20, 15], [10, 15]]) triangle_data = np.array( [ - [0, 10], - [5, 10], - [2.5, 15], - [0, 10], + [-5, 8], + [5, 8], + [0, 15], + [-5, 8], ] ) - +# add polygons figure[0, 0].add_polygon(circle_data, name="circle") -figure[0, 0].add_polygon(octogon_data, colors=Colormap("jet").lut(8), name="octogon") figure[0, 0].add_polygon( - rectangle_data, colors=["r", "r", "cyan", "y"], name="rectangle" + octogon_data, + colors=Colormap("jet").lut(8), # set vertex colors from jet cmap + name="octogon" +) +figure[0, 0].add_polygon( + rectangle_data, + colors=["r", "r", "cyan", "y"], # manually specify vertex colors + name="rectangle" ) figure[0, 0].add_polygon(triangle_data, colors="m") figure.show() -fpl.loop.run() + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/mesh/surface_gaussian.py b/examples/mesh/surface_gaussian.py index 5d7e7b777..6a9fb0f1d 100644 --- a/examples/mesh/surface_gaussian.py +++ b/examples/mesh/surface_gaussian.py @@ -30,7 +30,7 @@ def gaus2d(x=0, y=0, mx=0, my=0, sx=1, sy=1): z = gaus2d(x, y, mx=5, my=5, sx=1, sy=1) * 50 mesh = figure[0, 0].add_surface( - np.dstack([x, y, z]), mode="phong", colors="magenta", cmap="jet" + np.dstack([x, y, z]), mode="phong", cmap="jet" ) # figure[0, 0].axes.grids.xy.visible = True diff --git a/fastplotlib/graphics/features/_mesh.py b/fastplotlib/graphics/features/_mesh.py index a125e5723..26c6508b0 100644 --- a/fastplotlib/graphics/features/_mesh.py +++ b/fastplotlib/graphics/features/_mesh.py @@ -268,19 +268,19 @@ def set_value(self, graphic, value: np.ndarray | Sequence): positions, indices = triangulate_polygon(value) - geometry = graphic.geometry + geometry = graphic.world_object.geometry # Need larger buffer? - if len(value) > geometry.positions.nitems: + if len(positions) > geometry.positions.nitems: arr = np.zeros((geometry.positions.nitems * 2, 3), np.float32) geometry.positions = pygfx.Buffer(arr) if len(indices) > geometry.indices.nitems: arr = np.zeros((geometry.indices.nitems * 2, 3), np.int32) geometry.indices = pygfx.Buffer(arr) - geometry.positions.data[: len(value)] = value - geometry.positions.data[len(value) :] = value[-1] if len(value) else (0, 0, 0) - geometry.positions.draw_range = 0, len(value) + geometry.positions.data[: len(positions)] = positions + geometry.positions.data[len(positions) :] = positions[-1] if len(positions) else (0, 0, 0) + geometry.positions.draw_range = 0, len(positions) geometry.positions.update_full() geometry.indices.data[: len(indices)] = indices From f96aa3149ea6bd061f1295535bb120ef37b7c267 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 1 Dec 2025 00:57:32 -0500 Subject: [PATCH 08/14] update --- fastplotlib/graphics/features/_mesh.py | 7 +-- fastplotlib/graphics/mesh.py | 67 ++++++++++++++++++++++---- 2 files changed, 59 insertions(+), 15 deletions(-) diff --git a/fastplotlib/graphics/features/_mesh.py b/fastplotlib/graphics/features/_mesh.py index 26c6508b0..f68df28a3 100644 --- a/fastplotlib/graphics/features/_mesh.py +++ b/fastplotlib/graphics/features/_mesh.py @@ -198,11 +198,8 @@ def value(self) -> np.ndarray: def set_value(self, graphic, value: np.ndarray): positions, indices = surface_data_to_mesh(value) - graphic.world_object.geometry.positions.data[:] = positions - graphic.world_object.geometry.indices.data[:] = indices - - graphic.world_object.geometry.positions.update_full() - graphic.world_object.geometry.indices.update_full() + graphic.positions = positions + graphic.indices = indices # if cmap is a 1D texture we need to set the texcoords again using new z values if graphic.world_object.material.map is not None: diff --git a/fastplotlib/graphics/mesh.py b/fastplotlib/graphics/mesh.py index 64abbd452..3329b282b 100644 --- a/fastplotlib/graphics/mesh.py +++ b/fastplotlib/graphics/mesh.py @@ -52,6 +52,15 @@ def __init__( 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. @@ -67,6 +76,12 @@ def __init__( 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` @@ -166,6 +181,7 @@ def __init__( @property def mode(self) -> Literal["basic", "phong", "slice"]: + """get mesh rendering mode""" return self._mode @property @@ -188,6 +204,7 @@ def indices(self, mew_indices): @property def mapcoords(self) -> np.ndarray | None: + """get or set the mapcoords""" if self._mapcoords is not None: return self._mapcoords.data @@ -225,6 +242,7 @@ def colors(self, value: str | np.ndarray | Sequence[float] | Sequence[str]): @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 @@ -237,7 +255,7 @@ def cmap( @property def plane(self) -> tuple[float, float, float, float] | None: - """Get or set displayed plane in the volume. Valid only for `slice` render mode.""" + """Get or set the current slice plane. Valid only for ``"slice"`` render mode.""" if self.mode != "slice": return @@ -277,6 +295,10 @@ def __init__( 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. + 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. @@ -339,6 +361,7 @@ def __init__( @property def data(self) -> np.ndarray: + """get or set the surface data""" return self._data.value @data.setter @@ -347,6 +370,7 @@ def data(self, new_data: np.ndarray): @property def clim(self) -> tuple[float, float] | None: + """get or set the colormap limits""" return self._clim @clim.setter @@ -377,19 +401,40 @@ def __init__( **kwargs, ): """ - Create a polygon mesh graphic + 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 - mode - colors - mapcoords - cmap - clim - kwargs + 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` """ - pass positions, indices = triangulate_polygon(data) @@ -415,6 +460,7 @@ def __init__( @property def data(self) -> np.ndarray: + """get or set the polygon vertex data""" return self._data.value @data.setter @@ -423,6 +469,7 @@ def data(self, new_data: np.ndarray | Sequence): @property def clim(self) -> tuple[float, float] | None: + """get or set the colormap limits""" return self._clim @clim.setter From bde23b311959478ce7ccc918cf94a151531742b5 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 1 Dec 2025 00:57:48 -0500 Subject: [PATCH 09/14] black --- fastplotlib/graphics/features/_mesh.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/fastplotlib/graphics/features/_mesh.py b/fastplotlib/graphics/features/_mesh.py index f68df28a3..2ec2af3af 100644 --- a/fastplotlib/graphics/features/_mesh.py +++ b/fastplotlib/graphics/features/_mesh.py @@ -276,7 +276,9 @@ def set_value(self, graphic, value: np.ndarray | Sequence): geometry.indices = pygfx.Buffer(arr) geometry.positions.data[: len(positions)] = positions - geometry.positions.data[len(positions) :] = positions[-1] if len(positions) else (0, 0, 0) + geometry.positions.data[len(positions) :] = ( + positions[-1] if len(positions) else (0, 0, 0) + ) geometry.positions.draw_range = 0, len(positions) geometry.positions.update_full() From 2dd7524ca8731cfac123fe817e9fe262fb7c90f3 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 1 Dec 2025 01:26:26 -0500 Subject: [PATCH 10/14] more examples --- examples/mesh/image_surface.py | 2 +- examples/mesh/surface_terrain.py | 36 ++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 examples/mesh/surface_terrain.py diff --git a/examples/mesh/image_surface.py b/examples/mesh/image_surface.py index 31c872f37..fce3c4958 100644 --- a/examples/mesh/image_surface.py +++ b/examples/mesh/image_surface.py @@ -21,7 +21,7 @@ z = im.mean(axis=2) z = scipy.ndimage.gaussian_filter(z, 5) # 2nd arg is sigma -mesh = figure[0, 0].add_surface(z, colors="magenta", cmap=im) +mesh = figure[0, 0].add_surface(z, cmap=im) mesh.world_object.local.scale_y = -1 diff --git a/examples/mesh/surface_terrain.py b/examples/mesh/surface_terrain.py new file mode 100644 index 000000000..394e65216 --- /dev/null +++ b/examples/mesh/surface_terrain.py @@ -0,0 +1,36 @@ +""" +Elevation map of the earth +========================== + +Surface graphic showing elevation map of the earth +""" + +# test_example = false +# sphinx_gallery_pygfx_docs = 'code' + +import imageio.v3 as iio +import fastplotlib as fpl +import numpy as np + +# grayscale image of the earth where the pixel value indicates elevation +elevation = iio.imread("https://neo.gsfc.nasa.gov/archive/bluemarble/bmng/topography/srtm_ramp2.world.5400x2700.jpg").astype(np.float32) +elevation /= 2 + +figure = fpl.Figure(size=(700, 560), cameras="3d", controller_types="orbit") + +mesh = figure[0, 0].add_surface(elevation, cmap="terrain") +mesh.world_object.local.scale_y = -1 + + +figure[0, 0].axes.grids.xy.visible = True +figure[0, 0].camera.show_object(mesh.world_object, (-4, 2, -1), up=(0, 0, 1)) +figure.show() + +figure[0, 0].camera.zoom = 2.5 + + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() From e8cfce24bc19017e226433ea591fdb9972347e11 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 1 Dec 2025 01:41:56 -0500 Subject: [PATCH 11/14] update add_graphic mixin --- fastplotlib/layouts/_graphic_methods_mixin.py | 57 ++++++++++++++++--- 1 file changed, 49 insertions(+), 8 deletions(-) diff --git a/fastplotlib/layouts/_graphic_methods_mixin.py b/fastplotlib/layouts/_graphic_methods_mixin.py index e13661b85..ae227a3f0 100644 --- a/fastplotlib/layouts/_graphic_methods_mixin.py +++ b/fastplotlib/layouts/_graphic_methods_mixin.py @@ -463,6 +463,15 @@ def add_mesh( 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. @@ -478,6 +487,12 @@ def add_mesh( 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` @@ -514,17 +529,39 @@ def add_polygon( ) -> PolygonGraphic: """ - Create a polygon mesh graphic + 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 - mode - colors - mapcoords - cmap - clim - kwargs + 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` """ return self._create_graphic( @@ -711,6 +748,10 @@ def add_surface( 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. + 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. From 999bbe5f5b29e0e51af7f364ffde3a8d1b5d08a2 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 1 Dec 2025 21:16:35 -0500 Subject: [PATCH 12/14] code review changes --- fastplotlib/graphics/mesh.py | 62 ++++++++++++++++-------------------- 1 file changed, 27 insertions(+), 35 deletions(-) diff --git a/fastplotlib/graphics/mesh.py b/fastplotlib/graphics/mesh.py index 3329b282b..933c37d41 100644 --- a/fastplotlib/graphics/mesh.py +++ b/fastplotlib/graphics/mesh.py @@ -37,6 +37,7 @@ def __init__( 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, ): @@ -105,11 +106,20 @@ def __init__( 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() + + mapcoords = (mapcoords - clim[0]) / (clim[1] - clim[0]) + if mapcoords is not None: self._mapcoords = pygfx.Buffer(np.asarray(mapcoords, dtype=np.float32)) else: self._mapcoords = None + self._clim = clim + uniform_color = "w" per_vertex_colors = False @@ -223,6 +233,20 @@ def mapcoords(self, new_mapcoords: np.ndarray | None): 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""" @@ -294,6 +318,7 @@ def __init__( 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 @@ -324,14 +349,6 @@ def __init__( self._data = SurfaceData(data) - if clim is not None: - if len(clim) != 2: - raise ValueError("clim must be a: tuple[float, float]") - - self._clim = tuple(clim) - else: - self._clim = None - positions, indices = surface_data_to_mesh(data) cmap_tex_view = resolve_cmap_mesh(cmap) @@ -343,11 +360,6 @@ def __init__( mapcoords = np.column_stack((positions[:, 0], positions[:, 1])).astype( np.float32 ) - # 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() - mapcoords = (mapcoords - clim[0]) / (clim[1] - clim[0]) super().__init__( positions, @@ -356,6 +368,7 @@ def __init__( colors=colors, mapcoords=mapcoords, cmap=cmap, + clim=clim, **kwargs, ) @@ -368,20 +381,6 @@ def data(self) -> np.ndarray: def data(self, new_data: np.ndarray): 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]) - class PolygonGraphic(MeshGraphic): _features = { @@ -440,14 +439,6 @@ def __init__( self._data = PolygonData(positions) - if clim is not None: - if len(clim) != 2: - raise ValueError("clim must be a: tuple[float, float]") - - self._clim = tuple(clim) - else: - self._clim = None - super().__init__( positions, indices, @@ -455,6 +446,7 @@ def __init__( colors=colors, mapcoords=mapcoords, cmap=cmap, + clim=clim, **kwargs, ) From 86b3976ca0009ca4d891714d3224d16f82e78de8 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 1 Dec 2025 21:37:38 -0500 Subject: [PATCH 13/14] fix --- fastplotlib/graphics/mesh.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/fastplotlib/graphics/mesh.py b/fastplotlib/graphics/mesh.py index 933c37d41..94441db98 100644 --- a/fastplotlib/graphics/mesh.py +++ b/fastplotlib/graphics/mesh.py @@ -33,7 +33,7 @@ def __init__( positions: Any, indices: Any, mode: Literal["basic", "phong", "slice"] = "phong", - plane: tuple[float, float, float, float] = (0, 0, 1, 0), + plane: tuple[float, float, float, float] = (0., 0., 1., 0.), colors: str | np.ndarray | Sequence = "w", mapcoords: Any = None, cmap: str | dict | pygfx.Texture | pygfx.TextureMap | np.ndarray = None, @@ -58,7 +58,7 @@ def __init__( * 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) + 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. @@ -111,9 +111,8 @@ def __init__( if clim is None and mapcoords is not None: clim = mapcoords.min(), mapcoords.max() - mapcoords = (mapcoords - clim[0]) / (clim[1] - clim[0]) - 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 From 0fda38c3c01b7e38955bb6d200c2e789e7311b7c Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 1 Dec 2025 21:39:18 -0500 Subject: [PATCH 14/14] better polygon example --- examples/mesh/polygon_animation.py | 48 ++++++++++++++++++------------ 1 file changed, 29 insertions(+), 19 deletions(-) diff --git a/examples/mesh/polygon_animation.py b/examples/mesh/polygon_animation.py index 251a4aa2c..6d4bc7bf0 100644 --- a/examples/mesh/polygon_animation.py +++ b/examples/mesh/polygon_animation.py @@ -13,48 +13,58 @@ import numpy as np from scipy.spatial import ConvexHull import fastplotlib as fpl -from time import perf_counter def points_to_hull(points) -> np.ndarray: - hull = ConvexHull(points, qhull_options='Qs') + hull = ConvexHull(points, qhull_options="Qs") return points[hull.vertices] figure = fpl.Figure(size=(700, 560)) -mu = (0, 0) + cov = np.array([[1, 0], [0, 1]]) # sample points from a 2d gaussian -samples = np.random.multivariate_normal(mu, cov, size=20) +samples1 = np.random.multivariate_normal((0, 0), cov, size=20) +samples2 = np.random.multivariate_normal((5, 0), cov, size=50) # add the convex hull as a polygon -polygon = figure[0, 0].add_polygon(points_to_hull(samples), colors="cyan") +polygon1 = figure[0, 0].add_polygon( + points_to_hull(samples1), colors="cyan", alpha=0.7, alpha_mode="blend" +) # add the sampled points -scatter = figure[0, 0].add_scatter(samples, sizes=8, colors="magenta") +scatter1 = figure[0, 0].add_scatter( + samples1, sizes=8, colors="blue", alpha=0.7, alpha_mode="blend" +) -t = perf_counter() -def animate(): - global t - # generate new data every ~0.2 seconds - if perf_counter() - t < 0.2: - return +# add the second gaussian and convex hull polygon +polygon2 = figure[0, 0].add_polygon( + points_to_hull(samples2), colors="magenta", alpha=0.7, alpha_mode="blend" +) +scatter2 = figure[0, 0].add_scatter( + samples2, sizes=8, colors="r", alpha=0.7, alpha_mode="blend" +) - # generate new samples - samples = np.random.multivariate_normal(mu, cov, size=20) +def animate(): # set new scatter data - scatter.data[:, :-1] = samples + scatter1.data[:, :-1] += np.random.normal(0, 0.05, size=samples1.size).reshape( + samples1.shape + ) # set convex hull with new polygon vertices - polygon.data = points_to_hull(samples) + polygon1.data = points_to_hull(scatter1.data[:, :-1]) - t = perf_counter() + # set the other scatter and polygon + scatter2.data[:, :-1] += np.random.normal(0, 0.05, size=samples2.size).reshape( + samples2.shape + ) + polygon2.data = points_to_hull(scatter2.data[:, :-1]) figure.show() -figure[0, 0].camera.width = 8 -figure[0, 0].camera.height = 8 +figure[0, 0].camera.width = 10 +figure[0, 0].camera.height = 10 figure.add_animations(animate)