diff --git a/examples/selection_tools/unit_circle.py b/examples/selection_tools/unit_circle.py new file mode 100644 index 000000000..76f6a207c --- /dev/null +++ b/examples/selection_tools/unit_circle.py @@ -0,0 +1,114 @@ +""" +Unit circle +=========== + +Example with linear selectors on a sine and cosine function that demonstrates the unit circle. + +This shows how fastplotlib supports bidirectional events, drag the linear selector on the sine +or cosine function and they will both move together. + +Click on the sine or cosine function to set the colormap transform to illustrate the sine or +cosine function output values on the unit circle. +""" + +# test_example = false +# sphinx_gallery_pygfx_docs = 'screenshot' + + +import numpy as np +import fastplotlib as fpl + + +# helper function to make a cirlce +def make_circle(center, radius: float, n_points: int) -> np.ndarray: + theta = np.linspace(0, 2 * np.pi, n_points) + xs = radius * np.cos(theta) + ys = radius * np.sin(theta) + + return np.column_stack([xs, ys]) + center + + +# create a figure with 3 subplots +figure = fpl.Figure((3, 1), names=["unit circle", "sin(x)", "cos(x)"], size=(700, 1024)) + +# set the axes to intersect at (0, 0, 0) to better illustrate the unit circle +for subplot in figure: + subplot.axes.intersection = (0, 0, 0) + +figure["sin(x)"].camera.maintain_aspect = False +figure["cos(x)"].camera.maintain_aspect = False + +# create sine and cosine data +xs = np.linspace(0, 2 * np.pi, 360) +sine = np.sin(xs) +cosine = np.cos(xs) + +# circle data +circle_data = make_circle(center=(0, 0), radius=1, n_points=360) + +# make the circle line graphic, set the cmap transform using the sine function +circle_graphic = figure["unit circle"].add_line( + circle_data, thickness=4, cmap="bwr", cmap_transform=sine +) + +# line to show the circle radius +# use it to indicate the current position of the sine and cosine selctors (below) +radius_data = np.array([[0, 0, 0], [*circle_data[0], 0]]) +circle_radius = figure["unit circle"].add_line( + radius_data, thickness=6, colors="magenta" +) + +# sine line graphic, cmap transform set from the sine function +sine_graphic = figure["sin(x)"].add_line( + sine, thickness=10, cmap="bwr", cmap_transform=sine +) + +# cosine line graphic, cmap transform set from the sine function +# illustrates the sine function values on the cosine graphic +cosine_graphic = figure["cos(x)"].add_line( + cosine, thickness=10, cmap="bwr", cmap_transform=sine +) + +# add linear selectors to the sine and cosine line graphics +sine_selector = sine_graphic.add_linear_selector() +cosine_selector = cosine_graphic.add_linear_selector() + +def set_circle_cmap(ev): + # sets the cmap transforms + + cmap_transform = ev.graphic.data[:, 1] # y-val data of the sine or cosine graphic + for g in [sine_graphic, cosine_graphic]: + g.cmap.transform = cmap_transform + + # set circle cmap transform + circle_graphic.cmap.transform = cmap_transform + +# when the sine or cosine graphic is clicked, the cmap_transform +# of the sine, cosine and circle line graphics are all set from +# the y-values of the clicked line +sine_graphic.add_event_handler(set_circle_cmap, "click") +cosine_graphic.add_event_handler(set_circle_cmap, "click") + + +def set_x_val(ev): + # used to sync the two selectors + value = ev.info["value"] + index = ev.get_selected_index() + + sine_selector.selection = value + cosine_selector.selection = value + + circle_radius.data[1, :-1] = circle_data[index] + +# add same event handler to both graphics +sine_selector.add_event_handler(set_x_val, "selection") +cosine_selector.add_event_handler(set_x_val, "selection") + +figure.show() + + +# NOTE: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively +# please see our docs for using fastplotlib interactively in ipython and jupyter +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/fastplotlib/graphics/_features/_base.py b/fastplotlib/graphics/_features/_base.py index 1612414a1..1088dc005 100644 --- a/fastplotlib/graphics/_features/_base.py +++ b/fastplotlib/graphics/_features/_base.py @@ -53,6 +53,9 @@ def __init__(self, **kwargs): self._event_handlers = list() self._block_events = False + # used by @block_reentrance decorator to block re-entrance into set_value functions + self._reentrant_block: bool = False + @property def value(self) -> Any: """Graphic Feature value, must be implemented in subclass""" @@ -316,3 +319,33 @@ def __len__(self): def __repr__(self): return f"{self.__class__.__name__} buffer data:\n" f"{self.value.__repr__()}" + + +def block_reentrance(set_value): + # decorator to block re-entrant set_value methods + # useful when creating complex, circular, bidirectional event graphs + def set_value_wrapper(self: GraphicFeature, graphic_or_key, value): + """ + wraps GraphicFeature.set_value + + self: GraphicFeature instance + + graphic_or_key: graphic, or key if a BufferManager + + value: the value passed to set_value() + """ + # set_value is already in the middle of an execution, block re-entrance + if self._reentrant_block: + return + try: + # block re-execution of set_value until it has *fully* finished executing + self._reentrant_block = True + set_value(self, graphic_or_key, value) + except Exception as exc: + # raise original exception + raise exc # set_value has raised. The line above and the lines 2+ steps below are probably more relevant! + finally: + # set_value has finished executing, now allow future executions + self._reentrant_block = False + + return set_value_wrapper diff --git a/fastplotlib/graphics/_features/_common.py b/fastplotlib/graphics/_features/_common.py index fe32a485f..e9c49a475 100644 --- a/fastplotlib/graphics/_features/_common.py +++ b/fastplotlib/graphics/_features/_common.py @@ -1,6 +1,6 @@ import numpy as np -from ._base import GraphicFeature, FeatureEvent +from ._base import GraphicFeature, FeatureEvent, block_reentrance class Name(GraphicFeature): @@ -14,6 +14,7 @@ def __init__(self, value: str): def value(self) -> str: return self._value + @block_reentrance def set_value(self, graphic, value: str): if not isinstance(value, str): raise TypeError("`Graphic` name must be of type ") @@ -44,6 +45,7 @@ def _validate(self, value): def value(self) -> np.ndarray: return self._value + @block_reentrance def set_value(self, graphic, value: np.ndarray | list | tuple): self._validate(value) @@ -74,6 +76,7 @@ def _validate(self, value): def value(self) -> np.ndarray: return self._value + @block_reentrance def set_value(self, graphic, value: np.ndarray | list | tuple): self._validate(value) @@ -96,6 +99,7 @@ def __init__(self, value: bool): def value(self) -> bool: return self._value + @block_reentrance def set_value(self, graphic, value: bool): graphic.world_object.visible = value self._value = value @@ -117,6 +121,7 @@ def __init__(self, value: bool): def value(self) -> bool: return self._value + @block_reentrance def set_value(self, graphic, value: bool): self._value = value event = FeatureEvent(type="deleted", info={"value": value}) diff --git a/fastplotlib/graphics/_features/_image.py b/fastplotlib/graphics/_features/_image.py index b67bf1cd4..c0e2b28d2 100644 --- a/fastplotlib/graphics/_features/_image.py +++ b/fastplotlib/graphics/_features/_image.py @@ -5,7 +5,7 @@ import numpy as np import pygfx -from ._base import GraphicFeature, FeatureEvent +from ._base import GraphicFeature, FeatureEvent, block_reentrance from ...utils import ( make_colors, @@ -135,6 +135,7 @@ def __next__(self) -> tuple[pygfx.Texture, tuple[int, int], tuple[slice, slice]] def __getitem__(self, item): return self.value[item] + @block_reentrance def __setitem__(self, key, value): self.value[key] = value @@ -159,6 +160,7 @@ def __init__(self, value: float): def value(self) -> float: return self._value + @block_reentrance def set_value(self, graphic, value: float): vmax = graphic._material.clim[1] graphic._material.clim = (value, vmax) @@ -179,6 +181,7 @@ def __init__(self, value: float): def value(self) -> float: return self._value + @block_reentrance def set_value(self, graphic, value: float): vmin = graphic._material.clim[0] graphic._material.clim = (vmin, value) @@ -200,6 +203,7 @@ def __init__(self, value: str): def value(self) -> str: return self._value + @block_reentrance def set_value(self, graphic, value: str): new_colors = make_colors(256, value) graphic._material.map.texture.data[:] = new_colors @@ -226,6 +230,7 @@ def _validate(self, value): def value(self) -> str: return self._value + @block_reentrance def set_value(self, graphic, value: str): self._validate(value) @@ -254,6 +259,7 @@ def _validate(self, value): def value(self) -> str: return self._value + @block_reentrance def set_value(self, graphic, value: str): self._validate(value) diff --git a/fastplotlib/graphics/_features/_positions_graphics.py b/fastplotlib/graphics/_features/_positions_graphics.py index c4e153a31..78e53f545 100644 --- a/fastplotlib/graphics/_features/_positions_graphics.py +++ b/fastplotlib/graphics/_features/_positions_graphics.py @@ -1,4 +1,4 @@ -from typing import Any, List +from typing import Any import numpy as np import pygfx @@ -11,6 +11,7 @@ BufferManager, FeatureEvent, to_gpu_supported_dtype, + block_reentrance, ) from .utils import parse_colors @@ -58,6 +59,7 @@ def __init__( super().__init__(data=data, isolated_buffer=isolated_buffer) + @block_reentrance def __setitem__( self, key: int | slice | np.ndarray[int | bool] | tuple[slice, ...], @@ -155,6 +157,7 @@ def __init__( def value(self) -> pygfx.Color: return self._value + @block_reentrance def set_value(self, graphic, value: str | np.ndarray | tuple | list | pygfx.Color): value = pygfx.Color(value) graphic.world_object.material.color = value @@ -174,6 +177,7 @@ def __init__(self, value: int | float): def value(self) -> float: return self._value + @block_reentrance def set_value(self, graphic, value: float | int): graphic.world_object.material.size = float(value) self._value = value @@ -192,6 +196,7 @@ def __init__(self, value: str): def value(self) -> str: return self._value + @block_reentrance def set_value(self, graphic, value: str): if "Line" in graphic.world_object.material.__class__.__name__: graphic.world_object.material.thickness_space = value @@ -243,6 +248,7 @@ def _fix_data(self, data): return to_gpu_supported_dtype(data) + @block_reentrance def __setitem__( self, key: int | slice | np.ndarray[int | bool] | tuple[slice, ...], @@ -318,6 +324,7 @@ def _fix_sizes( return sizes + @block_reentrance def __setitem__( self, key: int | slice | np.ndarray[int | bool] | list[int | bool], @@ -344,6 +351,7 @@ def __init__(self, value: float): def value(self) -> float: return self._value + @block_reentrance def set_value(self, graphic, value: float): graphic.world_object.material.thickness = value self._value = value @@ -392,6 +400,7 @@ def __init__( # set vertex colors from cmap self._vertex_colors[:] = colors + @block_reentrance def __setitem__(self, key: slice, cmap_name): if not isinstance(key, slice): raise TypeError( diff --git a/fastplotlib/graphics/_features/_selection_features.py b/fastplotlib/graphics/_features/_selection_features.py index c385f820f..c157023b4 100644 --- a/fastplotlib/graphics/_features/_selection_features.py +++ b/fastplotlib/graphics/_features/_selection_features.py @@ -1,9 +1,9 @@ -from typing import Sequence, Tuple +from typing import Sequence import numpy as np from ...utils import mesh_masks -from ._base import GraphicFeature, FeatureEvent +from ._base import GraphicFeature, FeatureEvent, block_reentrance class LinearSelectionFeature(GraphicFeature): @@ -54,6 +54,7 @@ def value(self) -> np.float32: """ return self._value + @block_reentrance def set_value(self, selector, value: float): # clip value between limits value = np.clip(value, self._limits[0], self._limits[1], dtype=np.float32) @@ -117,6 +118,7 @@ def axis(self) -> str: """one of "x" | "y" """ return self._axis + @block_reentrance def set_value(self, selector, value: Sequence[float]): """ Set start, stop range of selector @@ -231,6 +233,7 @@ def value(self) -> np.ndarray[float]: """ return self._value + @block_reentrance def set_value(self, selector, value: Sequence[float]): """ Set the selection of the rectangle selector. diff --git a/fastplotlib/graphics/_features/_text.py b/fastplotlib/graphics/_features/_text.py index baa2734d5..90af7c719 100644 --- a/fastplotlib/graphics/_features/_text.py +++ b/fastplotlib/graphics/_features/_text.py @@ -2,7 +2,7 @@ import pygfx -from ._base import GraphicFeature, FeatureEvent +from ._base import GraphicFeature, FeatureEvent, block_reentrance class TextData(GraphicFeature): @@ -14,6 +14,7 @@ def __init__(self, value: str): def value(self) -> str: return self._value + @block_reentrance def set_value(self, graphic, value: str): graphic.world_object.geometry.set_text(value) self._value = value @@ -31,6 +32,7 @@ def __init__(self, value: float | int): def value(self) -> float | int: return self._value + @block_reentrance def set_value(self, graphic, value: float | int): graphic.world_object.geometry.font_size = value self._value = graphic.world_object.geometry.font_size @@ -48,6 +50,7 @@ def __init__(self, value: str | np.ndarray | list[float] | tuple[float]): def value(self) -> pygfx.Color: return self._value + @block_reentrance def set_value(self, graphic, value: str | np.ndarray | list[float] | tuple[float]): value = pygfx.Color(value) graphic.world_object.material.color = value @@ -66,6 +69,7 @@ def __init__(self, value: str | np.ndarray | list[float] | tuple[float]): def value(self) -> pygfx.Color: return self._value + @block_reentrance def set_value(self, graphic, value: str | np.ndarray | list[float] | tuple[float]): value = pygfx.Color(value) graphic.world_object.material.outline_color = value @@ -84,6 +88,7 @@ def __init__(self, value: float): def value(self) -> float: return self._value + @block_reentrance def set_value(self, graphic, value: float): graphic.world_object.material.outline_thickness = value self._value = graphic.world_object.material.outline_thickness