From 00b7ccf44a8ca06f91a54adee6fb068a1545abfd Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 28 Nov 2025 10:28:00 +0100 Subject: [PATCH 01/21] Add support for mesh and surface --- examples/line/line.py | 24 +- examples/mesh/mesh.py | 35 ++ examples/mesh/surf.py | 36 ++ fastplotlib/graphics/__init__.py | 2 + fastplotlib/graphics/mesh.py | 382 ++++++++++++++++++ fastplotlib/layouts/_graphic_methods_mixin.py | 198 +++++++++ fastplotlib/layouts/_plot_area.py | 3 + 7 files changed, 662 insertions(+), 18 deletions(-) create mode 100644 examples/mesh/mesh.py create mode 100644 examples/mesh/surf.py create mode 100644 fastplotlib/graphics/mesh.py diff --git a/examples/line/line.py b/examples/line/line.py index f7839a1c4..8bc9812ea 100644 --- a/examples/line/line.py +++ b/examples/line/line.py @@ -10,31 +10,19 @@ import fastplotlib as fpl import numpy as np +import pygfx as gfx -figure = fpl.Figure(size=(700, 560)) -xs = np.linspace(-10, 10, 100) -# sine wave -ys = np.sin(xs) -sine_data = np.column_stack([xs, ys]) +figure = fpl.Figure(size=(700, 560), cameras='3d', controller_types='orbit') -# cosine wave -ys = np.cos(xs) + 5 -cosine_data = np.column_stack([xs, ys]) -# sinc function -a = 0.5 -ys = np.sinc(xs) * 3 + 8 -sinc_data = np.column_stack([xs, ys]) -sine = figure[0, 0].add_line(data=sine_data, thickness=5, colors="magenta") +geo = gfx.geometries.torus_knot_geometry() +positions = geo.positions.data +indices = geo.indices.data -# you can also use colormaps for lines! -cosine = figure[0, 0].add_line(data=cosine_data, thickness=12, cmap="autumn") +mesh = figure[0, 0].add_mesh(positions, indices, colors="magenta") -# or a list of colors for each datapoint -colors = ["r"] * 25 + ["purple"] * 25 + ["y"] * 25 + ["b"] * 25 -sinc = figure[0, 0].add_line(data=sinc_data, thickness=5, colors=colors) figure[0, 0].axes.grids.xy.visible = True figure.show() diff --git a/examples/mesh/mesh.py b/examples/mesh/mesh.py new file mode 100644 index 000000000..fbeb022e5 --- /dev/null +++ b/examples/mesh/mesh.py @@ -0,0 +1,35 @@ +""" +Simple mesh +=========== + +Example showing a simple mesh +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' + +import fastplotlib as fpl +import numpy as np +import pygfx as gfx + + +figure = fpl.Figure(size=(700, 560), cameras='3d', controller_types='orbit') + + +# Load geometry using Pygfx's geometry util +geo = gfx.geometries.torus_knot_geometry() +positions = geo.positions.data +indices = geo.indices.data + +mesh = figure[0, 0].add_mesh(positions, indices, colors="magenta") + + +figure[0, 0].axes.grids.xy.visible = True +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/surf.py b/examples/mesh/surf.py new file mode 100644 index 000000000..d15ef5c93 --- /dev/null +++ b/examples/mesh/surf.py @@ -0,0 +1,36 @@ +""" +Simple surface +============== + +Example showing a surface mesh +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' + +import fastplotlib as fpl +import numpy as np +import pygfx as gfx + + +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) +z = (x.reshape(1, -1) * x.reshape(-1, 1)) * 50 # 100x100 + + +mesh = figure[0, 0].add_surface(z, colors="magenta", cmap='jet') + + +figure[0, 0].axes.grids.xy.visible = True +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/fastplotlib/graphics/__init__.py b/fastplotlib/graphics/__init__.py index 46051479d..04fa95001 100644 --- a/fastplotlib/graphics/__init__.py +++ b/fastplotlib/graphics/__init__.py @@ -4,6 +4,7 @@ from .image import ImageGraphic from .image_volume import ImageVolumeGraphic from ._vectors import VectorsGraphic +from .mesh import MeshGraphic from .text import TextGraphic from .line_collection import LineCollection, LineStack @@ -15,6 +16,7 @@ "ImageGraphic", "ImageVolumeGraphic", "VectorsGraphic", + "MeshGraphic", "TextGraphic", "LineCollection", "LineStack", diff --git a/fastplotlib/graphics/mesh.py b/fastplotlib/graphics/mesh.py new file mode 100644 index 000000000..e5a8e4f83 --- /dev/null +++ b/fastplotlib/graphics/mesh.py @@ -0,0 +1,382 @@ +from typing import Sequence, Any + +import numpy as np + +import pygfx + +from ._positions_base import Graphic +from .selectors import ( + LinearRegionSelector, + LinearSelector, + RectangleSelector, + PolygonSelector, +) +from .features import ( + BufferManager, + VertexPositions, + VertexColors, + UniformColor, + VertexCmap, +) +from ..utils.functions import get_cmap +from ..utils import quick_min_max + + +class MeshGraphic(Graphic): + _features = { + "positions": VertexPositions, + "indices": BufferManager, + "mapcoords": (BufferManager, None), + "colors": (VertexColors, UniformColor), + } + + def __init__( + self, + positions: Any, + indices: Any, + colors: str | np.ndarray | Sequence = "w", + mapcoords: Any = None, + cmap: str = None, + isolated_buffer: bool = True, + **kwargs, + ): + """ + Create a mesh Graphic. + + Parameters + ---------- + positions: array-like + The 3D positions of the vertices. + + indices: array-like + The indices into the positions that make up the triangles. Each 3 + subsequent indices form a triangle. + + colors: str, array, or iterable, default "w" + A uniform color, or the per-position colors. + + mapcoords: array-like + The per-position 1D coordinates to which to apply the colormap (a.k.a. texcoords). + These can e.g. be some domain-specific value, mapped to [0..1]. + If ``mapcoords`` and ``cmap`` are given, they are used instead of ``colors``. + + cmap: str, optional + Apply a colormap to the mesh, this overrides any argument passed to + "colors". For supported colormaps see the ``cmap`` library + catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/ + + **kwargs + passed to :class:`.Graphic` + + """ + + super().__init__(**kwargs) + + if isinstance(positions, VertexPositions): + self._positions = positions + else: + self._positions = VertexPositions( + positions, isolated_buffer=isolated_buffer, property_name="positions" + ) + + if isinstance(positions, BufferManager): + self._indices = indices + else: + self._indices = BufferManager( + indices, isolated_buffer=isolated_buffer, property_name="indices" + ) + + if mapcoords is None: + self._mapcoords = None + elif isinstance(mapcoords, BufferManager): + self._mapcoords = mapcoords + else: + self._mapcoords = mapcoords = BufferManager( + mapcoords, isolated_buffer=isolated_buffer, property_name="mapcoords" + ) + + pygfx_cmap = None + uniform_color = "w" + per_vertex_colors = False + if cmap is not None: + # if a cmap is specified it overrides colors argument + if isinstance(cmap, str): + pygfx_cmap = pygfx.cm.create_colormap(get_cmap(cmap)) + else: + pygfx_cmap = pygfx.cm.create_colormap(cmap) + + else: + if colors is None: + uniform_color = "w" + self._colors = UniformColor(uniform_color) + elif isinstance(colors, str) or isinstance(colors, tuple): + uniform_color = colors + self._colors = UniformColor(uniform_color) + elif isinstance(colors, VertexColors): + per_vertex_colors = True + self._colors = colors + self._colors._shared += 1 + else: + per_vertex_colors = True + self._colors = VertexColors( + colors, n_colors=self._positions.value.shape[0] + ) + + geometry = pygfx.Geometry( + positions=self._positions.buffer, indices=self._indices._buffer + ) + material = pygfx.MeshPhongMaterial( + color_mode="uniform", + color=uniform_color, + pick_write=True, + ) + + # 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 + + # Decide on color mode + # uniform = None #: Use the uniform color (usually ``material.color``). + # vertex = None #: Use the per-vertex color specified in the geometry (usually ``geometry.colors``). + # face = None #: Use the per-face color specified in the geometry (usually ``geometry.colors``). + # vertex_map = None #: Use per-vertex texture coords (``geometry.texcoords``), and sample these in ``material.map``. + # face_map = None #: Use per-face texture coords (``geometry.texcoords``), and sample these in ``material.map``. + if mapcoords is not None and pygfx_cmap is not None: + material.color_mode = "vertex_map" + elif per_vertex_colors: + material.color_mode = "vertex" + else: + material.color_mode = "uniform" + + world_object: pygfx.Mesh = pygfx.Mesh(geometry=geometry, material=material) + + self._set_world_object(world_object) + + def add_linear_selector( + self, selection: float = None, axis: str = "x", **kwargs + ) -> LinearSelector: + """ + Adds a :class:`.LinearSelector`. + + Selectors are just ``Graphic`` objects, so you can manage, remove, or delete them from a + plot area just like any other ``Graphic``. + + Parameters + ---------- + selection: float, optional + selected point on the linear selector, by default the first datapoint on the line. + + axis: str, default "x" + axis that the selector resides on + + kwargs + passed to :class:`.LinearSelector` + + Returns + ------- + LinearSelector + + """ + + bounds_init, limits, size, center = self._get_linear_selector_init_args( + axis, padding=0 + ) + + if selection is None: + selection = bounds_init[0] + + selector = LinearSelector( + selection=selection, + limits=limits, + axis=axis, + parent=self, + **kwargs, + ) + + self._plot_area.add_graphic(selector, center=False) + + return selector + + def add_linear_region_selector( + self, + selection: tuple[float, float] = None, + padding: float = 0.0, + axis: str = "x", + **kwargs, + ) -> LinearRegionSelector: + """ + Add a :class:`.LinearRegionSelector`. + + Selectors are just ``Graphic`` objects, so you can manage, remove, or delete them from a + plot area just like any other ``Graphic``. + + Parameters + ---------- + selection: (float, float), optional + the starting bounds of the linear region selector, computed from data if not provided + + axis: str, default "x" + axis that the selector resides on + + padding: float, default 0.0 + Extra padding to extend the linear region selector along the orthogonal axis to make it easier to interact with. + + kwargs + passed to ``LinearRegionSelector`` + + Returns + ------- + LinearRegionSelector + linear selection graphic + + """ + + # TODO: check that all selectors work for the mesh + + bounds_init, limits, size, center = self._get_linear_selector_init_args( + axis, padding + ) + + if selection is None: + selection = bounds_init + + # create selector + selector = LinearRegionSelector( + selection=selection, + limits=limits, + size=size, + center=center, + axis=axis, + parent=self, + **kwargs, + ) + + self._plot_area.add_graphic(selector, center=False) + + # PlotArea manages this for garbage collection etc. just like all other Graphics + # so we should only work with a proxy on the user-end + return selector + + def add_rectangle_selector( + self, + selection: tuple[float, float, float, float] = None, + **kwargs, + ) -> RectangleSelector: + """ + Add a :class:`.RectangleSelector`. + + Selectors are just ``Graphic`` objects, so you can manage, remove, or delete them from a + plot area just like any other ``Graphic``. + + Parameters + ---------- + selection: (float, float, float, float), optional + initial (xmin, xmax, ymin, ymax) of the selection + """ + + # remove any nans + positions = self.positions.value[ + ~np.any(np.isnan(self.positions.value), axis=1) + ] + + x_axis_vals = positions[:, 0] + y_axis_vals = positions[:, 1] + + ymin = np.floor(y_axis_vals.min()).astype(int) + ymax = np.ceil(y_axis_vals.max()).astype(int) + + # default selection is 25% of the image + if selection is None: + selection = (x_axis_vals[0], x_axis_vals[value_25p], ymin, ymax) + + # min/max limits + limits = (x_axis_vals[0], x_axis_vals[-1], ymin * 1.5, ymax * 1.5) + + selector = RectangleSelector( + selection=selection, + limits=limits, + parent=self, + **kwargs, + ) + + self._plot_area.add_graphic(selector, center=False) + + return selector + + def add_polygon_selector( + self, + selection: list[tuple[float, float]] = None, + **kwargs, + ) -> PolygonSelector: + """ + Add a :class:`.PolygonSelector`. + + Selectors are just ``Graphic`` objects, so you can manage, remove, or delete them from a + plot area just like any other ``Graphic``. + + Parameters + ---------- + selection: List of positions, optional + Initial points for the polygon. If not given or None, you'll start drawing the selection (clicking adds points to the polygon). + """ + + # remove any nans + positions = self.positions.value[ + ~np.any(np.isnan(self.positions.value), axis=1) + ] + + x_axis_vals = positions[:, 0] + y_axis_vals = positions[:, 1] + + ymin = np.floor(y_axis_vals.min()).astype(int) + ymax = np.ceil(y_axis_vals.max()).astype(int) + + # min/max limits + limits = (x_axis_vals[0], x_axis_vals[-1], ymin * 1.5, ymax * 1.5) + + selector = PolygonSelector( + selection, + limits, + parent=self, + **kwargs, + ) + + self._plot_area.add_graphic(selector, center=False) + + return selector + + # TODO: this method is a bit of a mess, can refactor later + def _get_linear_selector_init_args( + self, axis: str, padding + ) -> tuple[tuple[float, float], tuple[float, float], float, float]: + bounds = self.world_object.get_bounding_box() + breakpoint() + + size = float + center = tuple + bounds_init = None + + if axis == "x": + # xvals + axis_vals = data[:, 0] + + # yvals to get size and center + magn_vals = data[:, 1] + elif axis == "y": + axis_vals = data[:, 1] + magn_vals = data[:, 0] + + bounds_init = axis_vals[0], axis_vals[value_25p] + limits = axis_vals[0], axis_vals[-1] + + # width or height of selector + size = int(np.ptp(magn_vals) * 1.5 + padding) + + # center of selector along the other axis + center = sum(quick_min_max(magn_vals)) / 2 + + return bounds_init, limits, size, center diff --git a/fastplotlib/layouts/_graphic_methods_mixin.py b/fastplotlib/layouts/_graphic_methods_mixin.py index e7ff99a1d..7b5bf761a 100644 --- a/fastplotlib/layouts/_graphic_methods_mixin.py +++ b/fastplotlib/layouts/_graphic_methods_mixin.py @@ -432,6 +432,204 @@ def add_line_stack( **kwargs, ) + def add_mesh( + self, + positions: Any, + indices: Any, + mapcoords: Any = None, + colors: Union[str, numpy.ndarray, Sequence] = "w", + cmap: str = None, + isolated_buffer: bool = True, + **kwargs, + ) -> MeshGraphic: + """ + + Create a mesh Graphic + + Parameters + ---------- + + positions: array-like + The 3D positions of the vertices. + + indices: array-like + The indices into the positions that make up the triangles. Each 3 + subsequent indices form a triangle. + + colors: str, array, or iterable, default "w" + A uniform color, or the per-position colors. + + mapcoords: array-like + The per-position 1D coordinates to which to apply the colormap (a.k.a. texcoords). + These can e.g. be some domain-specific value, mapped to [0..1]. + If ``mapcoords`` and ``cmap`` are given, they are used instead of ``colors``. + + cmap: str, optional + Apply a colormap to the mesh, this overrides any argument passed to + "colors". For supported colormaps see the ``cmap`` library + catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/ + + **kwargs + passed to :class:`.Graphic` + + + """ + return self._create_graphic( + MeshGraphic, + positions, + indices, + 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 1D 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/ + + 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 + if mapcoords is None: + if True: # 1d + mapcoords = z.ravel() + else: + # TODO: if cmap is 2D, create 2D texcoords + mapcoords = numpy.column_stack((Y.ravel(), Z.ravel())) + # 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: + 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, + ) + def add_scatter( self, data: Any, diff --git a/fastplotlib/layouts/_plot_area.py b/fastplotlib/layouts/_plot_area.py index 606f83909..977cf97fc 100644 --- a/fastplotlib/layouts/_plot_area.py +++ b/fastplotlib/layouts/_plot_area.py @@ -117,6 +117,9 @@ def __init__( self._background = pygfx.Background(None, self._background_material) self.scene.add(self._background) + self.scene.add(pygfx.AmbientLight()) + self.scene.add(self._camera.add(pygfx.DirectionalLight())) + def get_figure(self, obj=None): """Get Figure instance that contains this plot area""" if obj is None: From d944f5549fb99dbd434ff84c88fab9744b7ef32d Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 28 Nov 2025 10:47:18 +0100 Subject: [PATCH 02/21] restore line example --- examples/line/line.py | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/examples/line/line.py b/examples/line/line.py index 8bc9812ea..c50ecd656 100644 --- a/examples/line/line.py +++ b/examples/line/line.py @@ -10,19 +10,31 @@ import fastplotlib as fpl import numpy as np -import pygfx as gfx +figure = fpl.Figure(size=(700, 560)) -figure = fpl.Figure(size=(700, 560), cameras='3d', controller_types='orbit') +xs = np.linspace(-10, 10, 100) +# sine wave +ys = np.sin(xs) +sine_data = np.column_stack([xs, ys]) +# cosine wave +ys = np.cos(xs) + 5 +cosine_data = np.column_stack([xs, ys]) +# sinc function +a = 0.5 +ys = np.sinc(xs) * 3 + 8 +sinc_data = np.column_stack([xs, ys]) -geo = gfx.geometries.torus_knot_geometry() -positions = geo.positions.data -indices = geo.indices.data +sine = figure[0, 0].add_line(data=sine_data, thickness=5, colors="magenta") -mesh = figure[0, 0].add_mesh(positions, indices, colors="magenta") +# you can also use colormaps for lines! +cosine = figure[0, 0].add_line(data=cosine_data, thickness=12, cmap="autumn") +# or a list of colors for each datapoint +colors = ["r"] * 25 + ["purple"] * 25 + ["y"] * 25 + ["b"] * 25 +sinc = figure[0, 0].add_line(data=sinc_data, thickness=5, colors=colors) figure[0, 0].axes.grids.xy.visible = True figure.show() @@ -32,4 +44,4 @@ # See the "JupyterLab and IPython" section in the user guide if __name__ == "__main__": print(__doc__) - fpl.loop.run() + fpl.loop.run() \ No newline at end of file From d4897b4f21206703bc7b7d35c8927b715267e13a Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 28 Nov 2025 11:19:55 +0100 Subject: [PATCH 03/21] Implement image-surface example --- examples/line/line.py | 2 +- examples/mesh/image_surface.py | 36 +++++++++++++++++++ examples/mesh/{surf.py => surface.py} | 0 fastplotlib/graphics/mesh.py | 35 ++++++++++++------ fastplotlib/layouts/_graphic_methods_mixin.py | 19 ++++++---- 5 files changed, 74 insertions(+), 18 deletions(-) create mode 100644 examples/mesh/image_surface.py rename examples/mesh/{surf.py => surface.py} (100%) diff --git a/examples/line/line.py b/examples/line/line.py index c50ecd656..f7839a1c4 100644 --- a/examples/line/line.py +++ b/examples/line/line.py @@ -44,4 +44,4 @@ # See the "JupyterLab and IPython" section in the user guide if __name__ == "__main__": print(__doc__) - fpl.loop.run() \ No newline at end of file + fpl.loop.run() diff --git a/examples/mesh/image_surface.py b/examples/mesh/image_surface.py new file mode 100644 index 000000000..3b2bf072f --- /dev/null +++ b/examples/mesh/image_surface.py @@ -0,0 +1,36 @@ +""" +Image surface +============= + +Example showing an image as a surface. +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' + +import imageio.v3 as iio +import fastplotlib as fpl +import numpy as np +import scipy.ndimage + +im = iio.imread("imageio:astronaut.png") + +figure = fpl.Figure(size=(700, 560), cameras='3d', controller_types='orbit') + + +# Create the height map from the image +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) + + +figure[0, 0].axes.grids.xy.visible = True +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/surf.py b/examples/mesh/surface.py similarity index 100% rename from examples/mesh/surf.py rename to examples/mesh/surface.py diff --git a/fastplotlib/graphics/mesh.py b/fastplotlib/graphics/mesh.py index e5a8e4f83..9a81c0c4c 100644 --- a/fastplotlib/graphics/mesh.py +++ b/fastplotlib/graphics/mesh.py @@ -22,6 +22,27 @@ 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, @@ -56,7 +77,7 @@ def __init__( A uniform color, or the per-position colors. mapcoords: array-like - The per-position 1D coordinates to which to apply the colormap (a.k.a. texcoords). + The per-position coordinates to which to apply the colormap (a.k.a. texcoords). These can e.g. be some domain-specific value, mapped to [0..1]. If ``mapcoords`` and ``cmap`` are given, they are used instead of ``colors``. @@ -64,6 +85,7 @@ def __init__( 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. **kwargs passed to :class:`.Graphic` @@ -95,17 +117,10 @@ def __init__( mapcoords, isolated_buffer=isolated_buffer, property_name="mapcoords" ) - pygfx_cmap = None uniform_color = "w" per_vertex_colors = False - if cmap is not None: - # if a cmap is specified it overrides colors argument - if isinstance(cmap, str): - pygfx_cmap = pygfx.cm.create_colormap(get_cmap(cmap)) - else: - pygfx_cmap = pygfx.cm.create_colormap(cmap) - - else: + pygfx_cmap = resolve_cmap(cmap) + if cmap is None: if colors is None: uniform_color = "w" self._colors = UniformColor(uniform_color) diff --git a/fastplotlib/layouts/_graphic_methods_mixin.py b/fastplotlib/layouts/_graphic_methods_mixin.py index 7b5bf761a..403cd0a22 100644 --- a/fastplotlib/layouts/_graphic_methods_mixin.py +++ b/fastplotlib/layouts/_graphic_methods_mixin.py @@ -8,6 +8,7 @@ from ..graphics import * from ..graphics._base import Graphic +from ..graphics.mesh import resolve_cmap as resolve_mesh_cmap class GraphicMethodsMixin: @@ -460,7 +461,7 @@ def add_mesh( A uniform color, or the per-position colors. mapcoords: array-like - The per-position 1D coordinates to which to apply the colormap (a.k.a. texcoords). + The per-position coordinates to which to apply the colormap (a.k.a. texcoords). These can e.g. be some domain-specific value, mapped to [0..1]. If ``mapcoords`` and ``cmap`` are given, they are used instead of ``colors``. @@ -468,6 +469,7 @@ def add_mesh( 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. **kwargs passed to :class:`.Graphic` @@ -509,7 +511,7 @@ def add_surface( A uniform color, or the per-position colors. mapcoords: array-like - The per-position 1D coordinates to which to apply the colormap (a.k.a. texcoords). + 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. @@ -517,6 +519,7 @@ def add_surface( 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 @@ -592,15 +595,17 @@ def check_z(z): positions = numpy.column_stack((x.ravel(), y.ravel(), z.ravel())) # Create texcoords + cmap = resolve_mesh_cmap(cmap) if mapcoords is None: - if True: # 1d + if cmap.texture.dim == 1: # 1d mapcoords = z.ravel() - else: - # TODO: if cmap is 2D, create 2D texcoords - mapcoords = numpy.column_stack((Y.ravel(), 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: + if clim is None and mapcoords is not None: clim = mapcoords.min(), mapcoords.max() mapcoords = (mapcoords - clim[0]) / (clim[1] - clim[0]) else: From 99d00aa93fcc56bf8cc291dcb55c01f32e1bc438 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 28 Nov 2025 11:51:10 +0100 Subject: [PATCH 04/21] Fix 3D camera for mesh examples --- examples/mesh/image_surface.py | 2 ++ examples/mesh/mesh.py | 2 ++ examples/mesh/surface.py | 3 ++- 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/examples/mesh/image_surface.py b/examples/mesh/image_surface.py index 3b2bf072f..c089f0f90 100644 --- a/examples/mesh/image_surface.py +++ b/examples/mesh/image_surface.py @@ -23,9 +23,11 @@ z = scipy.ndimage.gaussian_filter(z, 5) # 2nd arg is sigma mesh = figure[0, 0].add_surface(z, colors="magenta", cmap=im) +mesh.world_object.local.scale_y = -1 figure[0, 0].axes.grids.xy.visible = True +figure[0, 0].camera.show_object(mesh.world_object, (1, 2, -1), up=(0, 0, 1)) figure.show() diff --git a/examples/mesh/mesh.py b/examples/mesh/mesh.py index fbeb022e5..b22ae61db 100644 --- a/examples/mesh/mesh.py +++ b/examples/mesh/mesh.py @@ -25,6 +25,8 @@ figure[0, 0].axes.grids.xy.visible = True +figure[0, 0].camera.show_object(mesh.world_object, (1, 1, -1), up=(0, 0, 1)) + figure.show() diff --git a/examples/mesh/surface.py b/examples/mesh/surface.py index d15ef5c93..12d8d9b1f 100644 --- a/examples/mesh/surface.py +++ b/examples/mesh/surface.py @@ -25,7 +25,8 @@ mesh = figure[0, 0].add_surface(z, colors="magenta", cmap='jet') -figure[0, 0].axes.grids.xy.visible = True +# 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.show() From 350500521672c33af9bb08bf03c5912c0c67f4d9 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 28 Nov 2025 11:59:55 +0100 Subject: [PATCH 05/21] Just remove selector logic for now --- fastplotlib/graphics/mesh.py | 225 ----------------------------------- 1 file changed, 225 deletions(-) diff --git a/fastplotlib/graphics/mesh.py b/fastplotlib/graphics/mesh.py index 9a81c0c4c..a44038371 100644 --- a/fastplotlib/graphics/mesh.py +++ b/fastplotlib/graphics/mesh.py @@ -170,228 +170,3 @@ def __init__( world_object: pygfx.Mesh = pygfx.Mesh(geometry=geometry, material=material) self._set_world_object(world_object) - - def add_linear_selector( - self, selection: float = None, axis: str = "x", **kwargs - ) -> LinearSelector: - """ - Adds a :class:`.LinearSelector`. - - Selectors are just ``Graphic`` objects, so you can manage, remove, or delete them from a - plot area just like any other ``Graphic``. - - Parameters - ---------- - selection: float, optional - selected point on the linear selector, by default the first datapoint on the line. - - axis: str, default "x" - axis that the selector resides on - - kwargs - passed to :class:`.LinearSelector` - - Returns - ------- - LinearSelector - - """ - - bounds_init, limits, size, center = self._get_linear_selector_init_args( - axis, padding=0 - ) - - if selection is None: - selection = bounds_init[0] - - selector = LinearSelector( - selection=selection, - limits=limits, - axis=axis, - parent=self, - **kwargs, - ) - - self._plot_area.add_graphic(selector, center=False) - - return selector - - def add_linear_region_selector( - self, - selection: tuple[float, float] = None, - padding: float = 0.0, - axis: str = "x", - **kwargs, - ) -> LinearRegionSelector: - """ - Add a :class:`.LinearRegionSelector`. - - Selectors are just ``Graphic`` objects, so you can manage, remove, or delete them from a - plot area just like any other ``Graphic``. - - Parameters - ---------- - selection: (float, float), optional - the starting bounds of the linear region selector, computed from data if not provided - - axis: str, default "x" - axis that the selector resides on - - padding: float, default 0.0 - Extra padding to extend the linear region selector along the orthogonal axis to make it easier to interact with. - - kwargs - passed to ``LinearRegionSelector`` - - Returns - ------- - LinearRegionSelector - linear selection graphic - - """ - - # TODO: check that all selectors work for the mesh - - bounds_init, limits, size, center = self._get_linear_selector_init_args( - axis, padding - ) - - if selection is None: - selection = bounds_init - - # create selector - selector = LinearRegionSelector( - selection=selection, - limits=limits, - size=size, - center=center, - axis=axis, - parent=self, - **kwargs, - ) - - self._plot_area.add_graphic(selector, center=False) - - # PlotArea manages this for garbage collection etc. just like all other Graphics - # so we should only work with a proxy on the user-end - return selector - - def add_rectangle_selector( - self, - selection: tuple[float, float, float, float] = None, - **kwargs, - ) -> RectangleSelector: - """ - Add a :class:`.RectangleSelector`. - - Selectors are just ``Graphic`` objects, so you can manage, remove, or delete them from a - plot area just like any other ``Graphic``. - - Parameters - ---------- - selection: (float, float, float, float), optional - initial (xmin, xmax, ymin, ymax) of the selection - """ - - # remove any nans - positions = self.positions.value[ - ~np.any(np.isnan(self.positions.value), axis=1) - ] - - x_axis_vals = positions[:, 0] - y_axis_vals = positions[:, 1] - - ymin = np.floor(y_axis_vals.min()).astype(int) - ymax = np.ceil(y_axis_vals.max()).astype(int) - - # default selection is 25% of the image - if selection is None: - selection = (x_axis_vals[0], x_axis_vals[value_25p], ymin, ymax) - - # min/max limits - limits = (x_axis_vals[0], x_axis_vals[-1], ymin * 1.5, ymax * 1.5) - - selector = RectangleSelector( - selection=selection, - limits=limits, - parent=self, - **kwargs, - ) - - self._plot_area.add_graphic(selector, center=False) - - return selector - - def add_polygon_selector( - self, - selection: list[tuple[float, float]] = None, - **kwargs, - ) -> PolygonSelector: - """ - Add a :class:`.PolygonSelector`. - - Selectors are just ``Graphic`` objects, so you can manage, remove, or delete them from a - plot area just like any other ``Graphic``. - - Parameters - ---------- - selection: List of positions, optional - Initial points for the polygon. If not given or None, you'll start drawing the selection (clicking adds points to the polygon). - """ - - # remove any nans - positions = self.positions.value[ - ~np.any(np.isnan(self.positions.value), axis=1) - ] - - x_axis_vals = positions[:, 0] - y_axis_vals = positions[:, 1] - - ymin = np.floor(y_axis_vals.min()).astype(int) - ymax = np.ceil(y_axis_vals.max()).astype(int) - - # min/max limits - limits = (x_axis_vals[0], x_axis_vals[-1], ymin * 1.5, ymax * 1.5) - - selector = PolygonSelector( - selection, - limits, - parent=self, - **kwargs, - ) - - self._plot_area.add_graphic(selector, center=False) - - return selector - - # TODO: this method is a bit of a mess, can refactor later - def _get_linear_selector_init_args( - self, axis: str, padding - ) -> tuple[tuple[float, float], tuple[float, float], float, float]: - bounds = self.world_object.get_bounding_box() - breakpoint() - - size = float - center = tuple - bounds_init = None - - if axis == "x": - # xvals - axis_vals = data[:, 0] - - # yvals to get size and center - magn_vals = data[:, 1] - elif axis == "y": - axis_vals = data[:, 1] - magn_vals = data[:, 0] - - bounds_init = axis_vals[0], axis_vals[value_25p] - limits = axis_vals[0], axis_vals[-1] - - # width or height of selector - size = int(np.ptp(magn_vals) * 1.5 + padding) - - # center of selector along the other axis - center = sum(quick_min_max(magn_vals)) / 2 - - return bounds_init, limits, size, center From c93b13f0df37015558be93794ebd45611a615154 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 28 Nov 2025 12:14:10 +0100 Subject: [PATCH 06/21] new examples in gallery --- docs/source/conf.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index 74a1fbaf9..da446ab96 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -44,7 +44,7 @@ "sphinx.ext.viewcode", "sphinx_copybutton", "sphinx_design", - "sphinx_gallery.gen_gallery" + "sphinx_gallery.gen_gallery", ] sphinx_gallery_conf = { @@ -65,6 +65,7 @@ "../../examples/controllers", "../../examples/line", "../../examples/line_collection", + "../../examples/mesh", "../../examples/scatter", "../../examples/vectors", "../../examples/text", @@ -77,9 +78,9 @@ "../../examples/qt", ] ), - "ignore_pattern": r'__init__\.py', + "ignore_pattern": r"__init__\.py", "nested_sections": False, - "thumbnail_size": (250, 250) + "thumbnail_size": (250, 250), } extra_conf = find_examples_for_gallery(EXAMPLES_DIR) @@ -107,7 +108,7 @@ "check_switcher": True, "switcher": { "json_url": "http://www.fastplotlib.org/_static/switcher.json", - "version_match": release + "version_match": release, }, "icon_links": [ { @@ -115,7 +116,7 @@ "url": "https://github.com/fastplotlib/fastplotlib", "icon": "fa-brands fa-github", } - ] + ], } html_static_path = ["_static"] From a5c728564b91d000824ceeb868a21e4efe0a6bf4 Mon Sep 17 00:00:00 2001 From: Kushal Kolar Date: Mon, 1 Dec 2025 21:52:17 -0500 Subject: [PATCH 07/21] mesh with graphic features (#954) * mesh with gfeatures * surface graphic works * update, add PolygonGraphic, works * black * for docs and test screenshots * black * polygon data updating works * update * black * more examples * update add_graphic mixin * code review changes * fix * better polygon example --- examples/mesh/README.rst | 2 + examples/mesh/image_surface.py | 5 +- examples/mesh/mesh.py | 7 +- examples/mesh/polygon_animation.py | 76 ++++ examples/mesh/polygons.py | 61 +++ examples/mesh/surface_earth.py | 95 ++++ examples/mesh/surface_ellipsoid.py | 55 +++ examples/mesh/surface_gaussian.py | 45 ++ .../mesh/{surface.py => surface_height.py} | 10 +- examples/mesh/surface_ripple.py | 62 +++ examples/mesh/surface_sphere_ripple.py | 81 ++++ examples/mesh/surface_terrain.py | 36 ++ examples/tests/testutils.py | 1 + fastplotlib/graphics/__init__.py | 4 +- fastplotlib/graphics/features/__init__.py | 16 +- fastplotlib/graphics/features/_base.py | 6 + fastplotlib/graphics/features/_mesh.py | 297 +++++++++++++ .../{_positions_graphics.py => _positions.py} | 2 - fastplotlib/graphics/features/utils.py | 5 +- fastplotlib/graphics/mesh.py | 413 +++++++++++++++--- fastplotlib/layouts/_graphic_methods_mixin.py | 222 +++++----- 21 files changed, 1310 insertions(+), 191 deletions(-) create mode 100644 examples/mesh/README.rst create mode 100644 examples/mesh/polygon_animation.py create mode 100644 examples/mesh/polygons.py create mode 100644 examples/mesh/surface_earth.py create mode 100644 examples/mesh/surface_ellipsoid.py create mode 100644 examples/mesh/surface_gaussian.py rename examples/mesh/{surface.py => surface_height.py} (70%) create mode 100644 examples/mesh/surface_ripple.py create mode 100644 examples/mesh/surface_sphere_ripple.py create mode 100644 examples/mesh/surface_terrain.py create mode 100644 fastplotlib/graphics/features/_mesh.py rename fastplotlib/graphics/features/{_positions_graphics.py => _positions.py} (99%) 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/mesh/image_surface.py b/examples/mesh/image_surface.py index c089f0f90..fce3c4958 100644 --- a/examples/mesh/image_surface.py +++ b/examples/mesh/image_surface.py @@ -10,19 +10,18 @@ import imageio.v3 as iio import fastplotlib as fpl -import numpy as np import scipy.ndimage 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 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/mesh.py b/examples/mesh/mesh.py index b22ae61db..4c8de088d 100644 --- a/examples/mesh/mesh.py +++ b/examples/mesh/mesh.py @@ -9,11 +9,10 @@ # sphinx_gallery_pygfx_docs = 'screenshot' import fastplotlib as fpl -import numpy as np 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 @@ -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/mesh/polygon_animation.py b/examples/mesh/polygon_animation.py new file mode 100644 index 000000000..6d4bc7bf0 --- /dev/null +++ b/examples/mesh/polygon_animation.py @@ -0,0 +1,76 @@ +""" +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 + + +def points_to_hull(points) -> np.ndarray: + hull = ConvexHull(points, qhull_options="Qs") + return points[hull.vertices] + + +figure = fpl.Figure(size=(700, 560)) + + +cov = np.array([[1, 0], [0, 1]]) + +# sample points from a 2d gaussian +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 +polygon1 = figure[0, 0].add_polygon( + points_to_hull(samples1), colors="cyan", alpha=0.7, alpha_mode="blend" +) +# add the sampled points +scatter1 = figure[0, 0].add_scatter( + samples1, sizes=8, colors="blue", alpha=0.7, alpha_mode="blend" +) + +# 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" +) + + +def animate(): + # set new scatter data + scatter1.data[:, :-1] += np.random.normal(0, 0.05, size=samples1.size).reshape( + samples1.shape + ) + # set convex hull with new polygon vertices + polygon1.data = points_to_hull(scatter1.data[:, :-1]) + + # 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 = 10 +figure[0, 0].camera.height = 10 + +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 new file mode 100644 index 000000000..616c2e0fb --- /dev/null +++ b/examples/mesh/polygons.py @@ -0,0 +1,61 @@ +""" +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] + + +# 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], [20, 10], [20, 15], [10, 15]]) +triangle_data = np.array( + [ + [-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), # 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() + + +# 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_earth.py b/examples/mesh/surface_earth.py new file mode 100644 index 000000000..c2e137bc8 --- /dev/null +++ b/examples/mesh/surface_earth.py @@ -0,0 +1,95 @@ +""" +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/mesh/surface_ellipsoid.py b/examples/mesh/surface_ellipsoid.py new file mode 100644 index 000000000..6d7cdae7b --- /dev/null +++ b/examples/mesh/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/mesh/surface_gaussian.py b/examples/mesh/surface_gaussian.py new file mode 100644 index 000000000..6a9fb0f1d --- /dev/null +++ b/examples/mesh/surface_gaussian.py @@ -0,0 +1,45 @@ +""" +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.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", 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/mesh/surface_height.py similarity index 70% rename from examples/mesh/surface.py rename to examples/mesh/surface_height.py index 12d8d9b1f..1e1db7ffe 100644 --- a/examples/mesh/surface.py +++ b/examples/mesh/surface_height.py @@ -13,20 +13,18 @@ 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 - -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/mesh/surface_ripple.py b/examples/mesh/surface_ripple.py new file mode 100644 index 000000000..ac556bd1b --- /dev/null +++ b/examples/mesh/surface_ripple.py @@ -0,0 +1,62 @@ +""" +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/mesh/surface_sphere_ripple.py b/examples/mesh/surface_sphere_ripple.py new file mode 100644 index 000000000..6caa03465 --- /dev/null +++ b/examples/mesh/surface_sphere_ripple.py @@ -0,0 +1,81 @@ +""" +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/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() 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", diff --git a/fastplotlib/graphics/__init__.py b/fastplotlib/graphics/__init__.py index 04fa95001..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 +from .mesh import MeshGraphic, SurfaceGraphic, PolygonGraphic from .text import TextGraphic from .line_collection import LineCollection, LineStack @@ -17,6 +17,8 @@ "ImageVolumeGraphic", "VectorsGraphic", "MeshGraphic", + "SurfaceGraphic", + "PolygonGraphic", "TextGraphic", "LineCollection", "LineStack", diff --git a/fastplotlib/graphics/features/__init__.py b/fastplotlib/graphics/features/__init__.py index f745f10c8..cb0b3ab2e 100644 --- a/fastplotlib/graphics/features/__init__.py +++ b/fastplotlib/graphics/features/__init__.py @@ -1,10 +1,20 @@ -from ._positions_graphics import ( +from ._positions import ( VertexColors, UniformColor, SizeSpace, VertexPositions, VertexCmap, ) +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, @@ -71,6 +81,10 @@ "SizeSpace", "VertexPositions", "VertexCmap", + "MeshVertexPositions", + "MeshIndices", + "MeshCmap", + "SurfaceData", "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..2ec2af3af --- /dev/null +++ b/fastplotlib/graphics/features/_mesh.py @@ -0,0 +1,297 @@ +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 +from ...utils.triangulation import triangulate + + +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.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}" + ) + + 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) + + +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 surface 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.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: + 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) + + +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.world_object.geometry + + # Need larger buffer? + 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(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 + 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/_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/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 a44038371..94441db98 100644 --- a/fastplotlib/graphics/mesh.py +++ b/fastplotlib/graphics/mesh.py @@ -1,63 +1,43 @@ -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, + SurfaceData, + surface_data_to_mesh, VertexColors, UniformColor, - VertexCmap, + resolve_cmap_mesh, + VolumeSlicePlane, + PolygonData, + triangulate_polygon, ) -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"] = "phong", + 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, + clim: tuple[float, float] = None, isolated_buffer: bool = True, **kwargs, ): @@ -73,6 +53,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. @@ -86,6 +75,13 @@ 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. + + 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` @@ -94,32 +90,38 @@ 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) + + # Apply contrast limits. Would be nice if Pygfx mesh material had clim too! But + # for now we apply it as a pre-processing step. + if clim is None and mapcoords is not None: + clim = mapcoords.min(), mapcoords.max() + + if mapcoords is not None: + mapcoords = (mapcoords - clim[0]) / (clim[1] - clim[0]) + self._mapcoords = pygfx.Buffer(np.asarray(mapcoords, dtype=np.float32)) else: - self._mapcoords = mapcoords = BufferManager( - mapcoords, isolated_buffer=isolated_buffer, property_name="mapcoords" - ) + self._mapcoords = None + + self._clim = clim 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 +132,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 +141,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 + if cmap is not None: + material.map = resolve_cmap_mesh(cmap) # Decide on color mode # uniform = None #: Use the uniform color (usually ``material.color``). @@ -160,7 +177,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 cmap is not None: material.color_mode = "vertex_map" elif per_vertex_colors: material.color_mode = "vertex" @@ -170,3 +187,287 @@ 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"]: + """get mesh rendering mode""" + 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: + """get or set the mapcoords""" + if self._mapcoords is not None: + return self._mapcoords.data + + @mapcoords.setter + def mapcoords(self, new_mapcoords: np.ndarray | None): + if new_mapcoords is None: + self.world_object.geometry.texcoords = None + self._mapcoords = None + return + + if new_mapcoords.shape == self._mapcoords.data.shape: + self._mapcoords.data[:] = new_mapcoords + self._mapcoords.update_full() + else: + # allocate new buffer + self._mapcoords = pygfx.Buffer(np.asarray(new_mapcoords, dtype=np.float32)) + self.world_object.geometry.texcoords = self._mapcoords + + @property + def clim(self) -> tuple[float, float] | None: + """get or set the colormap limits""" + return self._clim + + @clim.setter + def clim(self, new_clim: tuple[float, float]): + if len(new_clim) != 2: + raise ValueError("clim must be a: tuple[float, float]") + + self._clim = tuple(new_clim) + + self.mapcoords = (self.mapcoords - self.clim[0]) / (self.clim[1] - self.clim[0]) + + @property + def colors(self) -> VertexColors | pygfx.Color: + """Get or set the colors""" + if isinstance(self._colors, VertexColors): + return self._colors + + elif isinstance(self._colors, UniformColor): + return self._colors.value + + @colors.setter + def colors(self, value: str | np.ndarray | Sequence[float] | Sequence[str]): + if isinstance(self._colors, VertexColors): + self._colors[:] = value + + elif isinstance(self._colors, UniformColor): + self._colors.set_value(self, value) + + @property + def cmap(self) -> str | dict | pygfx.Texture | pygfx.TextureMap | np.ndarray | None: + """get or set the cmap""" + if self._cmap is not None: + return self._cmap.value + + @cmap.setter + def cmap( + self, + new_cmap: str | dict | pygfx.Texture | pygfx.TextureMap | np.ndarray | None, + ): + self._cmap.set_value(self, new_cmap) + + @property + def plane(self) -> tuple[float, float, float, float] | None: + """Get or set the current slice plane. Valid only for ``"slice"`` render mode.""" + if self.mode != "slice": + return + + return self._plane.value + + @plane.setter + def plane(self, value: tuple[float, float, float, float]): + if self.mode != "slice": + raise TypeError("`plane` property is only valid for `slice` render mode.") + + self._plane.set_value(self, value) + + +class SurfaceGraphic(MeshGraphic): + _features = { + "data": SurfaceData, + "colors": (VertexColors, UniformColor), + "cmap": MeshCmap, + } + + def __init__( + self, + data: np.ndarray, + mode: Literal["basic", "phong", "slice"] = "phong", + colors: str | np.ndarray | Sequence = "w", + mapcoords: Any = None, + cmap: str | dict | pygfx.Texture | pygfx.TextureMap | np.ndarray = None, + clim: tuple[float, float] | None = None, + **kwargs, + ): + """ + Create a Surface mesh Graphic + + Parameters + ---------- + data: array-like + A height-map (an image where the values indicate height, i.e. z values). + Can also be a [m, n, 3] to explicitly specify the x and y values in addition to the z values. + [m, n, 3] is a dstack of (x, y, z) values that form a grid on the xy plane. + + mode: one of "basic", "phong", "slice", default "phong" + * basic: illuminate mesh with only ambient lighting + * phong: phong lighting model, good for most use cases, see https://en.wikipedia.org/wiki/Phong_shading + + colors: str, array, or iterable, default "w" + A uniform color, or the per-position colors. + + mapcoords: array-like + The per-position coordinates to which to apply the colormap (a.k.a. texcoords). + These can e.g. be some domain-specific value (mapped to [0..1] using ``clim``). + If not given, they will be the depth (z-coordinate) of the surface. + + cmap: str, optional + Apply a colormap to the mesh, this overrides any argument passed to + "colors". For supported colormaps see the ``cmap`` library + catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/ + Both 1D and 2D colormaps are supported, though the mapcoords has to match the dimensionality. + + clim: tuple[float, float] + The colormap limits. If the mapcoords has values between e.g. 5 and 90, you want to set the clim + to e.g. (5, 90) or (0, 100) to determine how the values map onto the colormap. + + **kwargs + passed to :class:`.Graphic` + + """ + + self._data = SurfaceData(data) + + positions, indices = surface_data_to_mesh(data) + + cmap_tex_view = resolve_cmap_mesh(cmap) + if (cmap_tex_view is not None) and (mapcoords is None): + if cmap_tex_view.texture.dim == 1: # 1d + mapcoords = positions[:, 2] + + elif cmap_tex_view.texture.dim == 2: + mapcoords = np.column_stack((positions[:, 0], positions[:, 1])).astype( + np.float32 + ) + + super().__init__( + positions, + indices, + mode=mode, + colors=colors, + mapcoords=mapcoords, + cmap=cmap, + clim=clim, + **kwargs, + ) + + @property + def data(self) -> np.ndarray: + """get or set the surface data""" + return self._data.value + + @data.setter + def data(self, new_data: np.ndarray): + self._data.set_value(self, new_data) + + +class PolygonGraphic(MeshGraphic): + _features = { + "data": SurfaceData, + "colors": (VertexColors, UniformColor), + "cmap": MeshCmap, + } + + def __init__( + self, + data: np.ndarray, + mode: Literal["basic", "phong"] = "basic", + colors: str | np.ndarray | Sequence = "w", + mapcoords: Any = None, + cmap: str | dict | pygfx.Texture | pygfx.TextureMap | np.ndarray = None, + clim: tuple[float, float] | None = None, + **kwargs, + ): + """ + Create a polygon mesh graphic. + + The data are always in the 'xy' plane. Set a rotation to display the polygon in another plane or in 3D space. + + Parameters + ---------- + data: array-like + The polygon vertices, must be of shape: [n_vertices, 2] + + mode: one of "basic", "phong", "slice", default "phong" + * basic: illuminate mesh with only ambient lighting + * phong: phong lighting model, good for most use cases, see https://en.wikipedia.org/wiki/Phong_shading + + colors: str, array, or iterable, default "w" + A uniform color, or the per-position colors. + + mapcoords: array-like + The per-position coordinates to which to apply the colormap (a.k.a. texcoords). + These can e.g. be some domain-specific value (mapped to [0..1] using ``clim``). + If not given, they will be the depth (z-coordinate) of the surface. + + cmap: str, optional + Apply a colormap to the mesh, this overrides any argument passed to + "colors". For supported colormaps see the ``cmap`` library + catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/ + Both 1D and 2D colormaps are supported, though the mapcoords has to match the dimensionality. + + clim: tuple[float, float] + The colormap limits. If the mapcoords has values between e.g. 5 and 90, you want to set the clim + to e.g. (5, 90) or (0, 100) to determine how the values map onto the colormap. + + **kwargs + passed to :class:`.Graphic` + """ + + positions, indices = triangulate_polygon(data) + + self._data = PolygonData(positions) + + super().__init__( + positions, + indices, + mode=mode, + colors=colors, + mapcoords=mapcoords, + cmap=cmap, + clim=clim, + **kwargs, + ) + + @property + def data(self) -> np.ndarray: + """get or set the polygon vertex data""" + return self._data.value + + @data.setter + def data(self, new_data: np.ndarray | Sequence): + self._data.set_value(self, new_data) + + @property + def clim(self) -> tuple[float, float] | None: + """get or set the colormap limits""" + return self._clim + + @clim.setter + def clim(self, new_clim: tuple[float, float]): + if len(new_clim) != 2: + raise ValueError("clim must be a: tuple[float, float]") + + self._clim = tuple(new_clim) + + self.mapcoords = (self.mapcoords - self.clim[0]) / (self.clim[1] - self.clim[0]) diff --git a/fastplotlib/layouts/_graphic_methods_mixin.py b/fastplotlib/layouts/_graphic_methods_mixin.py index 403cd0a22..ae227a3f0 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: @@ -437,19 +436,26 @@ 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, ) -> MeshGraphic: """ - Create a mesh Graphic + Create a mesh Graphic. Parameters ---------- - positions: array-like The 3D positions of the vertices. @@ -457,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. @@ -470,6 +485,13 @@ 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. + + 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` @@ -480,6 +502,8 @@ def add_mesh( MeshGraphic, positions, indices, + mode, + plane, colors, mapcoords, cmap, @@ -487,25 +511,36 @@ def add_mesh( **kwargs, ) - def add_surface( + def add_polygon( self, - data: Any, + data: numpy.ndarray, + mode: Literal["basic", "phong"] = "basic", colors: Union[str, numpy.ndarray, Sequence] = "w", mapcoords: Any = None, - cmap: str = None, + cmap: ( + str + | dict + | pygfx.resources._texture.Texture + | pygfx.resources._texturemap.TextureMap + | numpy.ndarray + ) = None, clim: tuple[float, float] | None = None, - isolated_buffer: bool = True, **kwargs, - ) -> MeshGraphic: + ) -> PolygonGraphic: """ - Create a 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: 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. + 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. @@ -528,111 +563,9 @@ def add_surface( **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, + PolygonGraphic, data, mode, colors, mapcoords, cmap, clim, **kwargs ) def add_scatter( @@ -789,6 +722,63 @@ def add_scatter( **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. + + 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( + SurfaceGraphic, data, mode, colors, mapcoords, cmap, clim, **kwargs + ) + def add_text( self, text: str, From 8bac8b9cc716e901883db9b571dfe5433a3b0a8a Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 1 Dec 2025 21:57:48 -0500 Subject: [PATCH 08/21] remove unused import --- examples/guis/sine_cosine_funcs.py | 1 - 1 file changed, 1 deletion(-) diff --git a/examples/guis/sine_cosine_funcs.py b/examples/guis/sine_cosine_funcs.py index f7dd064cf..935f9a5a1 100644 --- a/examples/guis/sine_cosine_funcs.py +++ b/examples/guis/sine_cosine_funcs.py @@ -9,7 +9,6 @@ # test_example = false # sphinx_gallery_pygfx_docs = 'screenshot' -import glfw import numpy as np import fastplotlib as fpl from fastplotlib.ui import EdgeWindow From 83ac73dca855d4f775a6d63e472c3777b0eb1a0f Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 1 Dec 2025 21:58:32 -0500 Subject: [PATCH 09/21] update graphics mixin --- fastplotlib/layouts/_graphic_methods_mixin.py | 47 ++++++++++--------- 1 file changed, 25 insertions(+), 22 deletions(-) diff --git a/fastplotlib/layouts/_graphic_methods_mixin.py b/fastplotlib/layouts/_graphic_methods_mixin.py index ae227a3f0..8edb6691b 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( @@ -437,7 +437,7 @@ def add_mesh( 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, 0.0, 1.0, 0.0), colors: Union[str, numpy.ndarray, Sequence] = "w", mapcoords: Any = None, cmap: ( @@ -447,8 +447,9 @@ def add_mesh( | pygfx.resources._texturemap.TextureMap | numpy.ndarray ) = None, + clim: tuple[float, float] = None, isolated_buffer: bool = True, - **kwargs, + **kwargs ) -> MeshGraphic: """ @@ -468,7 +469,7 @@ def add_mesh( * 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. @@ -507,8 +508,9 @@ def add_mesh( colors, mapcoords, cmap, + clim, isolated_buffer, - **kwargs, + **kwargs ) def add_polygon( @@ -525,7 +527,7 @@ def add_polygon( | numpy.ndarray ) = None, clim: tuple[float, float] | None = None, - **kwargs, + **kwargs ) -> PolygonGraphic: """ @@ -591,7 +593,7 @@ def add_scatter( uniform_size: bool = False, size_space: str = "screen", isolated_buffer: bool = True, - **kwargs, + **kwargs ) -> ScatterGraphic: """ @@ -719,7 +721,7 @@ def add_scatter( uniform_size, size_space, isolated_buffer, - **kwargs, + **kwargs ) def add_surface( @@ -736,7 +738,7 @@ def add_surface( | numpy.ndarray ) = None, clim: tuple[float, float] | None = None, - **kwargs, + **kwargs ) -> SurfaceGraphic: """ @@ -747,6 +749,7 @@ def add_surface( 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 @@ -789,7 +792,7 @@ def add_text( screen_space: bool = True, offset: tuple[float] = (0, 0, 0), anchor: str = "middle-center", - **kwargs, + **kwargs ) -> TextGraphic: """ @@ -840,7 +843,7 @@ def add_text( screen_space, offset, anchor, - **kwargs, + **kwargs ) def add_vectors( @@ -850,7 +853,7 @@ def add_vectors( color: Union[str, Sequence[float], numpy.ndarray] = "w", size: float = None, vector_shape_options: dict = None, - **kwargs, + **kwargs ) -> VectorsGraphic: """ @@ -895,5 +898,5 @@ def add_vectors( color, size, vector_shape_options, - **kwargs, + **kwargs ) From fbecf03ebf15491bad3be922d5076d3eb4877a91 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 1 Dec 2025 21:59:17 -0500 Subject: [PATCH 10/21] docs --- docs/source/api/graphic_features/MeshCmap.rst | 35 ++ .../api/graphic_features/MeshIndices.rst | 36 ++ .../graphic_features/MeshVertexPositions.rst | 36 ++ .../api/graphic_features/SurfaceData.rst | 35 ++ docs/source/api/graphic_features/index.rst | 4 + docs/source/api/graphics/MeshGraphic.rst | 55 +++ docs/source/api/graphics/PolygonGraphic.rst | 56 +++ docs/source/api/graphics/SurfaceGraphic.rst | 56 +++ docs/source/api/graphics/index.rst | 3 + docs/source/api/layouts/subplot.rst | 3 + docs/source/user_guide/event_tables.rst | 399 ++++++++++++++++++ 11 files changed, 718 insertions(+) create mode 100644 docs/source/api/graphic_features/MeshCmap.rst create mode 100644 docs/source/api/graphic_features/MeshIndices.rst create mode 100644 docs/source/api/graphic_features/MeshVertexPositions.rst create mode 100644 docs/source/api/graphic_features/SurfaceData.rst create mode 100644 docs/source/api/graphics/MeshGraphic.rst create mode 100644 docs/source/api/graphics/PolygonGraphic.rst create mode 100644 docs/source/api/graphics/SurfaceGraphic.rst diff --git a/docs/source/api/graphic_features/MeshCmap.rst b/docs/source/api/graphic_features/MeshCmap.rst new file mode 100644 index 000000000..865ac13d9 --- /dev/null +++ b/docs/source/api/graphic_features/MeshCmap.rst @@ -0,0 +1,35 @@ +.. _api.MeshCmap: + +MeshCmap +******** + +======== +MeshCmap +======== +.. currentmodule:: fastplotlib.graphics.features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: MeshCmap_api + + MeshCmap + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: MeshCmap_api + + MeshCmap.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: MeshCmap_api + + MeshCmap.add_event_handler + MeshCmap.block_events + MeshCmap.clear_event_handlers + MeshCmap.remove_event_handler + MeshCmap.set_value + diff --git a/docs/source/api/graphic_features/MeshIndices.rst b/docs/source/api/graphic_features/MeshIndices.rst new file mode 100644 index 000000000..6005ca0c0 --- /dev/null +++ b/docs/source/api/graphic_features/MeshIndices.rst @@ -0,0 +1,36 @@ +.. _api.MeshIndices: + +MeshIndices +*********** + +=========== +MeshIndices +=========== +.. currentmodule:: fastplotlib.graphics.features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: MeshIndices_api + + MeshIndices + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: MeshIndices_api + + MeshIndices.buffer + MeshIndices.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: MeshIndices_api + + MeshIndices.add_event_handler + MeshIndices.block_events + MeshIndices.clear_event_handlers + MeshIndices.remove_event_handler + MeshIndices.set_value + diff --git a/docs/source/api/graphic_features/MeshVertexPositions.rst b/docs/source/api/graphic_features/MeshVertexPositions.rst new file mode 100644 index 000000000..d26fc461b --- /dev/null +++ b/docs/source/api/graphic_features/MeshVertexPositions.rst @@ -0,0 +1,36 @@ +.. _api.MeshVertexPositions: + +MeshVertexPositions +******************* + +=================== +MeshVertexPositions +=================== +.. currentmodule:: fastplotlib.graphics.features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: MeshVertexPositions_api + + MeshVertexPositions + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: MeshVertexPositions_api + + MeshVertexPositions.buffer + MeshVertexPositions.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: MeshVertexPositions_api + + MeshVertexPositions.add_event_handler + MeshVertexPositions.block_events + MeshVertexPositions.clear_event_handlers + MeshVertexPositions.remove_event_handler + MeshVertexPositions.set_value + diff --git a/docs/source/api/graphic_features/SurfaceData.rst b/docs/source/api/graphic_features/SurfaceData.rst new file mode 100644 index 000000000..87828d226 --- /dev/null +++ b/docs/source/api/graphic_features/SurfaceData.rst @@ -0,0 +1,35 @@ +.. _api.SurfaceData: + +SurfaceData +*********** + +=========== +SurfaceData +=========== +.. currentmodule:: fastplotlib.graphics.features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: SurfaceData_api + + SurfaceData + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: SurfaceData_api + + SurfaceData.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: SurfaceData_api + + SurfaceData.add_event_handler + SurfaceData.block_events + SurfaceData.clear_event_handlers + SurfaceData.remove_event_handler + SurfaceData.set_value + diff --git a/docs/source/api/graphic_features/index.rst b/docs/source/api/graphic_features/index.rst index 5c5c2b464..bd6f8615a 100644 --- a/docs/source/api/graphic_features/index.rst +++ b/docs/source/api/graphic_features/index.rst @@ -9,6 +9,10 @@ Graphic Features SizeSpace VertexPositions VertexCmap + MeshVertexPositions + MeshIndices + MeshCmap + SurfaceData Thickness VertexMarkers UniformMarker diff --git a/docs/source/api/graphics/MeshGraphic.rst b/docs/source/api/graphics/MeshGraphic.rst new file mode 100644 index 000000000..5e2c5dac5 --- /dev/null +++ b/docs/source/api/graphics/MeshGraphic.rst @@ -0,0 +1,55 @@ +.. _api.MeshGraphic: + +MeshGraphic +*********** + +=========== +MeshGraphic +=========== +.. currentmodule:: fastplotlib + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: MeshGraphic_api + + MeshGraphic + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: MeshGraphic_api + + MeshGraphic.alpha + MeshGraphic.alpha_mode + MeshGraphic.axes + MeshGraphic.block_events + MeshGraphic.clim + MeshGraphic.cmap + MeshGraphic.colors + MeshGraphic.deleted + MeshGraphic.event_handlers + MeshGraphic.indices + MeshGraphic.mapcoords + MeshGraphic.mode + MeshGraphic.name + MeshGraphic.offset + MeshGraphic.plane + MeshGraphic.positions + MeshGraphic.right_click_menu + MeshGraphic.rotation + MeshGraphic.supported_events + MeshGraphic.visible + MeshGraphic.world_object + +Methods +~~~~~~~ +.. autosummary:: + :toctree: MeshGraphic_api + + MeshGraphic.add_axes + MeshGraphic.add_event_handler + MeshGraphic.clear_event_handlers + MeshGraphic.remove_event_handler + MeshGraphic.rotate + diff --git a/docs/source/api/graphics/PolygonGraphic.rst b/docs/source/api/graphics/PolygonGraphic.rst new file mode 100644 index 000000000..f9446f425 --- /dev/null +++ b/docs/source/api/graphics/PolygonGraphic.rst @@ -0,0 +1,56 @@ +.. _api.PolygonGraphic: + +PolygonGraphic +************** + +============== +PolygonGraphic +============== +.. currentmodule:: fastplotlib + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: PolygonGraphic_api + + PolygonGraphic + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: PolygonGraphic_api + + PolygonGraphic.alpha + PolygonGraphic.alpha_mode + PolygonGraphic.axes + PolygonGraphic.block_events + PolygonGraphic.clim + PolygonGraphic.cmap + PolygonGraphic.colors + PolygonGraphic.data + PolygonGraphic.deleted + PolygonGraphic.event_handlers + PolygonGraphic.indices + PolygonGraphic.mapcoords + PolygonGraphic.mode + PolygonGraphic.name + PolygonGraphic.offset + PolygonGraphic.plane + PolygonGraphic.positions + PolygonGraphic.right_click_menu + PolygonGraphic.rotation + PolygonGraphic.supported_events + PolygonGraphic.visible + PolygonGraphic.world_object + +Methods +~~~~~~~ +.. autosummary:: + :toctree: PolygonGraphic_api + + PolygonGraphic.add_axes + PolygonGraphic.add_event_handler + PolygonGraphic.clear_event_handlers + PolygonGraphic.remove_event_handler + PolygonGraphic.rotate + diff --git a/docs/source/api/graphics/SurfaceGraphic.rst b/docs/source/api/graphics/SurfaceGraphic.rst new file mode 100644 index 000000000..385ce2432 --- /dev/null +++ b/docs/source/api/graphics/SurfaceGraphic.rst @@ -0,0 +1,56 @@ +.. _api.SurfaceGraphic: + +SurfaceGraphic +************** + +============== +SurfaceGraphic +============== +.. currentmodule:: fastplotlib + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: SurfaceGraphic_api + + SurfaceGraphic + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: SurfaceGraphic_api + + SurfaceGraphic.alpha + SurfaceGraphic.alpha_mode + SurfaceGraphic.axes + SurfaceGraphic.block_events + SurfaceGraphic.clim + SurfaceGraphic.cmap + SurfaceGraphic.colors + SurfaceGraphic.data + SurfaceGraphic.deleted + SurfaceGraphic.event_handlers + SurfaceGraphic.indices + SurfaceGraphic.mapcoords + SurfaceGraphic.mode + SurfaceGraphic.name + SurfaceGraphic.offset + SurfaceGraphic.plane + SurfaceGraphic.positions + SurfaceGraphic.right_click_menu + SurfaceGraphic.rotation + SurfaceGraphic.supported_events + SurfaceGraphic.visible + SurfaceGraphic.world_object + +Methods +~~~~~~~ +.. autosummary:: + :toctree: SurfaceGraphic_api + + SurfaceGraphic.add_axes + SurfaceGraphic.add_event_handler + SurfaceGraphic.clear_event_handlers + SurfaceGraphic.remove_event_handler + SurfaceGraphic.rotate + diff --git a/docs/source/api/graphics/index.rst b/docs/source/api/graphics/index.rst index ac47a7dfd..bac85e6c1 100644 --- a/docs/source/api/graphics/index.rst +++ b/docs/source/api/graphics/index.rst @@ -10,6 +10,9 @@ Graphics ImageGraphic ImageVolumeGraphic VectorsGraphic + MeshGraphic + SurfaceGraphic + PolygonGraphic TextGraphic LineCollection LineStack diff --git a/docs/source/api/layouts/subplot.rst b/docs/source/api/layouts/subplot.rst index 4e40e8d08..a7455cf5f 100644 --- a/docs/source/api/layouts/subplot.rst +++ b/docs/source/api/layouts/subplot.rst @@ -52,7 +52,10 @@ Methods Subplot.add_line Subplot.add_line_collection Subplot.add_line_stack + Subplot.add_mesh + Subplot.add_polygon Subplot.add_scatter + Subplot.add_surface Subplot.add_text Subplot.add_vectors Subplot.auto_scale diff --git a/docs/source/user_guide/event_tables.rst b/docs/source/user_guide/event_tables.rst index 8e942830e..ba53c3411 100644 --- a/docs/source/user_guide/event_tables.rst +++ b/docs/source/user_guide/event_tables.rst @@ -897,6 +897,405 @@ deleted | value | bool | True when graphic was deleted | +----------+------+-------------------------------+ +MeshGraphic +----------- + +positions +^^^^^^^^^ + +**event info dict** + ++----------+----------------------------------------------+--------------------------------------------------------+ +| dict key | type | description | ++==========+==============================================+========================================================+ +| key | slice, index (int) or numpy-like fancy index | key at which vertex positions data were indexed/sliced | ++----------+----------------------------------------------+--------------------------------------------------------+ +| value | int | float | array-like | new data values for points that were changed | ++----------+----------------------------------------------+--------------------------------------------------------+ + +indices +^^^^^^^ + +**event info dict** + ++----------+----------------------------------------------+-------------------------------------------------+ +| dict key | type | description | ++==========+==============================================+=================================================+ +| key | slice, index (int) or numpy-like fancy index | key at which vertex indices were indexed/sliced | ++----------+----------------------------------------------+-------------------------------------------------+ +| value | int | float | array-like | new data values for indices that were changed | ++----------+----------------------------------------------+-------------------------------------------------+ + +colors +^^^^^^ + +**event info dict** + ++------------+--------------------------------------+------------------------------------------------------+ +| dict key | type | description | ++============+======================================+======================================================+ +| key | slice, index, numpy-like fancy index | index/slice at which colors were indexed/sliced | ++------------+--------------------------------------+------------------------------------------------------+ +| value | np.ndarray [n_points_changed, RGBA] | new color values for points that were changed | ++------------+--------------------------------------+------------------------------------------------------+ +| user_value | str or array-like | user input value that was parsed into the RGBA array | ++------------+--------------------------------------+------------------------------------------------------+ + +colors +^^^^^^ + +**event info dict** + ++----------+--------------------------------------------------+-----------------+ +| dict key | type | description | ++==========+==================================================+=================+ +| value | str | pygfx.Color | np.ndarray | Sequence[float] | new color value | ++----------+--------------------------------------------------+-----------------+ + +cmap +^^^^ + +**event info dict** + ++----------+------------------------------------------------------------+-------------+ +| dict key | type | description | ++==========+============================================================+=============+ +| value | str | dict | pygfx.TextureMap | pygfx.Texture | np.ndarray | new cmap | ++----------+------------------------------------------------------------+-------------+ + +name +^^^^ + +**event info dict** + ++----------+------+--------------------+ +| dict key | type | description | ++==========+======+====================+ +| value | str | user provided name | ++----------+------+--------------------+ + +offset +^^^^^^ + +**event info dict** + ++----------+---------------------------------+----------------------+ +| dict key | type | description | ++==========+=================================+======================+ +| value | np.ndarray[float, float, float] | new offset (x, y, z) | ++----------+---------------------------------+----------------------+ + +rotation +^^^^^^^^ + +**event info dict** + ++----------+----------------------------------------+-------------------------+ +| dict key | type | description | ++==========+========================================+=========================+ +| value | np.ndarray[float, float, float, float] | new rotation quaternion | ++----------+----------------------------------------+-------------------------+ + +alpha +^^^^^ + +**event info dict** + ++----------+-------+-----------------+ +| dict key | type | description | ++==========+=======+=================+ +| value | float | new alpha value | ++----------+-------+-----------------+ + +alpha_mode +^^^^^^^^^^ + +**event info dict** + ++----------+------+----------------+ +| dict key | type | description | ++==========+======+================+ +| value | str | new alpha mode | ++----------+------+----------------+ + +visible +^^^^^^^ + +**event info dict** + ++----------+------+---------------------+ +| dict key | type | description | ++==========+======+=====================+ +| value | bool | new visibility bool | ++----------+------+---------------------+ + +deleted +^^^^^^^ + +**event info dict** + ++----------+------+-------------------------------+ +| dict key | type | description | ++==========+======+===============================+ +| value | bool | True when graphic was deleted | ++----------+------+-------------------------------+ + +SurfaceGraphic +-------------- + +data +^^^^ + +**event info dict** + ++----------+------------+------------------+ +| dict key | type | description | ++==========+============+==================+ +| value | np.ndarray | new surface data | ++----------+------------+------------------+ + +colors +^^^^^^ + +**event info dict** + ++------------+--------------------------------------+------------------------------------------------------+ +| dict key | type | description | ++============+======================================+======================================================+ +| key | slice, index, numpy-like fancy index | index/slice at which colors were indexed/sliced | ++------------+--------------------------------------+------------------------------------------------------+ +| value | np.ndarray [n_points_changed, RGBA] | new color values for points that were changed | ++------------+--------------------------------------+------------------------------------------------------+ +| user_value | str or array-like | user input value that was parsed into the RGBA array | ++------------+--------------------------------------+------------------------------------------------------+ + +colors +^^^^^^ + +**event info dict** + ++----------+--------------------------------------------------+-----------------+ +| dict key | type | description | ++==========+==================================================+=================+ +| value | str | pygfx.Color | np.ndarray | Sequence[float] | new color value | ++----------+--------------------------------------------------+-----------------+ + +cmap +^^^^ + +**event info dict** + ++----------+------------------------------------------------------------+-------------+ +| dict key | type | description | ++==========+============================================================+=============+ +| value | str | dict | pygfx.TextureMap | pygfx.Texture | np.ndarray | new cmap | ++----------+------------------------------------------------------------+-------------+ + +name +^^^^ + +**event info dict** + ++----------+------+--------------------+ +| dict key | type | description | ++==========+======+====================+ +| value | str | user provided name | ++----------+------+--------------------+ + +offset +^^^^^^ + +**event info dict** + ++----------+---------------------------------+----------------------+ +| dict key | type | description | ++==========+=================================+======================+ +| value | np.ndarray[float, float, float] | new offset (x, y, z) | ++----------+---------------------------------+----------------------+ + +rotation +^^^^^^^^ + +**event info dict** + ++----------+----------------------------------------+-------------------------+ +| dict key | type | description | ++==========+========================================+=========================+ +| value | np.ndarray[float, float, float, float] | new rotation quaternion | ++----------+----------------------------------------+-------------------------+ + +alpha +^^^^^ + +**event info dict** + ++----------+-------+-----------------+ +| dict key | type | description | ++==========+=======+=================+ +| value | float | new alpha value | ++----------+-------+-----------------+ + +alpha_mode +^^^^^^^^^^ + +**event info dict** + ++----------+------+----------------+ +| dict key | type | description | ++==========+======+================+ +| value | str | new alpha mode | ++----------+------+----------------+ + +visible +^^^^^^^ + +**event info dict** + ++----------+------+---------------------+ +| dict key | type | description | ++==========+======+=====================+ +| value | bool | new visibility bool | ++----------+------+---------------------+ + +deleted +^^^^^^^ + +**event info dict** + ++----------+------+-------------------------------+ +| dict key | type | description | ++==========+======+===============================+ +| value | bool | True when graphic was deleted | ++----------+------+-------------------------------+ + +PolygonGraphic +-------------- + +data +^^^^ + +**event info dict** + ++----------+------------+------------------+ +| dict key | type | description | ++==========+============+==================+ +| value | np.ndarray | new surface data | ++----------+------------+------------------+ + +colors +^^^^^^ + +**event info dict** + ++------------+--------------------------------------+------------------------------------------------------+ +| dict key | type | description | ++============+======================================+======================================================+ +| key | slice, index, numpy-like fancy index | index/slice at which colors were indexed/sliced | ++------------+--------------------------------------+------------------------------------------------------+ +| value | np.ndarray [n_points_changed, RGBA] | new color values for points that were changed | ++------------+--------------------------------------+------------------------------------------------------+ +| user_value | str or array-like | user input value that was parsed into the RGBA array | ++------------+--------------------------------------+------------------------------------------------------+ + +colors +^^^^^^ + +**event info dict** + ++----------+--------------------------------------------------+-----------------+ +| dict key | type | description | ++==========+==================================================+=================+ +| value | str | pygfx.Color | np.ndarray | Sequence[float] | new color value | ++----------+--------------------------------------------------+-----------------+ + +cmap +^^^^ + +**event info dict** + ++----------+------------------------------------------------------------+-------------+ +| dict key | type | description | ++==========+============================================================+=============+ +| value | str | dict | pygfx.TextureMap | pygfx.Texture | np.ndarray | new cmap | ++----------+------------------------------------------------------------+-------------+ + +name +^^^^ + +**event info dict** + ++----------+------+--------------------+ +| dict key | type | description | ++==========+======+====================+ +| value | str | user provided name | ++----------+------+--------------------+ + +offset +^^^^^^ + +**event info dict** + ++----------+---------------------------------+----------------------+ +| dict key | type | description | ++==========+=================================+======================+ +| value | np.ndarray[float, float, float] | new offset (x, y, z) | ++----------+---------------------------------+----------------------+ + +rotation +^^^^^^^^ + +**event info dict** + ++----------+----------------------------------------+-------------------------+ +| dict key | type | description | ++==========+========================================+=========================+ +| value | np.ndarray[float, float, float, float] | new rotation quaternion | ++----------+----------------------------------------+-------------------------+ + +alpha +^^^^^ + +**event info dict** + ++----------+-------+-----------------+ +| dict key | type | description | ++==========+=======+=================+ +| value | float | new alpha value | ++----------+-------+-----------------+ + +alpha_mode +^^^^^^^^^^ + +**event info dict** + ++----------+------+----------------+ +| dict key | type | description | ++==========+======+================+ +| value | str | new alpha mode | ++----------+------+----------------+ + +visible +^^^^^^^ + +**event info dict** + ++----------+------+---------------------+ +| dict key | type | description | ++==========+======+=====================+ +| value | bool | new visibility bool | ++----------+------+---------------------+ + +deleted +^^^^^^^ + +**event info dict** + ++----------+------+-------------------------------+ +| dict key | type | description | ++==========+======+===============================+ +| value | bool | True when graphic was deleted | ++----------+------+-------------------------------+ + TextGraphic ----------- From 2ccd7858983f5d5fe485c37bf834bd0e2686b899 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 1 Dec 2025 21:59:57 -0500 Subject: [PATCH 11/21] black --- fastplotlib/graphics/mesh.py | 2 +- fastplotlib/layouts/_graphic_methods_mixin.py | 40 +++++++++---------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/fastplotlib/graphics/mesh.py b/fastplotlib/graphics/mesh.py index 94441db98..44f362ee0 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, 0.0, 1.0, 0.0), colors: str | np.ndarray | Sequence = "w", mapcoords: Any = None, cmap: str | dict | pygfx.Texture | pygfx.TextureMap | np.ndarray = None, diff --git a/fastplotlib/layouts/_graphic_methods_mixin.py b/fastplotlib/layouts/_graphic_methods_mixin.py index 8edb6691b..06a4c7517 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( @@ -449,7 +449,7 @@ def add_mesh( ) = None, clim: tuple[float, float] = None, isolated_buffer: bool = True, - **kwargs + **kwargs, ) -> MeshGraphic: """ @@ -510,7 +510,7 @@ def add_mesh( cmap, clim, isolated_buffer, - **kwargs + **kwargs, ) def add_polygon( @@ -527,7 +527,7 @@ def add_polygon( | numpy.ndarray ) = None, clim: tuple[float, float] | None = None, - **kwargs + **kwargs, ) -> PolygonGraphic: """ @@ -593,7 +593,7 @@ def add_scatter( uniform_size: bool = False, size_space: str = "screen", isolated_buffer: bool = True, - **kwargs + **kwargs, ) -> ScatterGraphic: """ @@ -721,7 +721,7 @@ def add_scatter( uniform_size, size_space, isolated_buffer, - **kwargs + **kwargs, ) def add_surface( @@ -738,7 +738,7 @@ def add_surface( | numpy.ndarray ) = None, clim: tuple[float, float] | None = None, - **kwargs + **kwargs, ) -> SurfaceGraphic: """ @@ -792,7 +792,7 @@ def add_text( screen_space: bool = True, offset: tuple[float] = (0, 0, 0), anchor: str = "middle-center", - **kwargs + **kwargs, ) -> TextGraphic: """ @@ -843,7 +843,7 @@ def add_text( screen_space, offset, anchor, - **kwargs + **kwargs, ) def add_vectors( @@ -853,7 +853,7 @@ def add_vectors( color: Union[str, Sequence[float], numpy.ndarray] = "w", size: float = None, vector_shape_options: dict = None, - **kwargs + **kwargs, ) -> VectorsGraphic: """ @@ -898,5 +898,5 @@ def add_vectors( color, size, vector_shape_options, - **kwargs + **kwargs, ) From 5dfdb08aacca381dfb935504eee2925804be10d0 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 1 Dec 2025 22:29:47 -0500 Subject: [PATCH 12/21] don't need to set limit for docs gen --- docs/source/conf.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index da446ab96..8547e9ae7 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -10,15 +10,12 @@ os.environ["WGPU_FORCE_OFFSCREEN"] = "1" import fastplotlib -import pygfx from pygfx.utils.gallery_scraper import find_examples_for_gallery from pathlib import Path import sys from sphinx_gallery.sorting import ExplicitOrder import imageio.v3 as iio -MAX_TEXTURE_SIZE = 2048 -pygfx.renderers.wgpu.set_wgpu_limits(**{"max-texture-dimension-2d": MAX_TEXTURE_SIZE}) ROOT_DIR = Path(__file__).parents[1].parents[0] # repo root EXAMPLES_DIR = Path.joinpath(ROOT_DIR, "examples") From 2ddb97f9a43fd413f20c260ded7debf6c3abe60a Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 1 Dec 2025 22:32:37 -0500 Subject: [PATCH 13/21] add missing comma --- examples/tests/testutils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/tests/testutils.py b/examples/tests/testutils.py index 18ad8ed41..aad729c7a 100644 --- a/examples/tests/testutils.py +++ b/examples/tests/testutils.py @@ -24,7 +24,7 @@ "scatter/*.py", "line/*.py", "line_collection/*.py", - "vectors/*.py" + "vectors/*.py", "mesh/*.py", "gridplot/*.py", "window_layouts/*.py", From a4eecea99123abdcc5f0368e2cc4fdaf4f543f04 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 1 Dec 2025 22:43:36 -0500 Subject: [PATCH 14/21] dont run terrain example --- examples/mesh/surface_terrain.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/mesh/surface_terrain.py b/examples/mesh/surface_terrain.py index 394e65216..f747a708c 100644 --- a/examples/mesh/surface_terrain.py +++ b/examples/mesh/surface_terrain.py @@ -5,7 +5,7 @@ Surface graphic showing elevation map of the earth """ -# test_example = false +# run_example = false # sphinx_gallery_pygfx_docs = 'code' import imageio.v3 as iio From c3c528eeea46f65b12110e26391f03ac0e1b8d57 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 1 Dec 2025 23:15:46 -0500 Subject: [PATCH 15/21] new screenshots --- examples/screenshots/image_surface.png | 3 +++ examples/screenshots/mesh.png | 3 +++ examples/screenshots/no-imgui-image_surface.png | 3 +++ examples/screenshots/no-imgui-mesh.png | 3 +++ examples/screenshots/no-imgui-surface_gaussian.png | 3 +++ examples/screenshots/no-imgui-surface_height.png | 3 +++ examples/screenshots/no-imgui-vectors_simple.png | 3 +++ examples/screenshots/no-imgui-vectors_swirl.png | 3 +++ examples/screenshots/surface_gaussian.png | 3 +++ examples/screenshots/surface_height.png | 3 +++ examples/screenshots/vectors_simple.png | 3 +++ examples/screenshots/vectors_swirl.png | 3 +++ 12 files changed, 36 insertions(+) create mode 100644 examples/screenshots/image_surface.png create mode 100644 examples/screenshots/mesh.png create mode 100644 examples/screenshots/no-imgui-image_surface.png create mode 100644 examples/screenshots/no-imgui-mesh.png create mode 100644 examples/screenshots/no-imgui-surface_gaussian.png create mode 100644 examples/screenshots/no-imgui-surface_height.png create mode 100644 examples/screenshots/no-imgui-vectors_simple.png create mode 100644 examples/screenshots/no-imgui-vectors_swirl.png create mode 100644 examples/screenshots/surface_gaussian.png create mode 100644 examples/screenshots/surface_height.png create mode 100644 examples/screenshots/vectors_simple.png create mode 100644 examples/screenshots/vectors_swirl.png diff --git a/examples/screenshots/image_surface.png b/examples/screenshots/image_surface.png new file mode 100644 index 000000000..86300a7d4 --- /dev/null +++ b/examples/screenshots/image_surface.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c0a74e7a23147dc7c50b085c9beb7e1d41d012546606b586692b3b4968947569 +size 301999 diff --git a/examples/screenshots/mesh.png b/examples/screenshots/mesh.png new file mode 100644 index 000000000..8a2d5c219 --- /dev/null +++ b/examples/screenshots/mesh.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a040db1c5159f0e8e9b3dfb61b7076909481d7f3c21b25722cd0b50c14c30b2d +size 320096 diff --git a/examples/screenshots/no-imgui-image_surface.png b/examples/screenshots/no-imgui-image_surface.png new file mode 100644 index 000000000..5ebc655d1 --- /dev/null +++ b/examples/screenshots/no-imgui-image_surface.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0703c47bee63b8170100fbe58a72b41a4c40525adf2ed2c16fa2d860c627ed21 +size 311054 diff --git a/examples/screenshots/no-imgui-mesh.png b/examples/screenshots/no-imgui-mesh.png new file mode 100644 index 000000000..5a83fc871 --- /dev/null +++ b/examples/screenshots/no-imgui-mesh.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:675e4b8201f6dc77d1f7bd5269ad948b45bbdcfb400d764c771a89e8528b974f +size 325110 diff --git a/examples/screenshots/no-imgui-surface_gaussian.png b/examples/screenshots/no-imgui-surface_gaussian.png new file mode 100644 index 000000000..849d4d9cb --- /dev/null +++ b/examples/screenshots/no-imgui-surface_gaussian.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5ccd13d12890895cc70bf4b9e071ea75f6a23a90f885ac6986ac6fb1fd6d544b +size 33510 diff --git a/examples/screenshots/no-imgui-surface_height.png b/examples/screenshots/no-imgui-surface_height.png new file mode 100644 index 000000000..789783464 --- /dev/null +++ b/examples/screenshots/no-imgui-surface_height.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e8662ea400572e3a9730b42d915c093040ed2694c0f02439438898570ca41666 +size 51219 diff --git a/examples/screenshots/no-imgui-vectors_simple.png b/examples/screenshots/no-imgui-vectors_simple.png new file mode 100644 index 000000000..02f067c08 --- /dev/null +++ b/examples/screenshots/no-imgui-vectors_simple.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8eb8ba74def34c750e876c1811800157606d71423fa27c5f3e338b66513a30ee +size 129275 diff --git a/examples/screenshots/no-imgui-vectors_swirl.png b/examples/screenshots/no-imgui-vectors_swirl.png new file mode 100644 index 000000000..63300917b --- /dev/null +++ b/examples/screenshots/no-imgui-vectors_swirl.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9e41489d3fffefe5b4217879d8fd166205e81b91f7e88c2437ae21113b3937c1 +size 72255 diff --git a/examples/screenshots/surface_gaussian.png b/examples/screenshots/surface_gaussian.png new file mode 100644 index 000000000..8e9a414c4 --- /dev/null +++ b/examples/screenshots/surface_gaussian.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a40c79782f8498d4c03f0f6f09583c8a7d139a73a8457b30158d5625d77792ba +size 32108 diff --git a/examples/screenshots/surface_height.png b/examples/screenshots/surface_height.png new file mode 100644 index 000000000..56a6a2c9b --- /dev/null +++ b/examples/screenshots/surface_height.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b1f9bb4570725a7876f5296a69e9325b81e1e58633c46ae502adc0dc6ad00aca +size 50123 diff --git a/examples/screenshots/vectors_simple.png b/examples/screenshots/vectors_simple.png new file mode 100644 index 000000000..332b37812 --- /dev/null +++ b/examples/screenshots/vectors_simple.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6bf6bdfb4530a434417480bcd10713ccdc43db3a9c554da4be8e333760a8984d +size 126670 diff --git a/examples/screenshots/vectors_swirl.png b/examples/screenshots/vectors_swirl.png new file mode 100644 index 000000000..ab6f298e9 --- /dev/null +++ b/examples/screenshots/vectors_swirl.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ec512fd733f25df5706055efbdb077db5fa034349c5611a86a20f3055b0d8123 +size 71012 From 834421648034825fa34e8b4236ebaf7c749f7291 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Wed, 3 Dec 2025 11:34:59 +0100 Subject: [PATCH 16/21] remove (i expect) unnecessary check --- fastplotlib/graphics/features/_mesh.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/fastplotlib/graphics/features/_mesh.py b/fastplotlib/graphics/features/_mesh.py index 2ec2af3af..fccf69c0e 100644 --- a/fastplotlib/graphics/features/_mesh.py +++ b/fastplotlib/graphics/features/_mesh.py @@ -77,14 +77,11 @@ def __init__( ) def _fix_data(self, data): - if data.shape == (3,): - pass - elif data.ndim != 2 or data.shape[1] not in (3, 4): + 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") From 1c6dbde8735c607682e46c72d21b0821a90702bd Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 3 Dec 2025 22:11:37 -0500 Subject: [PATCH 17/21] lights as properties --- fastplotlib/layouts/_plot_area.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/fastplotlib/layouts/_plot_area.py b/fastplotlib/layouts/_plot_area.py index 977cf97fc..d94616c14 100644 --- a/fastplotlib/layouts/_plot_area.py +++ b/fastplotlib/layouts/_plot_area.py @@ -117,8 +117,11 @@ def __init__( self._background = pygfx.Background(None, self._background_material) self.scene.add(self._background) - self.scene.add(pygfx.AmbientLight()) - self.scene.add(self._camera.add(pygfx.DirectionalLight())) + self._ambient_light = pygfx.AmbientLight() + self._directional_light = pygfx.DirectionalLight() + + self.scene.add(self._ambient_light) + self.scene.add(self._camera.add(self._directional_light)) def get_figure(self, obj=None): """Get Figure instance that contains this plot area""" @@ -169,6 +172,8 @@ def camera(self, new_camera: str | pygfx.PerspectiveCamera): # user wants to set completely new camera, remove current camera from controller if isinstance(new_camera, pygfx.PerspectiveCamera): self.controller.remove_camera(self._camera) + # add directional light to new camera + new_camera.add(self._directional_light) # add new camera to controller self.controller.add_camera(new_camera) @@ -277,6 +282,16 @@ def background_color(self, colors: str | tuple[float]): """1, 2, or 4 colors, each color must be acceptable by pygfx.Color""" self._background_material.set_colors(*colors) + @property + def ambient_light(self) -> pygfx.AmbientLight: + """the ambient lighting in the scene""" + return self._ambient_light + + @property + def directional_light(self) -> pygfx.DirectionalLight: + """the directional lighting on the camera in the scene""" + return self._directional_light + @property def animations(self) -> dict[str, list[callable]]: """Returns a dictionary of 'pre' and 'post' animation functions.""" From 14159eff57deb9e97c20b7ba1a0e2844abe1098e Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 3 Dec 2025 23:04:08 -0500 Subject: [PATCH 18/21] just use VertexPositions --- fastplotlib/graphics/features/__init__.py | 2 -- fastplotlib/graphics/features/_mesh.py | 12 ------------ fastplotlib/graphics/mesh.py | 10 +++++----- 3 files changed, 5 insertions(+), 19 deletions(-) diff --git a/fastplotlib/graphics/features/__init__.py b/fastplotlib/graphics/features/__init__.py index cb0b3ab2e..cf99d376d 100644 --- a/fastplotlib/graphics/features/__init__.py +++ b/fastplotlib/graphics/features/__init__.py @@ -6,7 +6,6 @@ VertexCmap, ) from ._mesh import ( - MeshVertexPositions, MeshIndices, MeshCmap, SurfaceData, @@ -81,7 +80,6 @@ "SizeSpace", "VertexPositions", "VertexCmap", - "MeshVertexPositions", "MeshIndices", "MeshCmap", "SurfaceData", diff --git a/fastplotlib/graphics/features/_mesh.py b/fastplotlib/graphics/features/_mesh.py index 2ec2af3af..f94136728 100644 --- a/fastplotlib/graphics/features/_mesh.py +++ b/fastplotlib/graphics/features/_mesh.py @@ -37,18 +37,6 @@ def resolve_cmap_mesh(cmap) -> pygfx.TextureMap | None: 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 = [ { diff --git a/fastplotlib/graphics/mesh.py b/fastplotlib/graphics/mesh.py index 44f362ee0..2e5a11851 100644 --- a/fastplotlib/graphics/mesh.py +++ b/fastplotlib/graphics/mesh.py @@ -6,7 +6,7 @@ from ._positions_base import Graphic from .features import ( - MeshVertexPositions, + VertexPositions, MeshIndices, MeshCmap, SurfaceData, @@ -22,7 +22,7 @@ class MeshGraphic(Graphic): _features = { - "positions": MeshVertexPositions, + "positions": VertexPositions, "indices": MeshIndices, "colors": (VertexColors, UniformColor), "cmap": MeshCmap, @@ -90,10 +90,10 @@ def __init__( super().__init__(**kwargs) - if isinstance(positions, MeshVertexPositions): + if isinstance(positions, VertexPositions): self._positions = positions else: - self._positions = MeshVertexPositions( + self._positions = VertexPositions( positions, isolated_buffer=isolated_buffer, property_name="positions" ) @@ -194,7 +194,7 @@ def mode(self) -> Literal["basic", "phong", "slice"]: return self._mode @property - def positions(self) -> MeshVertexPositions: + def positions(self) -> VertexPositions: """Get or set the vertex positions""" return self._positions From c60fe315624890d6c1dbfe805f1a4bd0017c00c2 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 3 Dec 2025 23:25:16 -0500 Subject: [PATCH 19/21] update PolyData --- fastplotlib/graphics/features/_mesh.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/fastplotlib/graphics/features/_mesh.py b/fastplotlib/graphics/features/_mesh.py index debd31677..1fd7b422c 100644 --- a/fastplotlib/graphics/features/_mesh.py +++ b/fastplotlib/graphics/features/_mesh.py @@ -252,18 +252,18 @@ def set_value(self, graphic, value: np.ndarray | Sequence): geometry = graphic.world_object.geometry - # Need larger buffer? - if len(positions) > geometry.positions.nitems: - arr = np.zeros((geometry.positions.nitems * 2, 3), np.float32) + # Need larger (or smaller) buffer? Scale up/down with factors of 2. + need_position_size = 2 ** int(np.ceil(np.log2(max(8, len(positions))))) + if need_position_size != geometry.positions.nitems: + arr = np.zeros((need_position_size, 3), np.float32) geometry.positions = pygfx.Buffer(arr) - if len(indices) > geometry.indices.nitems: - arr = np.zeros((geometry.indices.nitems * 2, 3), np.int32) + need_indices_size = 2 ** int(np.ceil(np.log2(max(8, len(indices))))) + if need_indices_size != geometry.indices.nitems: + arr = np.zeros((need_indices_size, 3), np.int32) 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 9d3a007e457bd4f2c6da78fd2fe224df021d87b4 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 3 Dec 2025 23:32:21 -0500 Subject: [PATCH 20/21] I need to use precommit --- fastplotlib/graphics/features/_mesh.py | 4 +++- fastplotlib/layouts/_plot_area.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/fastplotlib/graphics/features/_mesh.py b/fastplotlib/graphics/features/_mesh.py index 1fd7b422c..7355acb4e 100644 --- a/fastplotlib/graphics/features/_mesh.py +++ b/fastplotlib/graphics/features/_mesh.py @@ -263,7 +263,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() diff --git a/fastplotlib/layouts/_plot_area.py b/fastplotlib/layouts/_plot_area.py index d94616c14..01721780c 100644 --- a/fastplotlib/layouts/_plot_area.py +++ b/fastplotlib/layouts/_plot_area.py @@ -286,7 +286,7 @@ def background_color(self, colors: str | tuple[float]): def ambient_light(self) -> pygfx.AmbientLight: """the ambient lighting in the scene""" return self._ambient_light - + @property def directional_light(self) -> pygfx.DirectionalLight: """the directional lighting on the camera in the scene""" From 868ee959db119cbedd5f4e18605321debc2a00b5 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 3 Dec 2025 23:33:32 -0500 Subject: [PATCH 21/21] update api docs --- .../graphic_features/MeshVertexPositions.rst | 36 ------------------- docs/source/api/graphic_features/index.rst | 1 - docs/source/api/layouts/subplot.rst | 2 ++ 3 files changed, 2 insertions(+), 37 deletions(-) delete mode 100644 docs/source/api/graphic_features/MeshVertexPositions.rst diff --git a/docs/source/api/graphic_features/MeshVertexPositions.rst b/docs/source/api/graphic_features/MeshVertexPositions.rst deleted file mode 100644 index d26fc461b..000000000 --- a/docs/source/api/graphic_features/MeshVertexPositions.rst +++ /dev/null @@ -1,36 +0,0 @@ -.. _api.MeshVertexPositions: - -MeshVertexPositions -******************* - -=================== -MeshVertexPositions -=================== -.. currentmodule:: fastplotlib.graphics.features - -Constructor -~~~~~~~~~~~ -.. autosummary:: - :toctree: MeshVertexPositions_api - - MeshVertexPositions - -Properties -~~~~~~~~~~ -.. autosummary:: - :toctree: MeshVertexPositions_api - - MeshVertexPositions.buffer - MeshVertexPositions.value - -Methods -~~~~~~~ -.. autosummary:: - :toctree: MeshVertexPositions_api - - MeshVertexPositions.add_event_handler - MeshVertexPositions.block_events - MeshVertexPositions.clear_event_handlers - MeshVertexPositions.remove_event_handler - MeshVertexPositions.set_value - diff --git a/docs/source/api/graphic_features/index.rst b/docs/source/api/graphic_features/index.rst index bd6f8615a..cd11544be 100644 --- a/docs/source/api/graphic_features/index.rst +++ b/docs/source/api/graphic_features/index.rst @@ -9,7 +9,6 @@ Graphic Features SizeSpace VertexPositions VertexCmap - MeshVertexPositions MeshIndices MeshCmap SurfaceData diff --git a/docs/source/api/layouts/subplot.rst b/docs/source/api/layouts/subplot.rst index a7455cf5f..93db00a2e 100644 --- a/docs/source/api/layouts/subplot.rst +++ b/docs/source/api/layouts/subplot.rst @@ -20,12 +20,14 @@ Properties .. autosummary:: :toctree: Subplot_api + Subplot.ambient_light Subplot.animations Subplot.axes Subplot.background_color Subplot.camera Subplot.canvas Subplot.controller + Subplot.directional_light Subplot.docks Subplot.frame Subplot.graphics