From 97442f5aa1826c434c71bbf444ef6cebe162fd27 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 19 Nov 2025 07:28:50 -0500 Subject: [PATCH 01/34] new cursor tool, basics work --- examples/misc/cursors.py | 33 ++++ fastplotlib/layouts/_figure.py | 6 +- fastplotlib/tools/__init__.py | 5 +- fastplotlib/tools/_cursor.py | 351 +++++++++++++++++++++++++++++++++ fastplotlib/tools/_tooltip.py | 55 ++++-- 5 files changed, 429 insertions(+), 21 deletions(-) create mode 100644 examples/misc/cursors.py create mode 100644 fastplotlib/tools/_cursor.py diff --git a/examples/misc/cursors.py b/examples/misc/cursors.py new file mode 100644 index 000000000..72663efc6 --- /dev/null +++ b/examples/misc/cursors.py @@ -0,0 +1,33 @@ +""" +Cursor tool +=========== + +Example with multiple subplots and an interactive cursor +that marks the same position in each subplot +""" + +import numpy as np +import fastplotlib as fpl +import imageio.v3 as iio + +img1 = iio.imread("imageio:camera.png") +img2 = iio.imread("imageio:astronaut.png") + +scatter_data = np.random.normal(loc=256, scale=(50), size=(500)).reshape(250, 2) + + +figure = fpl.Figure(shape=(2, 2)) + +figure[0, 0].add_image(img1) +figure[0, 1].add_image(img2) +figure[1, 0].add_scatter(scatter_data, sizes=5) + +cursor = fpl.Cursor(mode="crosshair", color="cyan") + +for subplot in figure: + cursor.add_subplot(subplot) + +figure.show_tooltips = True + +figure.show() +fpl.loop.run() diff --git a/fastplotlib/layouts/_figure.py b/fastplotlib/layouts/_figure.py index 8fd5dc666..842c55b76 100644 --- a/fastplotlib/layouts/_figure.py +++ b/fastplotlib/layouts/_figure.py @@ -21,7 +21,7 @@ from ._subplot import Subplot from ._engine import GridLayout, WindowLayout, ScreenSpaceCamera from .. import ImageGraphic -from ..tools import Tooltip +from ..tools import GraphicTooltip class Figure: @@ -461,7 +461,7 @@ def __init__( self._overlay_scene = pygfx.Scene() # tooltip in overlay render pass - self._tooltip_manager = Tooltip() + self._tooltip_manager = GraphicTooltip() self._overlay_scene.add(self._tooltip_manager.world_object) self._show_tooltips = show_tooltips @@ -534,7 +534,7 @@ def names(self) -> np.ndarray[str]: return names @property - def tooltip_manager(self) -> Tooltip: + def tooltip_manager(self) -> GraphicTooltip: """manage tooltips""" return self._tooltip_manager diff --git a/fastplotlib/tools/__init__.py b/fastplotlib/tools/__init__.py index df129a369..15799253f 100644 --- a/fastplotlib/tools/__init__.py +++ b/fastplotlib/tools/__init__.py @@ -1,7 +1,10 @@ from ._histogram_lut import HistogramLUTTool -from ._tooltip import Tooltip +from ._tooltip import Tooltip, GraphicTooltip +from ._cursor import Cursor __all__ = [ "HistogramLUTTool", "Tooltip", + "GraphicTooltip", + "Cursor", ] diff --git a/fastplotlib/tools/_cursor.py b/fastplotlib/tools/_cursor.py new file mode 100644 index 000000000..5224ebf3a --- /dev/null +++ b/fastplotlib/tools/_cursor.py @@ -0,0 +1,351 @@ +from functools import partial +from typing import Literal, Sequence + +import numpy as np +import pygfx + +from ..layouts import Subplot +from ..utils import RenderQueue + + +class Cursor: + def __init__( + self, + mode: Literal["crosshair", "marker"], + size: float = 1.0, # in screen space + color: str | Sequence[float] | pygfx.Color | np.ndarray = "r", + marker: str = "+", + edge_color: str | Sequence[float] | pygfx.Color | np.ndarray = "k", + edge_width: float = 0.5, + alpha: float = 0.7, + size_space: Literal["screen", "world"] = "screen" + ): + """ + A cursor that indicates the same position in world-space across subplots. + + Parameters + ---------- + mode: "crosshair" | "marker" + cursor mode + + size: float, default 1.0 + * if ``mode`` == 'crosshair', this is the crosshair line thickness + * if ``mode`` == 'marker', it's the size of the marker + + You probably want to use ``size > 5`` if ``mode`` is 'marker' and ``size_space`` is ``screen`` + + color: str | Sequence[float] | pygfx.Color | np.ndarray, default "r" + color of the marker + + marker: str, default "+" + marker shape, used if mode == 'marker' + + edge_color: str | Sequence[float] | pygfx.Color | np.ndarray, default "k" + marker edge color, used if mode == 'marker' + + edge_width: float, default 0.5 + marker edge widget, used if mode == 'marker' + + alpha: float, default 0.7 + alpha (transparency) of the cursor + + size_space: "screen" | "world", default "screen" + size space of the cursor, if "screen" the ``size`` is exact screen pixels. + if "world" the ``size`` is in world-space + + """ + + self._cursors: dict[Subplot, [pygfx.Points | pygfx.Group[pygfx.Line]]] = dict() + + self._mode = None + self.mode = mode + self.size = size + self.color = color + self.marker = marker + self.edge_color = edge_color + self.edge_width = edge_width + self.alpha = alpha + self.size_space = size_space + + self._pause = False + + self._position = [0, 0] + + @property + def mode(self) -> Literal["crosshair", "marker"]: + return self._mode + + @mode.setter + def mode(self, mode: Literal["crosshair", "marker"]): + if not (mode == "crosshair" or mode == "marker"): + raise ValueError(f"mode must be one of: 'crosshair' | 'marker', you passed: {mode}") + + if mode == self.mode: + return + + # mode has changed, clear and create new world objects + subplots = list(self._cursors.keys()) + + self.clear() + + for subplot in subplots: + self.add_subplot(subplot) + + self._mode = mode + + @property + def size(self) -> float: + return self._size + + @size.setter + def size(self, new_size: float): + for c in self._cursors.values(): + if self.mode == "marker": + c.material.size = new_size + elif self.mode == "crosshair": + h, v = c.children + h.material.thickness = new_size + v.material.thickness = new_size + + self._size = new_size + + @property + def size_space(self) -> Literal["screen", "world"]: + return self._size_space + + @size_space.setter + def size_space(self, space: Literal["screen", "world"]): + if space not in ["screen", "world", "model"]: + raise ValueError( + f"valid `size_space` is one of: 'screen' | 'world'. You passed: {space}" + ) + + for c in self._cursors.values(): + if self.mode == "marker": + c.material.size_space = space + + elif self.mode == "crosshair": + h, v = c.children + h.material.thickness_space = space + v.material.thickness_space = space + + self._size_space = space + + @property + def color(self) -> pygfx.Color: + return self._color + + @color.setter + def color(self, new_color): + new_color = pygfx.Color(new_color) + + for c in self._cursors.values(): + c.material.color = new_color + + self._color = new_color + + @property + def marker(self) -> str: + return self._marker + + @marker.setter + def marker(self, new_marker: str): + if self.mode == "marker": + for c in self._cursors.values(): + c.material.marker = new_marker + + self._marker = new_marker + + @property + def edge_color(self) -> pygfx.Color: + return self._edge_color + + @edge_color.setter + def edge_color(self, new_color: str | Sequence | np.ndarray | pygfx.Color): + new_color = pygfx.Color(new_color) + + if self.mode == "marker": + for c in self._cursors.values(): + c.material.edge_color = new_color + + self._edge_color = new_color + + @property + def edge_width(self) -> float: + return self._edge_width + + @edge_width.setter + def edge_width(self, new_width: float): + if self.mode == "marker": + for c in self._cursors.values(): + c.material.edge_width = new_width + + self._edge_width = new_width + + @property + def alpha(self) -> float: + return self._alpha + + @alpha.setter + def alpha(self, value: float): + for c in self._cursors.values(): + c.material.opacity = value + + self._alpha = value + + @property + def pause(self) -> bool: + return self._pause + + @pause.setter + def pause(self, pause: bool): + self._pause = bool(pause) + + @property + def position(self) -> tuple[float, float]: + """x, y position in world space""" + return tuple(self._position) + + @position.setter + def position(self, pos: tuple[float, float]): + for cursor in self._cursors.values(): + + if self.mode == "marker": + cursor.geometry.positions.data[0, :-1] = pos + cursor.geometry.positions.update_full() + + elif self.mode == "crosshair": + line_h, line_v = cursor.children + + # set x vals for horizontal line + line_h.geometry.positions.data[0, 0] = pos[0] - 1 + line_h.geometry.positions.data[1, 0] = pos[0] + 1 + + # set y value + line_h.geometry.positions.data[:, 1] = pos[1] + + line_h.geometry.positions.update_full() + + # set y vals for vertical line + line_v.geometry.positions.data[0, 1] = pos[1] - 1 + line_v.geometry.positions.data[1, 1] = pos[1] + 1 + + # set x value + line_v.geometry.positions.data[:, 0] = pos[0] + + line_v.geometry.positions.update_full() + + self._position[:] = pos + + def add_subplot(self, subplot: Subplot): + """add this cursor to a subplot""" + if subplot in self._cursors.keys(): + raise KeyError + + if self.mode == "marker": + cursor = self._create_marker() + + elif self.mode == "crosshair": + cursor = self._create_crosshair() + + subplot.scene.add(cursor) + subplot.renderer.add_event_handler(partial(self._pointer_moved, subplot), "pointer_move") + + self._cursors[subplot] = cursor + + def remove_subplot(self, subplot: Subplot): + """remove cursor from subplot""" + if subplot not in self._cursors.keys(): + raise KeyError("cursor not in given supblot") + + subplot.scene.remove(self._cursors.pop(subplot)) + + def clear(self): + """remove from all subplots""" + for subplot in self._cursors.keys(): + self.remove_subplot(subplot) + + def _create_marker(self) -> pygfx.Points: + point = pygfx.Points( + pygfx.Geometry(positions=np.array([[*self.position, 0]], dtype=np.float32)), + pygfx.PointsMarkerMaterial( + marker=self.marker, + size=self.size, + size_space=self.size_space, + color=self.color, + edge_color=self.edge_color, + edge_width=self.edge_width, + opacity=self.alpha, + alpha_mode="blend", + render_queue=RenderQueue.selector, + depth_test=False, + depth_write=False, + pick_write=False, + ) + ) + + return point + + def _create_crosshair(self) -> pygfx.Group: + x, y = self.position + line_h_data = np.array( + [ + [x - 1, y, 0], + [x + 1, y, 0], + ], dtype=np.float32 + ) + + line_v_data = np.array( + [ + [x, y - 1, 0], + [x, y + 1, 0], + ], dtype=np.float32 + ) + + line_h = pygfx.Line( + geometry=pygfx.Geometry(positions=line_h_data), + material=pygfx.LineInfiniteSegmentMaterial( + thickness=self.size, + thickness_space=self.size_space, + color=self.color, + opacity=self.alpha, + alpha_mode="blend", + aa=True, + render_queue=RenderQueue.selector, + depth_test=False, + depth_write=False, + pick_write=False, + ), + ) + + line_v = pygfx.Line( + geometry=pygfx.Geometry(positions=line_v_data), + material=pygfx.LineInfiniteSegmentMaterial( + thickness=self.size, + thickness_space=self.size_space, + color=self.color, + opacity=self.alpha, + alpha_mode="blend", + aa=True, + render_queue=RenderQueue.selector, + depth_test=False, + depth_write=False, + pick_write=False, + ), + ) + + lines = pygfx.Group() + lines.add(line_h, line_v) + + return lines + + def _pointer_moved(self, subplot, ev: pygfx.PointerEvent): + if self.pause: + return + + pos = subplot.map_screen_to_world(ev) + + if pos is None: + return + + self.position = pos[:-1] diff --git a/fastplotlib/tools/_tooltip.py b/fastplotlib/tools/_tooltip.py index f6c9cf531..aacecfb43 100644 --- a/fastplotlib/tools/_tooltip.py +++ b/fastplotlib/tools/_tooltip.py @@ -116,8 +116,6 @@ def __init__(self): # making the text easier to read self._padding = np.array([[5, 5, 0], [-5, -5, 0]], dtype=np.float32) - self._registered_graphics = dict() - @property def world_object(self) -> pygfx.Group: return self._world_object @@ -172,6 +170,23 @@ def padding(self, padding_xy: tuple[float, float]): self._padding[0, :2] = padding_xy self._padding[1, :2] = -np.asarray(padding_xy) + def display(self, position: tuple[float, float], info: str): + """ + display tooltip at the given position in screen space + + Parameters + ---------- + position: (x, y) + position in screen space + + info: str + tooltip text to display + + """ + # set the text and top left position of the tooltip + self._text.set_text(info) + self._set_position(position) + def _set_position(self, pos: tuple[float, float]): """ Set the position of the tooltip @@ -207,6 +222,18 @@ def _set_position(self, pos: tuple[float, float]): self._line.geometry.positions.data[:, :2] = pts self._line.geometry.positions.update_range() + def clear(self, *args): + self._text.set_text("") + self.world_object.visible = False + + +class GraphicTooltip(Tooltip): + """A tooltip that auto displays info for registered graphics""" + def __init__(self): + super().__init__() + + self._registered_graphics = dict() + def _event_handler(self, custom_tooltip: callable, ev: pygfx.PointerEvent): """Handles the tooltip appear event, determines the text to be set in the tooltip""" if custom_tooltip is not None: @@ -228,25 +255,19 @@ def _event_handler(self, custom_tooltip: callable, ev: pygfx.PointerEvent): f"{dim}: {val}" for dim, val in zip("xyz", ev.graphic.data[index]) ) else: - raise TypeError("Unsupported graphic") + return # make the tooltip object visible self.world_object.visible = True - # set the text and top left position of the tooltip - self._text.set_text(info) - self._set_position((ev.x, ev.y)) - - def _clear(self, ev): - self._text.set_text("") - self.world_object.visible = False + self.display((ev.x, ev.y), info) def register( - self, - graphic: Graphic, - appear_event: str = "pointer_move", - disappear_event: str = "pointer_leave", - custom_info: callable = None, + self, + graphic: Graphic, + appear_event: str = "pointer_move", + disappear_event: str = "pointer_leave", + custom_info: callable = None, ): """ Register a Graphic to display tooltips. @@ -276,7 +297,7 @@ def register( pfunc = partial(self._event_handler, custom_info) graphic.add_event_handler(pfunc, appear_event) - graphic.add_event_handler(self._clear, disappear_event) + graphic.add_event_handler(self.clear, disappear_event) self._registered_graphics[graphic] = (pfunc, appear_event, disappear_event) @@ -308,7 +329,7 @@ def unregister(self, graphic: Graphic): # remove handlers from graphic graphic.remove_event_handler(pfunc, appear_event) - graphic.remove_event_handler(self._clear, disappear_event) + graphic.remove_event_handler(self.clear, disappear_event) def unregister_all(self): """unregister all graphics""" From 034f719fb81d81c43ad6412ccfa3ce1881c13de7 Mon Sep 17 00:00:00 2001 From: clewis7 Date: Wed, 19 Nov 2025 10:53:15 -0500 Subject: [PATCH 02/34] lint and update api docs --- docs/source/api/tools/Cursor.rst | 42 ++++++++++++++++++++++++ docs/source/api/tools/GraphicTooltip.rst | 40 ++++++++++++++++++++++ docs/source/api/tools/Tooltip.rst | 5 ++- docs/source/api/tools/index.rst | 2 ++ fastplotlib/tools/_cursor.py | 34 +++++++++++-------- fastplotlib/tools/_tooltip.py | 11 ++++--- 6 files changed, 112 insertions(+), 22 deletions(-) create mode 100644 docs/source/api/tools/Cursor.rst create mode 100644 docs/source/api/tools/GraphicTooltip.rst diff --git a/docs/source/api/tools/Cursor.rst b/docs/source/api/tools/Cursor.rst new file mode 100644 index 000000000..c8650f3fe --- /dev/null +++ b/docs/source/api/tools/Cursor.rst @@ -0,0 +1,42 @@ +.. _api.Cursor: + +Cursor +****** + +====== +Cursor +====== +.. currentmodule:: fastplotlib + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: Cursor_api + + Cursor + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: Cursor_api + + Cursor.alpha + Cursor.color + Cursor.edge_color + Cursor.edge_width + Cursor.marker + Cursor.mode + Cursor.pause + Cursor.position + Cursor.size + Cursor.size_space + +Methods +~~~~~~~ +.. autosummary:: + :toctree: Cursor_api + + Cursor.add_subplot + Cursor.clear + Cursor.remove_subplot + diff --git a/docs/source/api/tools/GraphicTooltip.rst b/docs/source/api/tools/GraphicTooltip.rst new file mode 100644 index 000000000..948a1392d --- /dev/null +++ b/docs/source/api/tools/GraphicTooltip.rst @@ -0,0 +1,40 @@ +.. _api.GraphicTooltip: + +GraphicTooltip +************** + +============== +GraphicTooltip +============== +.. currentmodule:: fastplotlib + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: GraphicTooltip_api + + GraphicTooltip + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: GraphicTooltip_api + + GraphicTooltip.background_color + GraphicTooltip.font_size + GraphicTooltip.outline_color + GraphicTooltip.padding + GraphicTooltip.text_color + GraphicTooltip.world_object + +Methods +~~~~~~~ +.. autosummary:: + :toctree: GraphicTooltip_api + + GraphicTooltip.clear + GraphicTooltip.display + GraphicTooltip.register + GraphicTooltip.unregister + GraphicTooltip.unregister_all + diff --git a/docs/source/api/tools/Tooltip.rst b/docs/source/api/tools/Tooltip.rst index 71607bf20..c36d4ad5f 100644 --- a/docs/source/api/tools/Tooltip.rst +++ b/docs/source/api/tools/Tooltip.rst @@ -32,7 +32,6 @@ Methods .. autosummary:: :toctree: Tooltip_api - Tooltip.register - Tooltip.unregister - Tooltip.unregister_all + Tooltip.clear + Tooltip.display diff --git a/docs/source/api/tools/index.rst b/docs/source/api/tools/index.rst index c2666ed28..c81dba106 100644 --- a/docs/source/api/tools/index.rst +++ b/docs/source/api/tools/index.rst @@ -6,3 +6,5 @@ Tools HistogramLUTTool Tooltip + GraphicTooltip + Cursor diff --git a/fastplotlib/tools/_cursor.py b/fastplotlib/tools/_cursor.py index 5224ebf3a..877a90d71 100644 --- a/fastplotlib/tools/_cursor.py +++ b/fastplotlib/tools/_cursor.py @@ -10,15 +10,15 @@ class Cursor: def __init__( - self, - mode: Literal["crosshair", "marker"], - size: float = 1.0, # in screen space - color: str | Sequence[float] | pygfx.Color | np.ndarray = "r", - marker: str = "+", - edge_color: str | Sequence[float] | pygfx.Color | np.ndarray = "k", - edge_width: float = 0.5, - alpha: float = 0.7, - size_space: Literal["screen", "world"] = "screen" + self, + mode: Literal["crosshair", "marker"], + size: float = 1.0, # in screen space + color: str | Sequence[float] | pygfx.Color | np.ndarray = "r", + marker: str = "+", + edge_color: str | Sequence[float] | pygfx.Color | np.ndarray = "k", + edge_width: float = 0.5, + alpha: float = 0.7, + size_space: Literal["screen", "world"] = "screen", ): """ A cursor that indicates the same position in world-space across subplots. @@ -78,7 +78,9 @@ def mode(self) -> Literal["crosshair", "marker"]: @mode.setter def mode(self, mode: Literal["crosshair", "marker"]): if not (mode == "crosshair" or mode == "marker"): - raise ValueError(f"mode must be one of: 'crosshair' | 'marker', you passed: {mode}") + raise ValueError( + f"mode must be one of: 'crosshair' | 'marker', you passed: {mode}" + ) if mode == self.mode: return @@ -249,7 +251,9 @@ def add_subplot(self, subplot: Subplot): cursor = self._create_crosshair() subplot.scene.add(cursor) - subplot.renderer.add_event_handler(partial(self._pointer_moved, subplot), "pointer_move") + subplot.renderer.add_event_handler( + partial(self._pointer_moved, subplot), "pointer_move" + ) self._cursors[subplot] = cursor @@ -281,7 +285,7 @@ def _create_marker(self) -> pygfx.Points: depth_test=False, depth_write=False, pick_write=False, - ) + ), ) return point @@ -292,14 +296,16 @@ def _create_crosshair(self) -> pygfx.Group: [ [x - 1, y, 0], [x + 1, y, 0], - ], dtype=np.float32 + ], + dtype=np.float32, ) line_v_data = np.array( [ [x, y - 1, 0], [x, y + 1, 0], - ], dtype=np.float32 + ], + dtype=np.float32, ) line_h = pygfx.Line( diff --git a/fastplotlib/tools/_tooltip.py b/fastplotlib/tools/_tooltip.py index aacecfb43..3984571ef 100644 --- a/fastplotlib/tools/_tooltip.py +++ b/fastplotlib/tools/_tooltip.py @@ -229,6 +229,7 @@ def clear(self, *args): class GraphicTooltip(Tooltip): """A tooltip that auto displays info for registered graphics""" + def __init__(self): super().__init__() @@ -263,11 +264,11 @@ def _event_handler(self, custom_tooltip: callable, ev: pygfx.PointerEvent): self.display((ev.x, ev.y), info) def register( - self, - graphic: Graphic, - appear_event: str = "pointer_move", - disappear_event: str = "pointer_leave", - custom_info: callable = None, + self, + graphic: Graphic, + appear_event: str = "pointer_move", + disappear_event: str = "pointer_leave", + custom_info: callable = None, ): """ Register a Graphic to display tooltips. From 9a18381bbd70a97492ca50f27ea073fead3ab950 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 19 Nov 2025 22:54:51 -0500 Subject: [PATCH 03/34] add custom tooltip to figure --- fastplotlib/layouts/_figure.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/fastplotlib/layouts/_figure.py b/fastplotlib/layouts/_figure.py index 842c55b76..a3dce2c4d 100644 --- a/fastplotlib/layouts/_figure.py +++ b/fastplotlib/layouts/_figure.py @@ -21,7 +21,7 @@ from ._subplot import Subplot from ._engine import GridLayout, WindowLayout, ScreenSpaceCamera from .. import ImageGraphic -from ..tools import GraphicTooltip +from ..tools import Tooltip, GraphicTooltip class Figure: @@ -561,6 +561,15 @@ def show_tooltips(self, val: bool): elif not val: self._tooltip_manager.unregister_all() + def add_tooltip(self, tooltip: Tooltip): + if not isinstance(tooltip, Tooltip): + raise TypeError(f"tooltip must be a `Tooltip` instance, you passed: {tooltip}") + + self._overlay_scene.add(tooltip.world_object) + + def remove_tooltip(self, tooltip): + self._overlay_scene.remove(tooltip) + def _render(self, draw=True): # draw the underlay planes self.renderer.render(self._underlay_scene, self._underlay_camera, flush=False) From cf0c6dd023e183e4cb7ad937bc59bf52638c6232 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 19 Nov 2025 22:56:01 -0500 Subject: [PATCH 04/34] example WIP --- examples/misc/cursors.py | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/examples/misc/cursors.py b/examples/misc/cursors.py index 72663efc6..15a58325d 100644 --- a/examples/misc/cursors.py +++ b/examples/misc/cursors.py @@ -9,6 +9,7 @@ import numpy as np import fastplotlib as fpl import imageio.v3 as iio +from pylinalg import vec_transform img1 = iio.imread("imageio:camera.png") img2 = iio.imread("imageio:astronaut.png") @@ -16,7 +17,19 @@ scatter_data = np.random.normal(loc=256, scale=(50), size=(500)).reshape(250, 2) -figure = fpl.Figure(shape=(2, 2)) +def map_world_to_screen(subplot, pos): + ndc = vec_transform(pos, subplot.camera.camera_matrix) + + # ndc = (clip[0] / clip[3], clip[1] / clip[3], clip[2] / clip[3]) + + width, height = subplot.canvas.get_physical_size() + x_screen = (ndc[0] + 1) * 0.5 * width + y_screen = (1 - ndc[1]) * 0.5 * height + + return x_screen, y_screen + + +figure = fpl.Figure(shape=(2, 2), size=(500, 500)) figure[0, 0].add_image(img1) figure[0, 1].add_image(img2) @@ -29,5 +42,21 @@ figure.show_tooltips = True +tooltips2 = fpl.Tooltip() +tooltips2.world_object.visible = True +figure.add_tooltip(tooltips2) + +@figure.renderer.add_event_handler("pointer_move") +def update(ev): + pos = figure[0, 0].map_screen_to_world(ev) + if pos is None: + return + + x, y = map_world_to_screen(figure[0, 1], pos) + print(x, y) + + tooltips2.display((x, y), "bah") + + figure.show() fpl.loop.run() From 86807c2c5fc3126819eb1dd01526041bfaab78ff Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 20 Nov 2025 04:26:32 -0500 Subject: [PATCH 05/34] manual picking, world obj -> graphic mapping --- examples/misc/cursor_custom_tooltip.py | 61 +++++++++++++++++++++++ examples/misc/cursors.py | 66 ++++++++++--------------- fastplotlib/graphics/_base.py | 17 +++++++ fastplotlib/layouts/_plot_area.py | 67 +++++++++++++++++++++++++- fastplotlib/tools/_tooltip.py | 10 ++++ 5 files changed, 179 insertions(+), 42 deletions(-) create mode 100644 examples/misc/cursor_custom_tooltip.py diff --git a/examples/misc/cursor_custom_tooltip.py b/examples/misc/cursor_custom_tooltip.py new file mode 100644 index 000000000..c284d38b0 --- /dev/null +++ b/examples/misc/cursor_custom_tooltip.py @@ -0,0 +1,61 @@ +""" +Cursor tool with tooltips +========================= + +Cursor tool example that also displays tooltips +""" + +import numpy as np +import fastplotlib as fpl +import imageio.v3 as iio +from pylinalg import vec_transform, mat_combine + +img1 = iio.imread("imageio:camera.png") +img2 = iio.imread("imageio:astronaut.png") + +scatter_data = np.random.normal(loc=256, scale=(50), size=(500)).reshape(250, 2) +line_data = np.random.rand(100, 2) * 512 + +figure = fpl.Figure(shape=(2, 2), size=(500, 500)) + +img = figure[0, 0].add_image(img1, cmap="viridis") +figure[0, 1].add_image(img2, metadata="image metadata") +figure[1, 0].add_scatter(scatter_data, sizes=5, metadata="scatter metadata") +figure[1, 1].add_line(line_data, metadata="line metadata") + +cursor = fpl.Cursor(mode="crosshair", color="w") + +for subplot in figure: + cursor.add_subplot(subplot) + +figure.show_tooltips = True + +tooltips2 = fpl.Tooltip() +tooltips2.world_object.visible = True +figure.add_tooltip(tooltips2) + +@figure.renderer.add_event_handler("pointer_move") +def update(ev): + pos = figure[0, 0].map_screen_to_world(ev) + if pos is None: + return + + x, y = figure[0, 1].map_world_to_screen(pos) + pick = subplot.get_pick_info((x, y)) + + if pick is None: + tooltips2.visible = False + return + + info = pick["graphic"].metadata + tooltips2.display((x, y), str(info)) + +print((img.world_object.children[0].uniform_buffer.data["global_id"]).item()) +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/misc/cursors.py b/examples/misc/cursors.py index 15a58325d..94cfd367a 100644 --- a/examples/misc/cursors.py +++ b/examples/misc/cursors.py @@ -2,61 +2,47 @@ Cursor tool =========== -Example with multiple subplots and an interactive cursor -that marks the same position in each subplot +Example with multiple subplots and an interactive cursor that marks the same position in each subplot. """ +# test_example = False +# sphinx_gallery_pygfx_docs = 'screenshot' + import numpy as np import fastplotlib as fpl import imageio.v3 as iio -from pylinalg import vec_transform +from pylinalg import vec_transform, mat_combine -img1 = iio.imread("imageio:camera.png") -img2 = iio.imread("imageio:astronaut.png") +# get some data +img1 = iio.imread("imageio:camera.png") +img2 = iio.imread("imageio:wikkie.png") scatter_data = np.random.normal(loc=256, scale=(50), size=(500)).reshape(250, 2) +line_data = np.random.rand(100, 2) * 512 +# create a figure +figure = fpl.Figure(shape=(2, 2), size=(700, 750)) -def map_world_to_screen(subplot, pos): - ndc = vec_transform(pos, subplot.camera.camera_matrix) - - # ndc = (clip[0] / clip[3], clip[1] / clip[3], clip[2] / clip[3]) - - width, height = subplot.canvas.get_physical_size() - x_screen = (ndc[0] + 1) * 0.5 * width - y_screen = (1 - ndc[1]) * 0.5 * height - - return x_screen, y_screen - - -figure = fpl.Figure(shape=(2, 2), size=(500, 500)) - -figure[0, 0].add_image(img1) +# plot data +figure[0, 0].add_image(img1, cmap="viridis") figure[0, 1].add_image(img2) -figure[1, 0].add_scatter(scatter_data, sizes=5) +figure[1, 0].add_scatter(scatter_data, sizes=5, colors="r") +figure[1, 1].add_line(line_data, colors="r") -cursor = fpl.Cursor(mode="crosshair", color="cyan") +# creator a cursor in crosshair mode +cursor = fpl.Cursor(mode="crosshair", color="w") +# add all subplots to the cursor for subplot in figure: cursor.add_subplot(subplot) -figure.show_tooltips = True - -tooltips2 = fpl.Tooltip() -tooltips2.world_object.visible = True -figure.add_tooltip(tooltips2) - -@figure.renderer.add_event_handler("pointer_move") -def update(ev): - pos = figure[0, 0].map_screen_to_world(ev) - if pos is None: - return - - x, y = map_world_to_screen(figure[0, 1], pos) - print(x, y) - - tooltips2.display((x, y), "bah") - +# you can also set the cursor position programmatically +cursor.position = (256, 256) figure.show() -fpl.loop.run() + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/fastplotlib/graphics/_base.py b/fastplotlib/graphics/_base.py index a4f3e9a67..089bc6078 100644 --- a/fastplotlib/graphics/_base.py +++ b/fastplotlib/graphics/_base.py @@ -29,11 +29,16 @@ from ._axes import Axes HexStr: TypeAlias = str +WorldObjectID: TypeAlias = int # dict that holds all world objects for a given python kernel/session # Graphic objects only use proxies to WorldObjects WORLD_OBJECTS: dict[HexStr, pygfx.WorldObject] = dict() #: {hex id str: WorldObject} +# maps world object to the graphic which owns it, useful when manually picking from the renderer and we +# need to know the graphic associated with the target world object +WORLD_OBJECT_TO_GRAPHIC: dict[WorldObjectID, "Graphic"] = dict() + PYGFX_EVENTS = [ "key_down", @@ -251,6 +256,18 @@ def world_object(self) -> pygfx.WorldObject: def _set_world_object(self, wo: pygfx.WorldObject): WORLD_OBJECTS[self._fpl_address] = wo + # add to world object -> graphic mapping + if isinstance(wo, pygfx.Group): + for child in wo.children: + if isinstance(child, (pygfx.Image, pygfx.Volume, pygfx.Points, pygfx.Line)): + # need to call int() on it since it's a numpy array with 1 element + # and numpy arrays aren't hashable + global_id = int(child.uniform_buffer.data["global_id"]) + WORLD_OBJECT_TO_GRAPHIC[global_id] = self + else: + global_id = wo.uniform_buffer.data["global_id"] + WORLD_OBJECT_TO_GRAPHIC[global_id] = self + wo.visible = self.visible if "Image" in self.__class__.__name__: # Image and ImageVolume use tiling and share one material diff --git a/fastplotlib/layouts/_plot_area.py b/fastplotlib/layouts/_plot_area.py index 606f83909..872d0ca46 100644 --- a/fastplotlib/layouts/_plot_area.py +++ b/fastplotlib/layouts/_plot_area.py @@ -9,7 +9,7 @@ from rendercanvas import BaseRenderCanvas from ._utils import create_controller -from ..graphics._base import Graphic +from ..graphics._base import Graphic, WORLD_OBJECT_TO_GRAPHIC from ..graphics import ImageGraphic from ..graphics.selectors._base_selector import BaseSelector from ._graphic_methods_mixin import GraphicMethodsMixin @@ -283,13 +283,18 @@ def map_screen_to_world( self, pos: tuple[float, float] | pygfx.PointerEvent, allow_outside: bool = False ) -> np.ndarray | None: """ - Map screen position to world position + Map screen (canvas) position to world position Parameters ---------- pos: (float, float) | pygfx.PointerEvent ``(x, y)`` screen coordinates, or ``pygfx.PointerEvent`` + Returns + ------- + (float, float, float) + (x, y, z) position in world space, z is always 0 + """ if isinstance(pos, pygfx.PointerEvent): pos = pos.x, pos.y @@ -315,6 +320,64 @@ def map_screen_to_world( # default z is zero for now return np.array([*pos_world[:2], 0]) + def map_world_to_screen(self, pos: tuple[float, float, float]): + """ + Map world position to screen (canvas) posiition + + Parameters + ---------- + pos: (x, y, z) + world space position + + Returns + ------- + (float, float) + (x, y) position in screen (canvas) space + + """ + + if not len(pos) == 3: + raise ValueError(f"must pass 3d (x, y, z) position, you passed: {pos}") + + # apply camera transform and get NDC position + ndc = vec_transform(np.asarray(pos), self.camera.camera_matrix) + + # get viewport rect + x_offset, y_offset, w, h = self.viewport.rect + + # ndc to screen position + x_screen = x_offset + (ndc[0] + 1) * 0.5 * w + y_screen = y_offset + (1 - ndc[1]) * 0.5 * h + + return x_screen, y_screen + + def get_pick_info(self, pos): + """ + Get pick info at this screen position + + Parameters + ---------- + pos: (x, y) + screen space position + + Returns + ------- + dict | None + pick info if a graphic is at this position, else None + + """ + + info = self.renderer.get_pick_info(pos) + + if info["world_object"] is not None: + try: + graphic = WORLD_OBJECT_TO_GRAPHIC[info["world_object"]._global_id] + info["graphic"] = graphic + return info + + except KeyError: + pass # this world obj is not owned by a graphic + def _render(self): self._call_animate_functions(self._animate_funcs_pre) diff --git a/fastplotlib/tools/_tooltip.py b/fastplotlib/tools/_tooltip.py index 3984571ef..db9859265 100644 --- a/fastplotlib/tools/_tooltip.py +++ b/fastplotlib/tools/_tooltip.py @@ -1,4 +1,5 @@ from functools import partial +from typing import Literal import numpy as np import pygfx @@ -170,6 +171,14 @@ def padding(self, padding_xy: tuple[float, float]): self._padding[0, :2] = padding_xy self._padding[1, :2] = -np.asarray(padding_xy) + @property + def visible(self) -> bool: + return self._world_object.visible + + @visible.setter + def visible(self, visible: bool): + self._world_object.visible = visible + def display(self, position: tuple[float, float], info: str): """ display tooltip at the given position in screen space @@ -184,6 +193,7 @@ def display(self, position: tuple[float, float], info: str): """ # set the text and top left position of the tooltip + self.visible = True self._text.set_text(info) self._set_position(position) From 506fa2c19ea7a169b16c5465b620ad2a957f4257 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 20 Nov 2025 04:28:09 -0500 Subject: [PATCH 06/34] fix --- fastplotlib/graphics/_base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastplotlib/graphics/_base.py b/fastplotlib/graphics/_base.py index 089bc6078..bb28d0252 100644 --- a/fastplotlib/graphics/_base.py +++ b/fastplotlib/graphics/_base.py @@ -265,7 +265,7 @@ def _set_world_object(self, wo: pygfx.WorldObject): global_id = int(child.uniform_buffer.data["global_id"]) WORLD_OBJECT_TO_GRAPHIC[global_id] = self else: - global_id = wo.uniform_buffer.data["global_id"] + global_id = int(wo.uniform_buffer.data["global_id"]) WORLD_OBJECT_TO_GRAPHIC[global_id] = self wo.visible = self.visible From f69ccc970ea475bd34ad1978c4035e7773d67a7b Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 20 Nov 2025 05:13:18 -0500 Subject: [PATCH 07/34] fix gc --- fastplotlib/graphics/_base.py | 9 +++++++++ fastplotlib/graphics/_collection_base.py | 11 +---------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/fastplotlib/graphics/_base.py b/fastplotlib/graphics/_base.py index bb28d0252..70db4c8ce 100644 --- a/fastplotlib/graphics/_base.py +++ b/fastplotlib/graphics/_base.py @@ -170,6 +170,8 @@ def __init__( self._right_click_menu = None + self._world_object_ids = list() + @property def supported_events(self) -> tuple[str]: """events supported by this graphic""" @@ -264,9 +266,13 @@ def _set_world_object(self, wo: pygfx.WorldObject): # and numpy arrays aren't hashable global_id = int(child.uniform_buffer.data["global_id"]) WORLD_OBJECT_TO_GRAPHIC[global_id] = self + # store id to pop from dict when graphic is deleted + self._world_object_ids.append(global_id) else: global_id = int(wo.uniform_buffer.data["global_id"]) WORLD_OBJECT_TO_GRAPHIC[global_id] = self + # store id to pop from dict when graphic is deleted + self._world_object_ids.append(global_id) wo.visible = self.visible if "Image" in self.__class__.__name__: @@ -461,6 +467,9 @@ def _fpl_prepare_del(self): Optionally implemented in subclasses """ + for global_id in self._world_object_ids: + WORLD_OBJECT_TO_GRAPHIC.pop(global_id) + # remove axes if added to this graphic if self._axes is not None: self._plot_area.scene.remove(self._axes) diff --git a/fastplotlib/graphics/_collection_base.py b/fastplotlib/graphics/_collection_base.py index 36f83ec7a..89bf387bb 100644 --- a/fastplotlib/graphics/_collection_base.py +++ b/fastplotlib/graphics/_collection_base.py @@ -308,6 +308,7 @@ def _fpl_prepare_del(self): """ # clear any attached event handlers and animation functions self.world_object._event_handlers.clear() + self.world_object.clear() for g in self: g._fpl_prepare_del() @@ -318,16 +319,6 @@ def __getitem__(self, key) -> CollectionIndexer: return self._indexer(selection=self.graphics[key], features=self._features) - def __del__(self): - # detach children - self.world_object.clear() - - for g in self.graphics: - g._fpl_prepare_del() - del g - - super().__del__() - def __len__(self): return len(self._graphics) From c0c12032e4872784f9e48a61c99ece0b0c2b49a5 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 25 Nov 2025 14:50:51 -0500 Subject: [PATCH 08/34] stuff --- examples/misc/cursor_custom_tooltip.py | 8 +- fastplotlib/graphics/_base.py | 25 +++ fastplotlib/{tools => graphics}/_tooltip.py | 230 ++++++++++---------- fastplotlib/layouts/_figure.py | 43 ---- fastplotlib/tools/__init__.py | 3 - 5 files changed, 141 insertions(+), 168 deletions(-) rename fastplotlib/{tools => graphics}/_tooltip.py (61%) diff --git a/examples/misc/cursor_custom_tooltip.py b/examples/misc/cursor_custom_tooltip.py index c284d38b0..5cb83bdc3 100644 --- a/examples/misc/cursor_custom_tooltip.py +++ b/examples/misc/cursor_custom_tooltip.py @@ -16,10 +16,10 @@ scatter_data = np.random.normal(loc=256, scale=(50), size=(500)).reshape(250, 2) line_data = np.random.rand(100, 2) * 512 -figure = fpl.Figure(shape=(2, 2), size=(500, 500)) +figure = fpl.Figure(shape=(2, 2), size=(700, 800)) img = figure[0, 0].add_image(img1, cmap="viridis") -figure[0, 1].add_image(img2, metadata="image metadata") +figure[0, 1].add_image(img2, metadata=np.arange(512)) figure[1, 0].add_scatter(scatter_data, sizes=5, metadata="scatter metadata") figure[1, 1].add_line(line_data, metadata="line metadata") @@ -46,8 +46,8 @@ def update(ev): if pick is None: tooltips2.visible = False return - - info = pick["graphic"].metadata + print(pick) + info = pick["graphic"].metadata[pick["index"][1]] tooltips2.display((x, y), str(info)) print((img.world_object.children[0].uniform_buffer.data["global_id"]).item()) diff --git a/fastplotlib/graphics/_base.py b/fastplotlib/graphics/_base.py index 70db4c8ce..20fdd2e18 100644 --- a/fastplotlib/graphics/_base.py +++ b/fastplotlib/graphics/_base.py @@ -27,6 +27,7 @@ Visible, ) from ._axes import Axes +from ._tooltip import Tooltip HexStr: TypeAlias = str WorldObjectID: TypeAlias = int @@ -172,6 +173,8 @@ def __init__( self._world_object_ids = list() + self._tooltip = GraphicTooltip(self) + @property def supported_events(self) -> tuple[str]: """events supported by this graphic""" @@ -452,6 +455,7 @@ def my_handler(event): def _fpl_add_plot_area_hook(self, plot_area): self._plot_area = plot_area + self._tooltip._fpl_add_plot_area_hook(plot_area) def __repr__(self): rval = f"{self.__class__.__name__}" @@ -569,3 +573,24 @@ def _fpl_request_right_click_menu(self): def _fpl_close_right_click_menu(self): pass + + +class GraphicTooltip(Tooltip): + def __init__(self, graphic: Graphic): + pass + + def _fpl_add_plot_area_hook(self, plot_area): + plot_area.get_figure()._overlay_scene.add(self._world_object) + + def display(self, position: tuple[float, float], info: str = None): + """ + + Parameters + ---------- + position + info + + Returns + ------- + + """ \ No newline at end of file diff --git a/fastplotlib/tools/_tooltip.py b/fastplotlib/graphics/_tooltip.py similarity index 61% rename from fastplotlib/tools/_tooltip.py rename to fastplotlib/graphics/_tooltip.py index db9859265..4c4fa85e0 100644 --- a/fastplotlib/tools/_tooltip.py +++ b/fastplotlib/graphics/_tooltip.py @@ -5,8 +5,6 @@ import pygfx from ..utils.enums import RenderQueue -from ..graphics import LineGraphic, ImageGraphic, ScatterGraphic, Graphic -from ..graphics.features import GraphicFeatureEvent class MeshMasks: @@ -117,10 +115,6 @@ def __init__(self): # making the text easier to read self._padding = np.array([[5, 5, 0], [-5, -5, 0]], dtype=np.float32) - @property - def world_object(self) -> pygfx.Group: - return self._world_object - @property def font_size(self): """Get or set font size""" @@ -234,115 +228,115 @@ def _set_position(self, pos: tuple[float, float]): def clear(self, *args): self._text.set_text("") - self.world_object.visible = False - - -class GraphicTooltip(Tooltip): - """A tooltip that auto displays info for registered graphics""" - - def __init__(self): - super().__init__() - - self._registered_graphics = dict() - - def _event_handler(self, custom_tooltip: callable, ev: pygfx.PointerEvent): - """Handles the tooltip appear event, determines the text to be set in the tooltip""" - if custom_tooltip is not None: - info = custom_tooltip(ev) - - elif isinstance(ev.graphic, ImageGraphic): - col, row = ev.pick_info["index"] - if ev.graphic.data.value.ndim == 2: - info = str(ev.graphic.data[row, col]) - else: - info = "\n".join( - f"{channel}: {val}" - for channel, val in zip("rgba", ev.graphic.data[row, col]) - ) - - elif isinstance(ev.graphic, (LineGraphic, ScatterGraphic)): - index = ev.pick_info["vertex_index"] - info = "\n".join( - f"{dim}: {val}" for dim, val in zip("xyz", ev.graphic.data[index]) - ) - else: - return - - # make the tooltip object visible - self.world_object.visible = True - - self.display((ev.x, ev.y), info) - - def register( - self, - graphic: Graphic, - appear_event: str = "pointer_move", - disappear_event: str = "pointer_leave", - custom_info: callable = None, - ): - """ - Register a Graphic to display tooltips. - - **Note:** if the passed graphic is already registered then it first unregistered - and then re-registered using the given arguments. - - Parameters - ---------- - graphic: Graphic - Graphic to register - - appear_event: str, default "pointer_move" - the pointer that triggers the tooltip to appear. Usually one of "pointer_move" | "click" | "double_click" - - disappear_event: str, default "pointer_leave" - the event that triggers the tooltip to disappear, does not have to be a pointer event. - - custom_info: callable, default None - a custom function that takes the pointer event defined as the `appear_event` and returns the text - to display in the tooltip - - """ - if graphic in list(self._registered_graphics.keys()): - # unregister first and then re-register - self.unregister(graphic) - - pfunc = partial(self._event_handler, custom_info) - graphic.add_event_handler(pfunc, appear_event) - graphic.add_event_handler(self.clear, disappear_event) - - self._registered_graphics[graphic] = (pfunc, appear_event, disappear_event) - - # automatically unregister when graphic is deleted - graphic.add_event_handler(self.unregister, "deleted") - - def unregister(self, graphic: Graphic): - """ - Unregister a Graphic to no longer display tooltips for this graphic. - - **Note:** if the passed graphic is not registered then it is just ignored without raising any exception. - - Parameters - ---------- - graphic: Graphic - Graphic to unregister - - """ - - if isinstance(graphic, GraphicFeatureEvent): - # this happens when the deleted event is triggered - graphic = graphic.graphic - - if graphic not in self._registered_graphics: - return - - # get pfunc and event names - pfunc, appear_event, disappear_event = self._registered_graphics.pop(graphic) - - # remove handlers from graphic - graphic.remove_event_handler(pfunc, appear_event) - graphic.remove_event_handler(self.clear, disappear_event) - - def unregister_all(self): - """unregister all graphics""" - for graphic in self._registered_graphics.keys(): - self.unregister(graphic) + self._world_object.visible = False + + +# class GraphicTooltip(Tooltip): +# """A tooltip that auto displays info for registered graphics""" +# +# def __init__(self): +# super().__init__() +# +# self._registered_graphics = dict() +# +# def _event_handler(self, custom_tooltip: callable, ev: pygfx.PointerEvent): +# """Handles the tooltip appear event, determines the text to be set in the tooltip""" +# if custom_tooltip is not None: +# info = custom_tooltip(ev) +# +# elif isinstance(ev.graphic, ImageGraphic): +# col, row = ev.pick_info["index"] +# if ev.graphic.data.value.ndim == 2: +# info = str(ev.graphic.data[row, col]) +# else: +# info = "\n".join( +# f"{channel}: {val}" +# for channel, val in zip("rgba", ev.graphic.data[row, col]) +# ) +# +# elif isinstance(ev.graphic, (LineGraphic, ScatterGraphic)): +# index = ev.pick_info["vertex_index"] +# info = "\n".join( +# f"{dim}: {val}" for dim, val in zip("xyz", ev.graphic.data[index]) +# ) +# else: +# return +# +# # make the tooltip object visible +# self.world_object.visible = True +# +# self.display((ev.x, ev.y), info) +# +# def register( +# self, +# graphic: Graphic, +# appear_event: str = "pointer_move", +# disappear_event: str = "pointer_leave", +# custom_info: callable = None, +# ): +# """ +# Register a Graphic to display tooltips. +# +# **Note:** if the passed graphic is already registered then it first unregistered +# and then re-registered using the given arguments. +# +# Parameters +# ---------- +# graphic: Graphic +# Graphic to register +# +# appear_event: str, default "pointer_move" +# the pointer that triggers the tooltip to appear. Usually one of "pointer_move" | "click" | "double_click" +# +# disappear_event: str, default "pointer_leave" +# the event that triggers the tooltip to disappear, does not have to be a pointer event. +# +# custom_info: callable, default None +# a custom function that takes the pointer event defined as the `appear_event` and returns the text +# to display in the tooltip +# +# """ +# if graphic in list(self._registered_graphics.keys()): +# # unregister first and then re-register +# self.unregister(graphic) +# +# pfunc = partial(self._event_handler, custom_info) +# graphic.add_event_handler(pfunc, appear_event) +# graphic.add_event_handler(self.clear, disappear_event) +# +# self._registered_graphics[graphic] = (pfunc, appear_event, disappear_event) +# +# # automatically unregister when graphic is deleted +# graphic.add_event_handler(self.unregister, "deleted") +# +# def unregister(self, graphic: Graphic): +# """ +# Unregister a Graphic to no longer display tooltips for this graphic. +# +# **Note:** if the passed graphic is not registered then it is just ignored without raising any exception. +# +# Parameters +# ---------- +# graphic: Graphic +# Graphic to unregister +# +# """ +# +# if isinstance(graphic, GraphicFeatureEvent): +# # this happens when the deleted event is triggered +# graphic = graphic.graphic +# +# if graphic not in self._registered_graphics: +# return +# +# # get pfunc and event names +# pfunc, appear_event, disappear_event = self._registered_graphics.pop(graphic) +# +# # remove handlers from graphic +# graphic.remove_event_handler(pfunc, appear_event) +# graphic.remove_event_handler(self.clear, disappear_event) +# +# def unregister_all(self): +# """unregister all graphics""" +# for graphic in self._registered_graphics.keys(): +# self.unregister(graphic) diff --git a/fastplotlib/layouts/_figure.py b/fastplotlib/layouts/_figure.py index a3dce2c4d..d4c8a5948 100644 --- a/fastplotlib/layouts/_figure.py +++ b/fastplotlib/layouts/_figure.py @@ -21,7 +21,6 @@ from ._subplot import Subplot from ._engine import GridLayout, WindowLayout, ScreenSpaceCamera from .. import ImageGraphic -from ..tools import Tooltip, GraphicTooltip class Figure: @@ -52,7 +51,6 @@ def __init__( canvas_kwargs: dict = None, size: tuple[int, int] = (500, 300), names: list | np.ndarray = None, - show_tooltips: bool = False, ): """ Create a Figure containing Subplots. @@ -124,9 +122,6 @@ def __init__( names: list or array of str, optional subplot names - show_tooltips: bool, default False - show tooltips on graphics - """ if rects is not None: @@ -460,12 +455,6 @@ def __init__( self._overlay_camera = ScreenSpaceCamera() self._overlay_scene = pygfx.Scene() - # tooltip in overlay render pass - self._tooltip_manager = GraphicTooltip() - self._overlay_scene.add(self._tooltip_manager.world_object) - - self._show_tooltips = show_tooltips - self._animate_funcs_pre: list[callable] = list() self._animate_funcs_post: list[callable] = list() @@ -533,43 +522,11 @@ def names(self) -> np.ndarray[str]: names.flags.writeable = False return names - @property - def tooltip_manager(self) -> GraphicTooltip: - """manage tooltips""" - return self._tooltip_manager - - @property - def show_tooltips(self) -> bool: - """show/hide tooltips for all graphics""" - return self._show_tooltips - @property def animations(self) -> dict[str, list[callable]]: """Returns a dictionary of 'pre' and 'post' animation functions.""" return {"pre": self._animate_funcs_pre, "post": self._animate_funcs_post} - @show_tooltips.setter - def show_tooltips(self, val: bool): - self._show_tooltips = val - - if val: - # register all graphics - for subplot in self: - for graphic in subplot.graphics: - self._tooltip_manager.register(graphic) - - elif not val: - self._tooltip_manager.unregister_all() - - def add_tooltip(self, tooltip: Tooltip): - if not isinstance(tooltip, Tooltip): - raise TypeError(f"tooltip must be a `Tooltip` instance, you passed: {tooltip}") - - self._overlay_scene.add(tooltip.world_object) - - def remove_tooltip(self, tooltip): - self._overlay_scene.remove(tooltip) - def _render(self, draw=True): # draw the underlay planes self.renderer.render(self._underlay_scene, self._underlay_camera, flush=False) diff --git a/fastplotlib/tools/__init__.py b/fastplotlib/tools/__init__.py index 15799253f..f75137bd2 100644 --- a/fastplotlib/tools/__init__.py +++ b/fastplotlib/tools/__init__.py @@ -1,10 +1,7 @@ from ._histogram_lut import HistogramLUTTool -from ._tooltip import Tooltip, GraphicTooltip from ._cursor import Cursor __all__ = [ "HistogramLUTTool", - "Tooltip", - "GraphicTooltip", "Cursor", ] From c5f8eaccf4686cf0ef4709bc93d673b21f02b2a5 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sat, 6 Dec 2025 05:48:33 -0500 Subject: [PATCH 09/34] cursor and tooltips refactor, basically works --- docs/source/user_guide/guide.rst | 22 +- examples/misc/tooltips.py | 54 ---- fastplotlib/graphics/__init__.py | 5 +- fastplotlib/graphics/_base.py | 255 ++++++++++++++++-- fastplotlib/graphics/_positions_base.py | 8 + fastplotlib/graphics/_tooltip.py | 134 ++------- fastplotlib/graphics/_vectors.py | 10 +- fastplotlib/graphics/features/__init__.py | 3 +- fastplotlib/graphics/features/_common.py | 49 ++++ fastplotlib/graphics/image.py | 22 ++ fastplotlib/graphics/image_volume.py | 13 + fastplotlib/graphics/line_collection.py | 12 + fastplotlib/graphics/mesh.py | 17 ++ .../graphics/selectors/_base_selector.py | 2 +- fastplotlib/graphics/text.py | 2 +- fastplotlib/layouts/_figure.py | 4 +- fastplotlib/layouts/_imgui_figure.py | 2 - fastplotlib/layouts/_plot_area.py | 43 ++- fastplotlib/tools/_cursor.py | 20 +- fastplotlib/tools/_histogram_lut.py | 2 +- 20 files changed, 455 insertions(+), 224 deletions(-) delete mode 100644 examples/misc/tooltips.py diff --git a/docs/source/user_guide/guide.rst b/docs/source/user_guide/guide.rst index 8bf255507..bd0352aa7 100644 --- a/docs/source/user_guide/guide.rst +++ b/docs/source/user_guide/guide.rst @@ -648,23 +648,29 @@ There are several spaces to consider when using ``fastplotlib``: World space is the 3D space in which graphical objects live. Objects and the camera can exist anywhere in this space. -2) Data Space +2) Model or Data Space - Data space is simply the world space plus any offset or rotation that has been applied to an object. + Model/Data space is simply the world space plus any offset, scaling and rotation that has been applied to an object. .. note:: - World space does not always correspond directly to data space, you may have to adjust for any offset or rotation of the ``Graphic``. + World space does not always correspond directly to data space, + you may have to adjust for any offset, rotation, and scaling of the ``Graphic``. See below. 3) Screen Space Screen space is the 2D space in which your screen pixels reside. This space is constrained by the screen width and height in pixels. In the rendering process, the camera is responsible for projecting the world space into screen space. -.. note:: - When interacting with ``Graphic`` objects, there is a very helpful function for mapping screen space to world space - (``Figure.map_screen_to_world(pos=(x, y))``). This can be particularly useful when working with click events where click - positions are returned in screen space but ``Graphic`` objects that you may want to interact with exist in world - space. +When interacting with ``Graphic`` objects, there are helpful functions for mapping between these spaces: + - ``Subplot.map_screen_to_world((x, y))`` + - ``Subplot.map_world_to_screen((x, y, z))`` + - ``Graphic.map_model_to_world((x, y, z))`` + - ``Graphic.map_world_to_model((x, y, z))`` + +This can be particularly useful when working with click events where click positions are returned in screen space but + ``Graphic`` objects that you may want to interact with exist in world space. It can also be useful for determining + the screen/canvas pixel position of a datapoint on a graphic by mapping: model -> world -> screen. The entire inverse + transform can also be performed, screen -> world -> model. For more information on the various spaces used by rendering engines please see this `article `_ diff --git a/examples/misc/tooltips.py b/examples/misc/tooltips.py deleted file mode 100644 index cad3d807c..000000000 --- a/examples/misc/tooltips.py +++ /dev/null @@ -1,54 +0,0 @@ -""" -Tooltips -======== - -Show tooltips on all graphics -""" - -# test_example = false -# sphinx_gallery_pygfx_docs = 'screenshot' - -import numpy as np -import imageio.v3 as iio -import fastplotlib as fpl - - -# get some data -scatter_data = np.random.rand(1_000, 3) - -xs = np.linspace(0, 2 * np.pi, 100) -ys = np.sin(xs) - -gray = iio.imread("imageio:camera.png") -rgb = iio.imread("imageio:astronaut.png") - -# create a figure -figure = fpl.Figure( - cameras=["3d", "2d", "2d", "2d"], - controller_types=["orbit", "panzoom", "panzoom", "panzoom"], - size=(700, 560), - shape=(2, 2), - show_tooltips=True, # tooltip will display data value info for all graphics -) - -# create graphics -scatter = figure[0, 0].add_scatter(scatter_data, sizes=3, colors="r") -line = figure[0, 1].add_line(np.column_stack([xs, ys])) -image = figure[1, 0].add_image(gray) -image_rgb = figure[1, 1].add_image(rgb) - - -figure.show() - -# to hide tooltips for all graphics in an existing Figure -# figure.show_tooltips = False - -# to show tooltips for all graphics in an existing Figure -# figure.show_tooltips = True - - -# 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 3d01e4a35..a09dc9641 100644 --- a/fastplotlib/graphics/__init__.py +++ b/fastplotlib/graphics/__init__.py @@ -1,4 +1,5 @@ -from ._base import Graphic +from ._tooltip import Tooltip +from ._base import Graphic, GraphicTooltip from .line import LineGraphic from .scatter import ScatterGraphic from .image import ImageGraphic @@ -10,6 +11,8 @@ __all__ = [ + "Tooltip", + "GraphicTooltip", "Graphic", "LineGraphic", "ScatterGraphic", diff --git a/fastplotlib/graphics/_base.py b/fastplotlib/graphics/_base.py index 20fdd2e18..c60bdf55c 100644 --- a/fastplotlib/graphics/_base.py +++ b/fastplotlib/graphics/_base.py @@ -1,6 +1,7 @@ +from __future__ import annotations from collections import defaultdict from functools import partial -from typing import Any, Literal, TypeAlias +from typing import Any, Literal, TypeAlias, Callable import weakref import numpy as np @@ -22,6 +23,7 @@ Name, Offset, Rotation, + Scale, Alpha, AlphaMode, Visible, @@ -38,7 +40,7 @@ # maps world object to the graphic which owns it, useful when manually picking from the renderer and we # need to know the graphic associated with the target world object -WORLD_OBJECT_TO_GRAPHIC: dict[WorldObjectID, "Graphic"] = dict() +WORLD_OBJECT_TO_GRAPHIC: dict[WorldObjectID, Graphic] = dict() PYGFX_EVENTS = [ @@ -68,6 +70,7 @@ def __init_subclass__(cls, **kwargs): "name": Name, "offset": Offset, "rotation": Rotation, + "scale": Scale, "alpha": Alpha, "alpha_mode": AlphaMode, "visible": Visible, @@ -78,12 +81,14 @@ def __init_subclass__(cls, **kwargs): def __init__( self, name: str = None, - offset: np.ndarray | list | tuple = (0.0, 0.0, 0.0), - rotation: np.ndarray | list | tuple = (0.0, 0.0, 0.0, 1.0), + offset: np.ndarray | tuple[float] = (0.0, 0.0, 0.0), + rotation: np.ndarray | tuple[float] = (0.0, 0.0, 0.0, 1.0), + scale: np.ndarray | tuple[float] = (1.0, 1.0, 1.0), alpha: float = 1.0, alpha_mode: str = "auto", visible: bool = True, metadata: Any = None, + create_tooltip: bool = True, ): """ @@ -98,6 +103,9 @@ def __init__( rotation: (float, float, float, float), default (0, 0, 0, 1) rotation quaternion + scale: (float, float, float), default (1.0, 1.0, 1.0) + (x, y, z) scale factors + alpha: (float), default 1.0 The global alpha value, i.e. opacity, of the graphic. @@ -161,6 +169,7 @@ def __init__( self._name = Name(name) self._deleted = Deleted(False) self._rotation = Rotation(rotation) + self._scale = Scale(scale) self._offset = Offset(offset) self._alpha = Alpha(alpha) self._alpha_mode = AlphaMode(alpha_mode) @@ -171,9 +180,19 @@ def __init__( self._right_click_menu = None + # store ids of all the WorldObjects that this Graphic manages/uses self._world_object_ids = list() - self._tooltip = GraphicTooltip(self) + # TODO: this exists for LineCollections since we don't want to create + # thousands or hundreds of tooltip objects and meshes etc. for each line, + # the GraphicCollection handles one tooltip instance instead. Once we + # refactor GraphicCollection we can make this nicer + # It also doesn't make sense to create tooltips for text, that would be very funny + # similarly they would probably not be useful for selector tools + if create_tooltip: + self._tooltip = GraphicTooltip(self) + else: + self._tooltip = None @property def supported_events(self) -> tuple[str]: @@ -195,7 +214,7 @@ def offset(self) -> np.ndarray: return self._offset.value @offset.setter - def offset(self, value: np.ndarray | list | tuple): + def offset(self, value: np.ndarray | tuple[float, float, float]): self._offset.set_value(self, value) @property @@ -204,9 +223,18 @@ def rotation(self) -> np.ndarray: return self._rotation.value @rotation.setter - def rotation(self, value: np.ndarray | list | tuple): + def rotation(self, value: np.ndarray | tuple[float, float, float, float]): self._rotation.set_value(self, value) + @property + def scale(self) -> np.ndarray: + """(x, y, z) scaling factor""" + return self._scale.value + + @scale.setter + def scale(self, value: np.ndarray | tuple[float, float, float]): + self._scale.set_value(self, value) + @property def alpha(self) -> float: """The opacity of the graphic""" @@ -267,12 +295,12 @@ def _set_world_object(self, wo: pygfx.WorldObject): if isinstance(child, (pygfx.Image, pygfx.Volume, pygfx.Points, pygfx.Line)): # need to call int() on it since it's a numpy array with 1 element # and numpy arrays aren't hashable - global_id = int(child.uniform_buffer.data["global_id"]) + global_id = child.id WORLD_OBJECT_TO_GRAPHIC[global_id] = self # store id to pop from dict when graphic is deleted self._world_object_ids.append(global_id) else: - global_id = int(wo.uniform_buffer.data["global_id"]) + global_id = wo.id WORLD_OBJECT_TO_GRAPHIC[global_id] = self # store id to pop from dict when graphic is deleted self._world_object_ids.append(global_id) @@ -295,6 +323,11 @@ def _set_world_object(self, wo: pygfx.WorldObject): if not all(wo.world.rotation == self.rotation): self.rotation = self.rotation + @property + def tooltip(self) -> GraphicTooltip: + """tooltip for this graphic""" + return self._tooltip + @property def event_handlers(self) -> list[tuple[str, callable, ...]]: """ @@ -453,9 +486,69 @@ def my_handler(event): feature = getattr(self, f"_{t}") feature.remove_event_handler(wrapper) + def map_model_to_world(self, position: tuple[float, float, float] | tuple[float, float]) -> np.ndarray: + """ + map position from model (data) space to world space, basically applies the world affine transform + + Parameters + ---------- + position: (float, float, float) or (float, float) + (x, y, z) or (x, y) position. If z is not provided then the graphic's offset z is used. + + Returns + ------- + np.ndarray + (x, y, z) position in world space + + """ + + if len(position) == 2: + # use z of the graphic + position = [*position, self.offset[-1]] + + if len(position) != 3: + raise ValueError(f"position must be tuple indicating (x, y, z) position in *model space*") + + # apply world transform to project from model space to world space + return la.vec_transform(position, self.world_object.world.matrix) + + def map_world_to_model(self, position: tuple[float, float, float] | tuple[float, float]) -> np.ndarray: + """ + map position from world space to model (data) space, basically applies the inverse world affine transform + + Parameters + ---------- + position: (float, float, float) or (float, float) + (x, y, z) or (x, y) position. If z is not provided then 0 is used. + + Returns + ------- + np.ndarray + (x, y, z) position in world space + + """ + + if len(position) == 2: + # use z of the graphic + position = [*position, self.offset[-1]] + + if len(position) != 3: + raise ValueError(f"position must be tuple indicating (x, y, z) position in *model space*") + + return la.vec_transform(position, self.world_object.world.inverse_matrix) + + def _fpl_tooltip_info_handler(self, ev: pygfx.PointerEvent) -> str: + """ + Takes a pygfx.PointerEvent and returns formatted pick info. + """ + + raise NotImplementedError("must be implemented in subclass") + def _fpl_add_plot_area_hook(self, plot_area): self._plot_area = plot_area - self._tooltip._fpl_add_plot_area_hook(plot_area) + + if self._tooltip is not None: + self._tooltip._fpl_add_plot_area_hook(plot_area) def __repr__(self): rval = f"{self.__class__.__name__}" @@ -471,9 +564,15 @@ def _fpl_prepare_del(self): Optionally implemented in subclasses """ + # remove from world_obj -> graphic map for global_id in self._world_object_ids: WORLD_OBJECT_TO_GRAPHIC.pop(global_id) + # prepare del in tooltip remove tooltip + if self._tooltip is not None: + self._tooltip._fpl_prepare_del() + del self._tooltip + # remove axes if added to this graphic if self._axes is not None: self._plot_area.scene.remove(self._axes) @@ -577,20 +676,138 @@ def _fpl_close_right_click_menu(self): class GraphicTooltip(Tooltip): def __init__(self, graphic: Graphic): - pass + self._graphic = graphic + self._plot_area = None - def _fpl_add_plot_area_hook(self, plot_area): - plot_area.get_figure()._overlay_scene.add(self._world_object) + self._info_handler: Callable = None - def display(self, position: tuple[float, float], info: str = None): + self._enabled = True + + super().__init__() + + @property + def enabled(self) -> bool: + """enable or disable tooltips for this graphic""" + return self._enabled + + @enabled.setter + def enabled(self, enable: bool): + self._enabled = bool(enable) + + if not self._enabled: + self.visible = False + + @property + def info_handler(self) -> None | Callable: + """get or set a custom handler for setting the tooltip info""" + return self._info_handler + + @info_handler.setter + def info_handler(self, func: Callable | None): + if func is None: + self._info_handler = None + return + + if not callable(func): + raise TypeError( + f"`info_handler` must be set with a callable that takes a pointer event, or it can be set as None" + ) + + self._info_handler = func + + def display(self, position: tuple[float, float, float] | tuple[float, float], info: str = None, space: Literal["model", "world", "screen"] = "model"): """ + display tooltip at the given position in the given space Parameters ---------- - position - info + position: (float, float, float) or (float, float) + (x, y, z) or (x, y) position in **model space** - Returns - ------- + info: str + text to display in the tooltip + + space: Literal["model", "world", "screen"], default "model" + interpret the ``position`` as being in this space + + """ + if not self.enabled: + return + + if space == "model": + world_pos = self._graphic.map_model_to_world(position) + screen_pos = self._plot_area.map_world_to_screen(world_pos) + + elif space == "world": + screen_pos = self._plot_area.map_world_to_screen(world_pos) + + elif space == "screen": + screen_pos = position + + else: + raise ValueError(f"`space` must be one of: 'model', 'world', or 'screen', you passed: {space}") + + if info is None: + # auto fetch pick info + pick_info = self._plot_area.get_pick_info(screen_pos) + + # if it is None return, the graphic is moved away from this position + if pick_info is None: + return + + # simulate event at this screen position, pass through graphic's formatter + info = self.format_event( + pygfx.PointerEvent("tooltip-pick", x=screen_pos[0], y=screen_pos[1], pick_info=pick_info)) + + super().display(screen_pos, info) + + def _fpl_auto_update_render(self): + # auto-updates the tooltip on every render so it is always accurate + # if the data under the graphic changes at this position, then it will update the text + if self.visible: + self.display( + position=self.position, + space="screen", + ) + + def _fpl_add_plot_area_hook(self, plot_area): + self._plot_area = plot_area + + # add to overlay scene + self._plot_area.get_figure()._fpl_overlay_scene.add(self._fpl_world_object) + + # this makes the tooltip info auto-update on every render + self._plot_area.add_animations(self._fpl_auto_update_render, post_render="True") + + # connect events + self._graphic.add_event_handler(self._pointer_move_handler, "pointer_move") + self._graphic.add_event_handler(self._pointer_leave_handler, "pointer_leave") + + def _fpl_prepare_del(self): + # remove from overlay scene + self._plot_area.get_figure()._fpl_overlay_scene.remove(self._fpl_world_object) + + # remove animation func + self._plot_area.remove_animation(self._fpl_auto_update_render) + + def format_event(self, ev): + # format pick info + info = self._graphic._fpl_tooltip_info_handler(ev) + + if self.info_handler is not None: + info = self.info_handler(ev, info) + + return info + + def _pointer_move_handler(self, ev: pygfx.PointerEvent): + if not self.enabled: + return + + info = self.format_event(ev) + + # IMPORTANT: call display() of superclass class, NOT this class, + # since the pointer event already has the screen space (x, y) + super().display((ev.x, ev.y), info) - """ \ No newline at end of file + def _pointer_leave_handler(self, ev): + self.clear() diff --git a/fastplotlib/graphics/_positions_base.py b/fastplotlib/graphics/_positions_base.py index 73520cc84..6badb089e 100644 --- a/fastplotlib/graphics/_positions_base.py +++ b/fastplotlib/graphics/_positions_base.py @@ -146,3 +146,11 @@ def __init__( self._size_space = SizeSpace(size_space) super().__init__(*args, **kwargs) + + def _fpl_tooltip_info_handler(self, ev: pygfx.PointerEvent) -> str: + index = ev.pick_info["vertex_index"] + info = "\n".join( + f"{dim}: {val:.4g}" for dim, val in zip("xyz", self.data[index]) + ) + + return info diff --git a/fastplotlib/graphics/_tooltip.py b/fastplotlib/graphics/_tooltip.py index 4c4fa85e0..a54623ca1 100644 --- a/fastplotlib/graphics/_tooltip.py +++ b/fastplotlib/graphics/_tooltip.py @@ -108,13 +108,21 @@ def __init__(self): # Plane gets rendered before text and line self._plane.render_order = -1 - self._world_object = pygfx.Group() - self._world_object.add(self._plane, self._text, self._line) + self._fpl_world_object = pygfx.Group() + self._fpl_world_object.add(self._plane, self._text, self._line) # padded to bbox so the background box behind the text extends a bit further # making the text easier to read self._padding = np.array([[5, 5, 0], [-5, -5, 0]], dtype=np.float32) + # position of the tooltip in screen space + self._position = np.array([0.0, 0.0]) + + @property + def position(self) -> np.ndarray: + """position of the tooltip in screen space""" + return self._position + @property def font_size(self): """Get or set font size""" @@ -167,11 +175,11 @@ def padding(self, padding_xy: tuple[float, float]): @property def visible(self) -> bool: - return self._world_object.visible + return self._fpl_world_object.visible @visible.setter def visible(self, visible: bool): - self._world_object.visible = visible + self._fpl_world_object.visible = visible def display(self, position: tuple[float, float], info: str): """ @@ -189,9 +197,10 @@ def display(self, position: tuple[float, float], info: str): # set the text and top left position of the tooltip self.visible = True self._text.set_text(info) - self._set_position(position) + self._draw_tooltip(position) + self._position[:] = position - def _set_position(self, pos: tuple[float, float]): + def _draw_tooltip(self, pos: tuple[float, float]): """ Set the position of the tooltip @@ -228,115 +237,4 @@ def _set_position(self, pos: tuple[float, float]): def clear(self, *args): self._text.set_text("") - self._world_object.visible = False - - -# class GraphicTooltip(Tooltip): -# """A tooltip that auto displays info for registered graphics""" -# -# def __init__(self): -# super().__init__() -# -# self._registered_graphics = dict() -# -# def _event_handler(self, custom_tooltip: callable, ev: pygfx.PointerEvent): -# """Handles the tooltip appear event, determines the text to be set in the tooltip""" -# if custom_tooltip is not None: -# info = custom_tooltip(ev) -# -# elif isinstance(ev.graphic, ImageGraphic): -# col, row = ev.pick_info["index"] -# if ev.graphic.data.value.ndim == 2: -# info = str(ev.graphic.data[row, col]) -# else: -# info = "\n".join( -# f"{channel}: {val}" -# for channel, val in zip("rgba", ev.graphic.data[row, col]) -# ) -# -# elif isinstance(ev.graphic, (LineGraphic, ScatterGraphic)): -# index = ev.pick_info["vertex_index"] -# info = "\n".join( -# f"{dim}: {val}" for dim, val in zip("xyz", ev.graphic.data[index]) -# ) -# else: -# return -# -# # make the tooltip object visible -# self.world_object.visible = True -# -# self.display((ev.x, ev.y), info) -# -# def register( -# self, -# graphic: Graphic, -# appear_event: str = "pointer_move", -# disappear_event: str = "pointer_leave", -# custom_info: callable = None, -# ): -# """ -# Register a Graphic to display tooltips. -# -# **Note:** if the passed graphic is already registered then it first unregistered -# and then re-registered using the given arguments. -# -# Parameters -# ---------- -# graphic: Graphic -# Graphic to register -# -# appear_event: str, default "pointer_move" -# the pointer that triggers the tooltip to appear. Usually one of "pointer_move" | "click" | "double_click" -# -# disappear_event: str, default "pointer_leave" -# the event that triggers the tooltip to disappear, does not have to be a pointer event. -# -# custom_info: callable, default None -# a custom function that takes the pointer event defined as the `appear_event` and returns the text -# to display in the tooltip -# -# """ -# if graphic in list(self._registered_graphics.keys()): -# # unregister first and then re-register -# self.unregister(graphic) -# -# pfunc = partial(self._event_handler, custom_info) -# graphic.add_event_handler(pfunc, appear_event) -# graphic.add_event_handler(self.clear, disappear_event) -# -# self._registered_graphics[graphic] = (pfunc, appear_event, disappear_event) -# -# # automatically unregister when graphic is deleted -# graphic.add_event_handler(self.unregister, "deleted") -# -# def unregister(self, graphic: Graphic): -# """ -# Unregister a Graphic to no longer display tooltips for this graphic. -# -# **Note:** if the passed graphic is not registered then it is just ignored without raising any exception. -# -# Parameters -# ---------- -# graphic: Graphic -# Graphic to unregister -# -# """ -# -# if isinstance(graphic, GraphicFeatureEvent): -# # this happens when the deleted event is triggered -# graphic = graphic.graphic -# -# if graphic not in self._registered_graphics: -# return -# -# # get pfunc and event names -# pfunc, appear_event, disappear_event = self._registered_graphics.pop(graphic) -# -# # remove handlers from graphic -# graphic.remove_event_handler(pfunc, appear_event) -# graphic.remove_event_handler(self.clear, disappear_event) -# -# def unregister_all(self): -# """unregister all graphics""" -# for graphic in self._registered_graphics.keys(): -# self.unregister(graphic) + self._fpl_world_object.visible = False diff --git a/fastplotlib/graphics/_vectors.py b/fastplotlib/graphics/_vectors.py index 6f761bd49..c0bf143ac 100644 --- a/fastplotlib/graphics/_vectors.py +++ b/fastplotlib/graphics/_vectors.py @@ -128,7 +128,7 @@ def __init__( } geometry = create_vector_geometry(color=color, **shape_options) - material = pygfx.MeshBasicMaterial() + material = pygfx.MeshBasicMaterial(pick_write=True) n_vectors = self._positions.value.shape[0] @@ -170,6 +170,14 @@ def directions(self) -> VectorDirections: def directions(self, new_directions): self._directions.set_value(self, new_directions) + def _fpl_tooltip_info_handler(self, ev: pygfx.PointerEvent) -> str: + index = ev.pick_info["instance_index"] + + info = (f"position: {self.positions[index]}\n" + f"direction: {self.directions[index]}") + + return info + # mesh code copied and adapted from pygfx def generate_torso( diff --git a/fastplotlib/graphics/features/__init__.py b/fastplotlib/graphics/features/__init__.py index cf99d376d..7f7410cf7 100644 --- a/fastplotlib/graphics/features/__init__.py +++ b/fastplotlib/graphics/features/__init__.py @@ -71,7 +71,7 @@ LinearRegionSelectionFeature, RectangleSelectionFeature, ) -from ._common import Name, Offset, Rotation, Alpha, AlphaMode, Visible, Deleted +from ._common import Name, Offset, Rotation, Scale, Alpha, AlphaMode, Visible, Deleted __all__ = [ @@ -119,6 +119,7 @@ "Name", "Offset", "Rotation", + "Scale", "Alpha", "AlphaMode", "Visible", diff --git a/fastplotlib/graphics/features/_common.py b/fastplotlib/graphics/features/_common.py index b2b99cc49..6ce167075 100644 --- a/fastplotlib/graphics/features/_common.py +++ b/fastplotlib/graphics/features/_common.py @@ -130,6 +130,55 @@ def set_value(self, graphic, value: np.ndarray | Sequence[float]): self._call_event_handlers(event) +class Scale(GraphicFeature): + event_info_spec = [ + { + "dict key": "value", + "type": "np.ndarray[float, float, float, float]", + "description": "new scale", + }, + ] + + def __init__( + self, value: np.ndarray | Sequence[float], property_name: str = "scale" + ): + """Graphic scaling factor""" + + self._validate(value) + # create ones array + self._value = np.ones(3) + + self._value[:] = value + super().__init__(property_name=property_name) + + def _validate(self, value): + if not len(value) in [2, 3]: + raise ValueError( + "scale must be a list, tuple, or array of 2 or 3 float values indicating (x, y) or (x, y, z) scaling" + ) + + @property + def value(self) -> np.ndarray: + return self._value + + @block_reentrance + def set_value(self, graphic, value: np.ndarray | Sequence[float]): + self._validate(value) + + if len(value) == 2: + value = (*value, graphic.world_object.world.scale_z) + + value = np.asarray(value) + + graphic.world_object.world.scale = value + + # set value of existing feature value array + self._value[:] = value + + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) + self._call_event_handlers(event) + + class Alpha(GraphicFeature): """The alpha value (i.e. opacity) of a graphic.""" diff --git a/fastplotlib/graphics/image.py b/fastplotlib/graphics/image.py index 1eaf54bb6..3aed2a2bb 100644 --- a/fastplotlib/graphics/image.py +++ b/fastplotlib/graphics/image.py @@ -21,6 +21,15 @@ ) +def _format_value(value: float): + """float -> rounded str, or str with scientific notation""" + abs_val = abs(value) + if abs_val < 0.01 or abs_val > 9_999: + return f"{value:.2e}" + else: + return f"{value:.4f}" + + class _ImageTile(pygfx.Image): """ Similar to pygfx.Image, only difference is that it modifies the pick_info @@ -477,3 +486,16 @@ def add_polygon_selector( self._plot_area.add_graphic(selector, center=False) return selector + + def _fpl_tooltip_info_handler(self, ev: pygfx.PointerEvent) -> str: + col, row = ev.pick_info["index"] + if self.data.value.ndim == 2: + val = self.data[row, col] + info = f"{val:.4g}" + else: + info = "\n".join( + f"{channel}: {val:.4g}" + for channel, val in zip("rgba", self.data[row, col]) + ) + + return info diff --git a/fastplotlib/graphics/image_volume.py b/fastplotlib/graphics/image_volume.py index db616b30d..521b7c304 100644 --- a/fastplotlib/graphics/image_volume.py +++ b/fastplotlib/graphics/image_volume.py @@ -419,3 +419,16 @@ def reset_vmin_vmax(self): vmin, vmax = quick_min_max(self.data.value) self.vmin = vmin self.vmax = vmax + + def _fpl_tooltip_info_handler(self, ev: pygfx.PointerEvent) -> str: + col, row = ev.pick_info["index"] + if ev.graphic.data.value.ndim == 2: + val = ev.graphic.data[row, col] + info = f"{val:.4g}" + else: + info = "\n".join( + f"{channel}: {val:.4g}" + for channel, val in zip("rgba", ev.graphic.data[row, col]) + ) + + return info diff --git a/fastplotlib/graphics/line_collection.py b/fastplotlib/graphics/line_collection.py index 275cc1e47..beb4f9e13 100644 --- a/fastplotlib/graphics/line_collection.py +++ b/fastplotlib/graphics/line_collection.py @@ -325,6 +325,7 @@ def __init__( name=_name, metadata=_m, isolated_buffer=isolated_buffer, + create_tooltip=False, **kwargs_lines, ) @@ -544,6 +545,17 @@ def _get_linear_selector_init_args(self, axis, padding): return bounds, limits, size, center + def _fpl_tooltip_info_handler(self, ev: pygfx.PointerEvent) -> str: + index = ev.pick_info["vertex_index"] + + # get index of the hovered line within the line stack + line_index = np.where(self.graphics == ev.graphic)[0].item() + info = "\n".join( + f"{dim}: {val:.4g}" for dim, val in zip("xyz", ev.graphic.data[index]) + ) + + return f"line index: {line_index}\n\n{info}" + axes = {"x": 0, "y": 1, "z": 2} diff --git a/fastplotlib/graphics/mesh.py b/fastplotlib/graphics/mesh.py index 2e5a11851..c71611700 100644 --- a/fastplotlib/graphics/mesh.py +++ b/fastplotlib/graphics/mesh.py @@ -291,6 +291,23 @@ def plane(self, value: tuple[float, float, float, float]): self._plane.set_value(self, value) + def _fpl_tooltip_info_handler(self, ev: pygfx.PointerEvent) -> str: + pick_info = ev.pick_info + + # Get what face was clicked + face_index = pick_info["face_index"] + coords = pick_info["face_coord"] + # Select which of the three vertices was closest + # Note that you can also select all vertices for this face, + # or use the coords to select the closest edge. + sub_index = np.argmax(coords) + # Look up the vertex index + vertex_index = int(self.indices[face_index, sub_index]) + + info = "\n".join(f"{dim}: {val:.4g}" for dim, val in zip("xyz", self.positions[vertex_index])) + + return info + class SurfaceGraphic(MeshGraphic): _features = { diff --git a/fastplotlib/graphics/selectors/_base_selector.py b/fastplotlib/graphics/selectors/_base_selector.py index e4dbc890b..a3fe46821 100644 --- a/fastplotlib/graphics/selectors/_base_selector.py +++ b/fastplotlib/graphics/selectors/_base_selector.py @@ -182,7 +182,7 @@ def __init__( self._parent = parent - Graphic.__init__(self, **kwargs) + Graphic.__init__(self, create_tooltip=False, **kwargs) def get_selected_index(self): """Not implemented for this selector""" diff --git a/fastplotlib/graphics/text.py b/fastplotlib/graphics/text.py index 9f1aeb8af..ce5e692ec 100644 --- a/fastplotlib/graphics/text.py +++ b/fastplotlib/graphics/text.py @@ -71,7 +71,7 @@ def __init__( """ - super().__init__(**kwargs) + super().__init__(create_tooltip=False, **kwargs) self._text = TextData(text) self._font_size = FontSize(font_size) diff --git a/fastplotlib/layouts/_figure.py b/fastplotlib/layouts/_figure.py index d4c8a5948..494879ebd 100644 --- a/fastplotlib/layouts/_figure.py +++ b/fastplotlib/layouts/_figure.py @@ -453,7 +453,7 @@ def __init__( # overlay render pass self._overlay_camera = ScreenSpaceCamera() - self._overlay_scene = pygfx.Scene() + self._fpl_overlay_scene = pygfx.Scene() self._animate_funcs_pre: list[callable] = list() self._animate_funcs_post: list[callable] = list() @@ -544,7 +544,7 @@ def _render(self, draw=True): # overlay render pass if hasattr(self.renderer, "clear"): self.renderer.clear(depth=True) - self.renderer.render(self._overlay_scene, self._overlay_camera, flush=False) + self.renderer.render(self._fpl_overlay_scene, self._overlay_camera, flush=False) self.renderer.flush() diff --git a/fastplotlib/layouts/_imgui_figure.py b/fastplotlib/layouts/_imgui_figure.py index 046c622ea..d54be4086 100644 --- a/fastplotlib/layouts/_imgui_figure.py +++ b/fastplotlib/layouts/_imgui_figure.py @@ -44,7 +44,6 @@ def __init__( canvas_kwargs: dict = None, size: tuple[int, int] = (500, 300), names: list | np.ndarray = None, - show_tooltips: bool = False, ): self._guis: dict[str, EdgeWindow] = {k: None for k in GUI_EDGES} @@ -61,7 +60,6 @@ def __init__( canvas_kwargs=canvas_kwargs, size=size, names=names, - show_tooltips=show_tooltips, ) self._imgui_renderer = ImguiRenderer(self.renderer.device, self.canvas) diff --git a/fastplotlib/layouts/_plot_area.py b/fastplotlib/layouts/_plot_area.py index 307c7251e..4ac152d7c 100644 --- a/fastplotlib/layouts/_plot_area.py +++ b/fastplotlib/layouts/_plot_area.py @@ -88,6 +88,8 @@ def __init__( self._animate_funcs_pre: list[callable] = list() self._animate_funcs_post: list[callable] = list() + self._animate_funcs_persist: list[callable] = list() + # list of all graphics managed by this PlotArea self._graphics: list[Graphic] = list() @@ -388,14 +390,11 @@ def get_pick_info(self, pos): info = self.renderer.get_pick_info(pos) if info["world_object"] is not None: - try: - graphic = WORLD_OBJECT_TO_GRAPHIC[info["world_object"]._global_id] - info["graphic"] = graphic + # if this world object is owned by a graphic + if info["world_object"].id in WORLD_OBJECT_TO_GRAPHIC.keys(): + info["graphic"] = WORLD_OBJECT_TO_GRAPHIC[info["world_object"].id] return info - except KeyError: - pass # this world obj is not owned by a graphic - def _render(self): self._call_animate_functions(self._animate_funcs_pre) @@ -431,6 +430,7 @@ def add_animations( *funcs: callable, pre_render: bool = True, post_render: bool = False, + persist: bool = False, ): """ Add function(s) that are called on every render cycle. @@ -447,6 +447,10 @@ def add_animations( post_render: bool, default ``False``, optional keyword-only argument if true, these function(s) are called after a render cycle + persist: bool, default False + if True, the animation function will persist even if ``clear_animations()`` is called. + Such functions must be removed explicitly using ``remove_animation()`` + """ for f in funcs: if not callable(f): @@ -457,6 +461,8 @@ def add_animations( self._animate_funcs_pre += funcs if post_render: self._animate_funcs_post += funcs + if persist: + self._animate_funcs_persist += funcs def remove_animation(self, func): """ @@ -481,6 +487,9 @@ def remove_animation(self, func): if func in self._animate_funcs_post: self._animate_funcs_post.remove(func) + if func in self._animate_funcs_persist: + self._animate_funcs_persist.remove(func) + def clear_animations(self, removal: str = None): """ Remove animation functions. @@ -491,27 +500,37 @@ def clear_animations(self, removal: str = None): The type of animation functions to clear. One of 'pre' or 'post'. If `None`, removes all animation functions. """ + to_remove = list() + if removal is None: # remove all for func in self._animate_funcs_pre: - self._animate_funcs_pre.remove(func) + to_remove.append(func) for func in self._animate_funcs_post: - self._animate_funcs_post.remove(func) + to_remove.append(func) + elif removal == "pre": # only pre for func in self._animate_funcs_pre: - self._animate_funcs_pre.remove(func) + to_remove.append(func) + elif removal == "post": # only post for func in self._animate_funcs_post: - self._animate_funcs_post.remove(func) + to_remove.append(func) else: raise ValueError( f"Animation type: {removal} must be one of 'pre' or 'post'. To remove all animation " f"functions, pass `type=None`" ) + for func in to_remove: + if func in self._animate_funcs_persist: + # skip + continue + self.remove_animation(func) + def _sort_images_by_depth(self): """ In general, we want to avoid setting the offset of a graphic, because the @@ -628,10 +647,6 @@ def _add_or_insert_graphic( obj_list = self._graphics self._fpl_graphics_scene.add(graphic.world_object) - # add to tooltip registry - if self.get_figure().show_tooltips: - self.get_figure().tooltip_manager.register(graphic) - else: raise TypeError("graphic must be of type Graphic | BaseSelector | Legend") diff --git a/fastplotlib/tools/_cursor.py b/fastplotlib/tools/_cursor.py index 877a90d71..0b3b161cd 100644 --- a/fastplotlib/tools/_cursor.py +++ b/fastplotlib/tools/_cursor.py @@ -6,6 +6,7 @@ from ..layouts import Subplot from ..utils import RenderQueue +from ..graphics import GraphicTooltip class Cursor: @@ -56,6 +57,7 @@ def __init__( """ self._cursors: dict[Subplot, [pygfx.Points | pygfx.Group[pygfx.Line]]] = dict() + self._active_tooltips: dict[Subplot, GraphicTooltip] = dict() self._mode = None self.mode = mode @@ -210,7 +212,7 @@ def position(self) -> tuple[float, float]: @position.setter def position(self, pos: tuple[float, float]): - for cursor in self._cursors.values(): + for subplot, cursor in self._cursors.items(): if self.mode == "marker": cursor.geometry.positions.data[0, :-1] = pos @@ -237,6 +239,20 @@ def position(self, pos: tuple[float, float]): line_v.geometry.positions.update_full() + # set tooltip using pick info if a graphic is at this position + # for now we just set z = 1 + screen_pos = subplot.map_world_to_screen((*pos, 1)) + pick_info = subplot.get_pick_info(screen_pos) + if pick_info is not None: + graphic = pick_info["graphic"] + if graphic.tooltip is not None: # some graphics don't use tooltips, ex: Text + info = graphic.tooltip.format_event(pygfx.PointerEvent("cursor-pick", x=screen_pos[0], y=screen_pos[1], target=pick_info["world_object"], pick_info=pick_info)) + graphic.tooltip.display(screen_pos, info, space="screen") + self._active_tooltips[subplot] = graphic.tooltip + else: + if self._active_tooltips[subplot] is not None: + self._active_tooltips[subplot].visible = False + self._position[:] = pos def add_subplot(self, subplot: Subplot): @@ -256,6 +272,7 @@ def add_subplot(self, subplot: Subplot): ) self._cursors[subplot] = cursor + self._active_tooltips[subplot] = None def remove_subplot(self, subplot: Subplot): """remove cursor from subplot""" @@ -263,6 +280,7 @@ def remove_subplot(self, subplot: Subplot): raise KeyError("cursor not in given supblot") subplot.scene.remove(self._cursors.pop(subplot)) + self._active_tooltips.pop(subplot) def clear(self): """remove from all subplots""" diff --git a/fastplotlib/tools/_histogram_lut.py b/fastplotlib/tools/_histogram_lut.py index 7507a7ff2..f5c192729 100644 --- a/fastplotlib/tools/_histogram_lut.py +++ b/fastplotlib/tools/_histogram_lut.py @@ -60,7 +60,7 @@ def __init__( kwargs: passed to ``Graphic`` """ - super().__init__(**kwargs) + super().__init__(create_tooltip, **kwargs) self._nbins = nbins self._flank_divisor = flank_divisor From 9323e3a82720dd65930451638b70fa4a6bcd7fa3 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 7 Dec 2025 02:07:24 -0500 Subject: [PATCH 10/34] much simpler tooltip and cursor --- examples/mesh/surface_ripple.py | 3 + examples/misc/cursor_custom_tooltip.py | 61 ------ examples/misc/cursor_manual.py | 49 +++++ examples/misc/tooltips_custom.py | 14 +- fastplotlib/graphics/__init__.py | 8 +- fastplotlib/graphics/_base.py | 184 +++--------------- fastplotlib/graphics/_collection_base.py | 2 + fastplotlib/graphics/_positions_base.py | 4 +- fastplotlib/graphics/_tooltip.py | 33 +++- fastplotlib/graphics/_vectors.py | 4 +- fastplotlib/graphics/image.py | 4 +- fastplotlib/graphics/image_volume.py | 4 +- fastplotlib/graphics/line_collection.py | 12 -- fastplotlib/graphics/mesh.py | 10 +- .../graphics/selectors/_base_selector.py | 4 +- fastplotlib/graphics/text.py | 4 +- fastplotlib/layouts/_figure.py | 47 ++--- fastplotlib/layouts/_plot_area.py | 71 ++++++- fastplotlib/tools/_cursor.py | 62 +++--- fastplotlib/tools/_histogram_lut.py | 4 +- 20 files changed, 280 insertions(+), 304 deletions(-) delete mode 100644 examples/misc/cursor_custom_tooltip.py create mode 100644 examples/misc/cursor_manual.py diff --git a/examples/mesh/surface_ripple.py b/examples/mesh/surface_ripple.py index ac556bd1b..1adf676ea 100644 --- a/examples/mesh/surface_ripple.py +++ b/examples/mesh/surface_ripple.py @@ -34,6 +34,9 @@ def create_ripple(shape=(100, 100), phase=0.0, freq=np.pi / 4, ampl=1.0): z, mode="basic", cmap="viridis", clim=(-max_z, max_z) ) +# enable continuous updates for the tooltip +figure[0, 0].tooltip.continuous_update = True + figure[0, 0].camera.show_object(surface.world_object, (-1, 3, -1), up=(0, 0, 1)) figure.show() diff --git a/examples/misc/cursor_custom_tooltip.py b/examples/misc/cursor_custom_tooltip.py deleted file mode 100644 index 5cb83bdc3..000000000 --- a/examples/misc/cursor_custom_tooltip.py +++ /dev/null @@ -1,61 +0,0 @@ -""" -Cursor tool with tooltips -========================= - -Cursor tool example that also displays tooltips -""" - -import numpy as np -import fastplotlib as fpl -import imageio.v3 as iio -from pylinalg import vec_transform, mat_combine - -img1 = iio.imread("imageio:camera.png") -img2 = iio.imread("imageio:astronaut.png") - -scatter_data = np.random.normal(loc=256, scale=(50), size=(500)).reshape(250, 2) -line_data = np.random.rand(100, 2) * 512 - -figure = fpl.Figure(shape=(2, 2), size=(700, 800)) - -img = figure[0, 0].add_image(img1, cmap="viridis") -figure[0, 1].add_image(img2, metadata=np.arange(512)) -figure[1, 0].add_scatter(scatter_data, sizes=5, metadata="scatter metadata") -figure[1, 1].add_line(line_data, metadata="line metadata") - -cursor = fpl.Cursor(mode="crosshair", color="w") - -for subplot in figure: - cursor.add_subplot(subplot) - -figure.show_tooltips = True - -tooltips2 = fpl.Tooltip() -tooltips2.world_object.visible = True -figure.add_tooltip(tooltips2) - -@figure.renderer.add_event_handler("pointer_move") -def update(ev): - pos = figure[0, 0].map_screen_to_world(ev) - if pos is None: - return - - x, y = figure[0, 1].map_world_to_screen(pos) - pick = subplot.get_pick_info((x, y)) - - if pick is None: - tooltips2.visible = False - return - print(pick) - info = pick["graphic"].metadata[pick["index"][1]] - tooltips2.display((x, y), str(info)) - -print((img.world_object.children[0].uniform_buffer.data["global_id"]).item()) -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/misc/cursor_manual.py b/examples/misc/cursor_manual.py new file mode 100644 index 000000000..7b328e99e --- /dev/null +++ b/examples/misc/cursor_manual.py @@ -0,0 +1,49 @@ +""" +Cursor transform +================ + +Create a cursor and add them to subplots with a transform function. An example usecase is image registration. +""" + +# test_example = False +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl +import imageio.v3 as iio + + +# get an image +img1 = iio.imread("imageio:camera.png") + +# create another image, but it is offset +img2 = np.zeros(img1.shape) +img2[50:, 20:] = img1[:-50, :-20] + +figure = fpl.Figure((1, 2)) + +# add images +figure[0, 0].add_image(img1) +figure[0, 1].add_image(img2) + +# create cursor +cursor = fpl.Cursor("crosshair") + +# add first subplot to cursor +cursor.add_subplot(figure[0, 0]) + +# a transform function for subplot 2 to indicate that the data is shifted +def transform_func(pos): + return (pos[0] + 20, pos[1] + 50) + +# add second subplot with a transform +cursor.add_subplot(figure[0, 1], transform=transform_func) + +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/misc/tooltips_custom.py b/examples/misc/tooltips_custom.py index d1cc1e297..3a54a945b 100644 --- a/examples/misc/tooltips_custom.py +++ b/examples/misc/tooltips_custom.py @@ -31,20 +31,26 @@ ) -def tooltip_info(ev) -> str: +def tooltip_info(pick_info: dict) -> str: # get index of the scatter point that is being hovered - index = ev.pick_info["vertex_index"] + index = pick_info["vertex_index"] # get the species name target = dataset["target"][index] cluster = agg.labels_[index] - info = f"species: {dataset['target_names'][target]}\ncluster: {cluster}" + + # the default formatting of the pick info + default_info = scatter.format_pick_info(pick_info) + + info = (f"species: {dataset['target_names'][target]}\n" + f"cluster: {cluster}\n\n" + f"{default_info}") # return this string to display it in the tooltip return info -figure.tooltip_manager.register(scatter, custom_info=tooltip_info) +scatter.tooltip_format = tooltip_info figure.show() diff --git a/fastplotlib/graphics/__init__.py b/fastplotlib/graphics/__init__.py index a09dc9641..d4a51ce16 100644 --- a/fastplotlib/graphics/__init__.py +++ b/fastplotlib/graphics/__init__.py @@ -1,5 +1,4 @@ -from ._tooltip import Tooltip -from ._base import Graphic, GraphicTooltip +from ._base import Graphic from .line import LineGraphic from .scatter import ScatterGraphic from .image import ImageGraphic @@ -8,11 +7,10 @@ from .mesh import MeshGraphic, SurfaceGraphic, PolygonGraphic from .text import TextGraphic from .line_collection import LineCollection, LineStack +from ._tooltip import TextBox, Tooltip __all__ = [ - "Tooltip", - "GraphicTooltip", "Graphic", "LineGraphic", "ScatterGraphic", @@ -25,4 +23,6 @@ "TextGraphic", "LineCollection", "LineStack", + "TextBox", + "Tooltip", ] diff --git a/fastplotlib/graphics/_base.py b/fastplotlib/graphics/_base.py index c60bdf55c..285b1cc6b 100644 --- a/fastplotlib/graphics/_base.py +++ b/fastplotlib/graphics/_base.py @@ -29,7 +29,6 @@ Visible, ) from ._axes import Axes -from ._tooltip import Tooltip HexStr: TypeAlias = str WorldObjectID: TypeAlias = int @@ -62,6 +61,11 @@ class Graphic: _features: dict[str, type] = dict() + # It also doesn't make sense to create tooltips for some graphics + # ex: text, that would be very funny. + # They would also get in the way of selector tools + _fpl_support_tooltip: bool = True + def __init_subclass__(cls, **kwargs): # set of all features @@ -88,7 +92,6 @@ def __init__( alpha_mode: str = "auto", visible: bool = True, metadata: Any = None, - create_tooltip: bool = True, ): """ @@ -183,16 +186,7 @@ def __init__( # store ids of all the WorldObjects that this Graphic manages/uses self._world_object_ids = list() - # TODO: this exists for LineCollections since we don't want to create - # thousands or hundreds of tooltip objects and meshes etc. for each line, - # the GraphicCollection handles one tooltip instance instead. Once we - # refactor GraphicCollection we can make this nicer - # It also doesn't make sense to create tooltips for text, that would be very funny - # similarly they would probably not be useful for selector tools - if create_tooltip: - self._tooltip = GraphicTooltip(self) - else: - self._tooltip = None + self._tooltip_format: Callable = None @property def supported_events(self) -> tuple[str]: @@ -324,9 +318,25 @@ def _set_world_object(self, wo: pygfx.WorldObject): self.rotation = self.rotation @property - def tooltip(self) -> GraphicTooltip: - """tooltip for this graphic""" - return self._tooltip + def tooltip_format(self) -> Callable[[dict], str] | None: + """ + set a custom tooltip format function which takes a ``pick_info`` dict and + returns a str to be displayed in the tooltip + """ + return self._tooltip_format + + @tooltip_format.setter + def tooltip_format(self, func: Callable[[dict], str] | None): + if func is None: + self._tooltip_format = None + return + + if not callable(func): + raise TypeError( + f"`tooltip_format` must be set with a callable that takes a pick_info dict, or it can be set as None" + ) + + self._tooltip_format = func @property def event_handlers(self) -> list[tuple[str, callable, ...]]: @@ -537,7 +547,7 @@ def map_world_to_model(self, position: tuple[float, float, float] | tuple[float, return la.vec_transform(position, self.world_object.world.inverse_matrix) - def _fpl_tooltip_info_handler(self, ev: pygfx.PointerEvent) -> str: + def format_pick_info(self, ev: pygfx.PointerEvent) -> str: """ Takes a pygfx.PointerEvent and returns formatted pick info. """ @@ -547,9 +557,6 @@ def _fpl_tooltip_info_handler(self, ev: pygfx.PointerEvent) -> str: def _fpl_add_plot_area_hook(self, plot_area): self._plot_area = plot_area - if self._tooltip is not None: - self._tooltip._fpl_add_plot_area_hook(plot_area) - def __repr__(self): rval = f"{self.__class__.__name__}" if self.name is not None: @@ -672,142 +679,3 @@ def _fpl_request_right_click_menu(self): def _fpl_close_right_click_menu(self): pass - - -class GraphicTooltip(Tooltip): - def __init__(self, graphic: Graphic): - self._graphic = graphic - self._plot_area = None - - self._info_handler: Callable = None - - self._enabled = True - - super().__init__() - - @property - def enabled(self) -> bool: - """enable or disable tooltips for this graphic""" - return self._enabled - - @enabled.setter - def enabled(self, enable: bool): - self._enabled = bool(enable) - - if not self._enabled: - self.visible = False - - @property - def info_handler(self) -> None | Callable: - """get or set a custom handler for setting the tooltip info""" - return self._info_handler - - @info_handler.setter - def info_handler(self, func: Callable | None): - if func is None: - self._info_handler = None - return - - if not callable(func): - raise TypeError( - f"`info_handler` must be set with a callable that takes a pointer event, or it can be set as None" - ) - - self._info_handler = func - - def display(self, position: tuple[float, float, float] | tuple[float, float], info: str = None, space: Literal["model", "world", "screen"] = "model"): - """ - display tooltip at the given position in the given space - - Parameters - ---------- - position: (float, float, float) or (float, float) - (x, y, z) or (x, y) position in **model space** - - info: str - text to display in the tooltip - - space: Literal["model", "world", "screen"], default "model" - interpret the ``position`` as being in this space - - """ - if not self.enabled: - return - - if space == "model": - world_pos = self._graphic.map_model_to_world(position) - screen_pos = self._plot_area.map_world_to_screen(world_pos) - - elif space == "world": - screen_pos = self._plot_area.map_world_to_screen(world_pos) - - elif space == "screen": - screen_pos = position - - else: - raise ValueError(f"`space` must be one of: 'model', 'world', or 'screen', you passed: {space}") - - if info is None: - # auto fetch pick info - pick_info = self._plot_area.get_pick_info(screen_pos) - - # if it is None return, the graphic is moved away from this position - if pick_info is None: - return - - # simulate event at this screen position, pass through graphic's formatter - info = self.format_event( - pygfx.PointerEvent("tooltip-pick", x=screen_pos[0], y=screen_pos[1], pick_info=pick_info)) - - super().display(screen_pos, info) - - def _fpl_auto_update_render(self): - # auto-updates the tooltip on every render so it is always accurate - # if the data under the graphic changes at this position, then it will update the text - if self.visible: - self.display( - position=self.position, - space="screen", - ) - - def _fpl_add_plot_area_hook(self, plot_area): - self._plot_area = plot_area - - # add to overlay scene - self._plot_area.get_figure()._fpl_overlay_scene.add(self._fpl_world_object) - - # this makes the tooltip info auto-update on every render - self._plot_area.add_animations(self._fpl_auto_update_render, post_render="True") - - # connect events - self._graphic.add_event_handler(self._pointer_move_handler, "pointer_move") - self._graphic.add_event_handler(self._pointer_leave_handler, "pointer_leave") - - def _fpl_prepare_del(self): - # remove from overlay scene - self._plot_area.get_figure()._fpl_overlay_scene.remove(self._fpl_world_object) - - # remove animation func - self._plot_area.remove_animation(self._fpl_auto_update_render) - - def format_event(self, ev): - # format pick info - info = self._graphic._fpl_tooltip_info_handler(ev) - - if self.info_handler is not None: - info = self.info_handler(ev, info) - - return info - - def _pointer_move_handler(self, ev: pygfx.PointerEvent): - if not self.enabled: - return - - info = self.format_event(ev) - - # IMPORTANT: call display() of superclass class, NOT this class, - # since the pointer event already has the screen space (x, y) - super().display((ev.x, ev.y), info) - - def _pointer_leave_handler(self, ev): - self.clear() diff --git a/fastplotlib/graphics/_collection_base.py b/fastplotlib/graphics/_collection_base.py index 89bf387bb..5b1fd87f1 100644 --- a/fastplotlib/graphics/_collection_base.py +++ b/fastplotlib/graphics/_collection_base.py @@ -181,6 +181,8 @@ class GraphicCollection(Graphic, CollectionProperties): _child_type: type _indexer: type + # tooltips will come from the child graphics + _fpl_support_tooltip = False def __init_subclass__(cls, **kwargs): super().__init_subclass__(**kwargs) diff --git a/fastplotlib/graphics/_positions_base.py b/fastplotlib/graphics/_positions_base.py index 6badb089e..af7d7badb 100644 --- a/fastplotlib/graphics/_positions_base.py +++ b/fastplotlib/graphics/_positions_base.py @@ -147,8 +147,8 @@ def __init__( self._size_space = SizeSpace(size_space) super().__init__(*args, **kwargs) - def _fpl_tooltip_info_handler(self, ev: pygfx.PointerEvent) -> str: - index = ev.pick_info["vertex_index"] + def format_pick_info(self, pick_info: dict) -> str: + index = pick_info["vertex_index"] info = "\n".join( f"{dim}: {val:.4g}" for dim, val in zip("xyz", self.data[index]) ) diff --git a/fastplotlib/graphics/_tooltip.py b/fastplotlib/graphics/_tooltip.py index a54623ca1..276024a7f 100644 --- a/fastplotlib/graphics/_tooltip.py +++ b/fastplotlib/graphics/_tooltip.py @@ -50,7 +50,7 @@ class MeshMasks: masks = MeshMasks -class Tooltip: +class TextBox: def __init__(self): # text object self._text = pygfx.Text( @@ -210,6 +210,9 @@ def _draw_tooltip(self, pos: tuple[float, float]): position in screen space """ + if np.array_equal(self.position, pos): + return + # need to flip due to inverted y x, y = pos[0], pos[1] @@ -238,3 +241,31 @@ def _draw_tooltip(self, pos: tuple[float, float]): def clear(self, *args): self._text.set_text("") self._fpl_world_object.visible = False + + +class Tooltip(TextBox): + def __init__(self): + super().__init__() + self._enabled: bool = True + self._continuous_update = False + self.visible = False + + @property + def enabled(self) -> bool: + return self._enabled + + @enabled.setter + def enabled(self, value: bool): + self._enabled = bool(value) + + if not self.enabled: + self.visible = False + + @property + def continuous_update(self) -> bool: + """update the tooltip on every render""" + return self._continuous_update + + @continuous_update.setter + def continuous_update(self, value: bool): + self._continuous_update = bool(value) diff --git a/fastplotlib/graphics/_vectors.py b/fastplotlib/graphics/_vectors.py index c0bf143ac..bd4f25dd8 100644 --- a/fastplotlib/graphics/_vectors.py +++ b/fastplotlib/graphics/_vectors.py @@ -170,8 +170,8 @@ def directions(self) -> VectorDirections: def directions(self, new_directions): self._directions.set_value(self, new_directions) - def _fpl_tooltip_info_handler(self, ev: pygfx.PointerEvent) -> str: - index = ev.pick_info["instance_index"] + def format_pick_info(self, pick_info: dict) -> str: + index = pick_info["instance_index"] info = (f"position: {self.positions[index]}\n" f"direction: {self.directions[index]}") diff --git a/fastplotlib/graphics/image.py b/fastplotlib/graphics/image.py index 3aed2a2bb..ece700385 100644 --- a/fastplotlib/graphics/image.py +++ b/fastplotlib/graphics/image.py @@ -487,8 +487,8 @@ def add_polygon_selector( return selector - def _fpl_tooltip_info_handler(self, ev: pygfx.PointerEvent) -> str: - col, row = ev.pick_info["index"] + def format_pick_info(self, pick_info: dict) -> str: + col, row = pick_info["index"] if self.data.value.ndim == 2: val = self.data[row, col] info = f"{val:.4g}" diff --git a/fastplotlib/graphics/image_volume.py b/fastplotlib/graphics/image_volume.py index 521b7c304..6656c874f 100644 --- a/fastplotlib/graphics/image_volume.py +++ b/fastplotlib/graphics/image_volume.py @@ -420,8 +420,8 @@ def reset_vmin_vmax(self): self.vmin = vmin self.vmax = vmax - def _fpl_tooltip_info_handler(self, ev: pygfx.PointerEvent) -> str: - col, row = ev.pick_info["index"] + def format_pick_info(self, pick_info: dict) -> str: + col, row = pick_info["index"] if ev.graphic.data.value.ndim == 2: val = ev.graphic.data[row, col] info = f"{val:.4g}" diff --git a/fastplotlib/graphics/line_collection.py b/fastplotlib/graphics/line_collection.py index beb4f9e13..275cc1e47 100644 --- a/fastplotlib/graphics/line_collection.py +++ b/fastplotlib/graphics/line_collection.py @@ -325,7 +325,6 @@ def __init__( name=_name, metadata=_m, isolated_buffer=isolated_buffer, - create_tooltip=False, **kwargs_lines, ) @@ -545,17 +544,6 @@ def _get_linear_selector_init_args(self, axis, padding): return bounds, limits, size, center - def _fpl_tooltip_info_handler(self, ev: pygfx.PointerEvent) -> str: - index = ev.pick_info["vertex_index"] - - # get index of the hovered line within the line stack - line_index = np.where(self.graphics == ev.graphic)[0].item() - info = "\n".join( - f"{dim}: {val:.4g}" for dim, val in zip("xyz", ev.graphic.data[index]) - ) - - return f"line index: {line_index}\n\n{info}" - axes = {"x": 0, "y": 1, "z": 2} diff --git a/fastplotlib/graphics/mesh.py b/fastplotlib/graphics/mesh.py index c71611700..f072b63ca 100644 --- a/fastplotlib/graphics/mesh.py +++ b/fastplotlib/graphics/mesh.py @@ -291,9 +291,7 @@ def plane(self, value: tuple[float, float, float, float]): self._plane.set_value(self, value) - def _fpl_tooltip_info_handler(self, ev: pygfx.PointerEvent) -> str: - pick_info = ev.pick_info - + def format_pick_info(self, pick_info: dict) -> str: # Get what face was clicked face_index = pick_info["face_index"] coords = pick_info["face_coord"] @@ -302,7 +300,11 @@ def _fpl_tooltip_info_handler(self, ev: pygfx.PointerEvent) -> str: # or use the coords to select the closest edge. sub_index = np.argmax(coords) # Look up the vertex index - vertex_index = int(self.indices[face_index, sub_index]) + try: + vertex_index = int(self.indices[face_index, sub_index]) + except IndexError: + # if vertex buffer sizes change then the pointer event can have outdated pick info? + return "error, buffer size changed" info = "\n".join(f"{dim}: {val:.4g}" for dim, val in zip("xyz", self.positions[vertex_index])) diff --git a/fastplotlib/graphics/selectors/_base_selector.py b/fastplotlib/graphics/selectors/_base_selector.py index a3fe46821..28c6534a7 100644 --- a/fastplotlib/graphics/selectors/_base_selector.py +++ b/fastplotlib/graphics/selectors/_base_selector.py @@ -40,6 +40,8 @@ class MoveInfo: # Selector base class class BaseSelector(Graphic): + _fpl_support_tooltip = False + @property def axis(self) -> str: return self._axis @@ -182,7 +184,7 @@ def __init__( self._parent = parent - Graphic.__init__(self, create_tooltip=False, **kwargs) + Graphic.__init__(self, **kwargs) def get_selected_index(self): """Not implemented for this selector""" diff --git a/fastplotlib/graphics/text.py b/fastplotlib/graphics/text.py index ce5e692ec..37e559576 100644 --- a/fastplotlib/graphics/text.py +++ b/fastplotlib/graphics/text.py @@ -21,6 +21,8 @@ class TextGraphic(Graphic): "outline_thickness": TextOutlineThickness, } + _fpl_support_tooltip = False + def __init__( self, text: str, @@ -71,7 +73,7 @@ def __init__( """ - super().__init__(create_tooltip=False, **kwargs) + super().__init__(**kwargs) self._text = TextData(text) self._font_size = FontSize(font_size) diff --git a/fastplotlib/layouts/_figure.py b/fastplotlib/layouts/_figure.py index 494879ebd..79b5be3a8 100644 --- a/fastplotlib/layouts/_figure.py +++ b/fastplotlib/layouts/_figure.py @@ -123,6 +123,29 @@ def __init__( subplot names """ + # create canvas and renderer + if canvas_kwargs is not None: + if size not in canvas_kwargs.keys(): + canvas_kwargs["size"] = size + else: + canvas_kwargs = {"size": size, "max_fps": 60.0, "vsync": True} + + canvas, renderer = make_canvas_and_renderer( + canvas, renderer, canvas_kwargs=canvas_kwargs + ) + + canvas.add_event_handler(self._fpl_reset_layout, "resize") + + self._canvas = canvas + self._renderer = renderer + + # underlay render pass + self._underlay_camera = ScreenSpaceCamera() + self._underlay_scene = pygfx.Scene() + + # overlay render pass + self._overlay_camera = ScreenSpaceCamera() + self._fpl_overlay_scene = pygfx.Scene() if rects is not None: if not all(isinstance(v, (np.ndarray, tuple, list)) for v in rects): @@ -197,18 +220,6 @@ def __init__( else: subplot_names = None - if canvas_kwargs is not None: - if size not in canvas_kwargs.keys(): - canvas_kwargs["size"] = size - else: - canvas_kwargs = {"size": size, "max_fps": 60.0, "vsync": True} - - canvas, renderer = make_canvas_and_renderer( - canvas, renderer, canvas_kwargs=canvas_kwargs - ) - - canvas.add_event_handler(self._fpl_reset_layout, "resize") - if isinstance(cameras, str): # create the array representing the views for each subplot in the grid cameras = np.array([cameras] * n_subplots) @@ -387,9 +398,6 @@ def __init__( for cam in cams[1:]: _controller.add_camera(cam) - self._canvas = canvas - self._renderer = renderer - if layout_mode == "grid": n_rows, n_cols = shape grid_index_iterator = list(product(range(n_rows), range(n_cols))) @@ -444,17 +452,10 @@ def __init__( canvas_rect=self.get_pygfx_render_area(), ) - # underlay render pass - self._underlay_camera = ScreenSpaceCamera() - self._underlay_scene = pygfx.Scene() - + # add subplot frames to underlay for subplot in self._subplots.ravel(): self._underlay_scene.add(subplot.frame._world_object) - # overlay render pass - self._overlay_camera = ScreenSpaceCamera() - self._fpl_overlay_scene = pygfx.Scene() - self._animate_funcs_pre: list[callable] = list() self._animate_funcs_post: list[callable] = list() diff --git a/fastplotlib/layouts/_plot_area.py b/fastplotlib/layouts/_plot_area.py index 4ac152d7c..7d8aacb76 100644 --- a/fastplotlib/layouts/_plot_area.py +++ b/fastplotlib/layouts/_plot_area.py @@ -10,7 +10,7 @@ from ._utils import create_controller from ..graphics._base import Graphic, WORLD_OBJECT_TO_GRAPHIC -from ..graphics import ImageGraphic +from ..graphics import ImageGraphic, Tooltip from ..graphics.selectors._base_selector import BaseSelector from ._graphic_methods_mixin import GraphicMethodsMixin from ..legends import Legend @@ -125,6 +125,10 @@ def __init__( self.scene.add(self._ambient_light) self.scene.add(self._camera.add(self._directional_light)) + self._tooltip = Tooltip() + self.get_figure()._fpl_overlay_scene.add(self._tooltip._fpl_world_object) + self.renderer.add_event_handler(self._fpl_set_tooltip, "pointer_move") + def get_figure(self, obj=None): """Get Figure instance that contains this plot area""" if obj is None: @@ -299,6 +303,11 @@ def animations(self) -> dict[str, list[callable]]: """Returns a dictionary of 'pre' and 'post' animation functions.""" return {"pre": self._animate_funcs_pre, "post": self._animate_funcs_post} + @property + def tooltip(self) -> Tooltip: + """The tooltip in this PlotArea""" + return self._tooltip + def map_screen_to_world( self, pos: tuple[float, float] | pygfx.PointerEvent, allow_outside: bool = False ) -> np.ndarray | None: @@ -395,6 +404,63 @@ def get_pick_info(self, pos): info["graphic"] = WORLD_OBJECT_TO_GRAPHIC[info["world_object"].id] return info + def _fpl_set_tooltip(self, ev: pygfx.PointerEvent): + # set tooltip using pointer position + if not self._tooltip.enabled: + return + + # is pointer in this plot area + if not self.viewport.is_inside(ev.x, ev.y): + return + + # is there a world object under the pointer + if ev.target is not None: + # is it owned by a graphic + if ev.target.id in WORLD_OBJECT_TO_GRAPHIC.keys(): + graphic = WORLD_OBJECT_TO_GRAPHIC[ev.target.id] + if not graphic._fpl_support_tooltip: + return + + pick_info = ev.pick_info + if graphic.tooltip_format is not None: + # custom formatter + info = graphic.tooltip_format(pick_info) + else: + # default formatter for this graphic + info = graphic.format_pick_info(pick_info) + self._tooltip.display((ev.x, ev.y), info) + return + + # not over a graphic that supports tooltips + self._tooltip.clear() + + def _fpl_update_tooltip_render(self): + # update tooltip on every render + # TODO: improve performance + if (not self._tooltip.visible) or (not self._tooltip.enabled): + return + + pick_info = self.get_pick_info(self._tooltip.position) + + # None if no graphic is at this position + if pick_info is not None: + graphic = pick_info["graphic"] + if graphic._fpl_support_tooltip: + if graphic.tooltip_format is not None: + # custom formatter + info = graphic.tooltip_format(pick_info) + else: + # default formatter for this graphic + info = graphic.format_pick_info(pick_info) + self._tooltip.display( + self._tooltip.position, + info + ) + return + + # tooltip cleared if none of the above condiitionals reached the tooltip display call + self._tooltip.clear() + def _render(self): self._call_animate_functions(self._animate_funcs_pre) @@ -406,6 +472,9 @@ def _render(self): self._call_animate_functions(self._animate_funcs_post) + if self._tooltip.continuous_update: + self._fpl_update_tooltip_render() + def _call_animate_functions(self, funcs: list[callable]): for fn in funcs: try: diff --git a/fastplotlib/tools/_cursor.py b/fastplotlib/tools/_cursor.py index 0b3b161cd..7538b7021 100644 --- a/fastplotlib/tools/_cursor.py +++ b/fastplotlib/tools/_cursor.py @@ -1,12 +1,11 @@ from functools import partial -from typing import Literal, Sequence +from typing import Literal, Sequence, Callable import numpy as np import pygfx from ..layouts import Subplot from ..utils import RenderQueue -from ..graphics import GraphicTooltip class Cursor: @@ -57,7 +56,7 @@ def __init__( """ self._cursors: dict[Subplot, [pygfx.Points | pygfx.Group[pygfx.Line]]] = dict() - self._active_tooltips: dict[Subplot, GraphicTooltip] = dict() + self._transforms: dict[Subplot, [Callable | None]] = dict() self._mode = None self.mode = mode @@ -213,52 +212,66 @@ def position(self) -> tuple[float, float]: @position.setter def position(self, pos: tuple[float, float]): for subplot, cursor in self._cursors.items(): + if self._transforms[subplot] is not None: + pos_transformed = self._transforms[subplot](pos) + else: + pos_transformed = pos if self.mode == "marker": - cursor.geometry.positions.data[0, :-1] = pos + cursor.geometry.positions.data[0, :-1] = pos_transformed cursor.geometry.positions.update_full() elif self.mode == "crosshair": line_h, line_v = cursor.children # set x vals for horizontal line - line_h.geometry.positions.data[0, 0] = pos[0] - 1 + line_h.geometry.positions.data[0, 0] = pos_transformed[0] - 1 line_h.geometry.positions.data[1, 0] = pos[0] + 1 # set y value - line_h.geometry.positions.data[:, 1] = pos[1] + line_h.geometry.positions.data[:, 1] = pos_transformed[1] line_h.geometry.positions.update_full() # set y vals for vertical line - line_v.geometry.positions.data[0, 1] = pos[1] - 1 - line_v.geometry.positions.data[1, 1] = pos[1] + 1 + line_v.geometry.positions.data[0, 1] = pos_transformed[1] - 1 + line_v.geometry.positions.data[1, 1] = pos_transformed[1] + 1 # set x value - line_v.geometry.positions.data[:, 0] = pos[0] + line_v.geometry.positions.data[:, 0] = pos_transformed[0] line_v.geometry.positions.update_full() # set tooltip using pick info if a graphic is at this position # for now we just set z = 1 - screen_pos = subplot.map_world_to_screen((*pos, 1)) + screen_pos = subplot.map_world_to_screen((*pos_transformed, 1)) pick_info = subplot.get_pick_info(screen_pos) - if pick_info is not None: - graphic = pick_info["graphic"] - if graphic.tooltip is not None: # some graphics don't use tooltips, ex: Text - info = graphic.tooltip.format_event(pygfx.PointerEvent("cursor-pick", x=screen_pos[0], y=screen_pos[1], target=pick_info["world_object"], pick_info=pick_info)) - graphic.tooltip.display(screen_pos, info, space="screen") - self._active_tooltips[subplot] = graphic.tooltip - else: - if self._active_tooltips[subplot] is not None: - self._active_tooltips[subplot].visible = False - self._position[:] = pos + self._position[:] = pos_transformed - def add_subplot(self, subplot: Subplot): - """add this cursor to a subplot""" + if pick_info is not None: + graphic = pick_info["graphic"] + if graphic._fpl_support_tooltip: # some graphics don't support tooltips, ex: Text + if graphic.tooltip_format is not None: + # custom formatter + info = graphic.tooltip_format + else: + # default formatter for this graphic + info = graphic.format_pick_info(pick_info) + + subplot.tooltip.display(screen_pos, info) + continue + + # tooltip cleared if none of the above condiitionals reached the tooltip display call + subplot.tooltip.clear() + + def add_subplot(self, subplot: Subplot, transform: Callable | None = None): + """add this cursor to a subplot, with an optional position transform function""" if subplot in self._cursors.keys(): - raise KeyError + raise KeyError(f"The given subplot has already been added to this cursor") + + if (not callable(transform)) and (transform is not None): + raise TypeError(f"`transform` must be a callable or `None`, you passed: {transform}") if self.mode == "marker": cursor = self._create_marker() @@ -272,7 +285,7 @@ def add_subplot(self, subplot: Subplot): ) self._cursors[subplot] = cursor - self._active_tooltips[subplot] = None + self._transforms[subplot] = transform def remove_subplot(self, subplot: Subplot): """remove cursor from subplot""" @@ -280,7 +293,6 @@ def remove_subplot(self, subplot: Subplot): raise KeyError("cursor not in given supblot") subplot.scene.remove(self._cursors.pop(subplot)) - self._active_tooltips.pop(subplot) def clear(self): """remove from all subplots""" diff --git a/fastplotlib/tools/_histogram_lut.py b/fastplotlib/tools/_histogram_lut.py index f5c192729..d651137da 100644 --- a/fastplotlib/tools/_histogram_lut.py +++ b/fastplotlib/tools/_histogram_lut.py @@ -27,6 +27,8 @@ def _get_image_graphic_events(image_graphic: ImageGraphic) -> list[str]: # TODO: This is a widget, we can think about a BaseWidget class later if necessary class HistogramLUTTool(Graphic): + _fpl_support_tooltip = False + def __init__( self, data: np.ndarray, @@ -60,7 +62,7 @@ def __init__( kwargs: passed to ``Graphic`` """ - super().__init__(create_tooltip, **kwargs) + super().__init__(**kwargs) self._nbins = nbins self._flank_divisor = flank_divisor From a59f6867a9ce0f81dfe1a20167f687863aa7d5e2 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 7 Dec 2025 02:17:54 -0500 Subject: [PATCH 11/34] move TextBox and Tooltip, update API docs --- docs/source/api/graphic_features/Scale.rst | 35 ++++ docs/source/api/graphic_features/index.rst | 1 + docs/source/api/graphics/Graphic.rst | 5 + docs/source/api/graphics/ImageGraphic.rst | 5 + .../api/graphics/ImageVolumeGraphic.rst | 5 + docs/source/api/graphics/LineCollection.rst | 5 + docs/source/api/graphics/LineGraphic.rst | 5 + docs/source/api/graphics/LineStack.rst | 5 + docs/source/api/graphics/MeshGraphic.rst | 5 + docs/source/api/graphics/PolygonGraphic.rst | 5 + docs/source/api/graphics/ScatterGraphic.rst | 5 + docs/source/api/graphics/SurfaceGraphic.rst | 5 + docs/source/api/graphics/TextGraphic.rst | 5 + docs/source/api/graphics/VectorsGraphic.rst | 5 + docs/source/api/layouts/figure.rst | 2 - docs/source/api/layouts/imgui_figure.rst | 2 - docs/source/api/layouts/subplot.rst | 3 + .../api/selectors/LinearRegionSelector.rst | 5 + docs/source/api/selectors/LinearSelector.rst | 5 + .../api/selectors/RectangleSelector.rst | 5 + docs/source/api/tools/GraphicTooltip.rst | 40 ----- docs/source/api/tools/HistogramLUTTool.rst | 5 + docs/source/api/tools/TextBox.rst | 38 +++++ docs/source/api/tools/Tooltip.rst | 5 +- docs/source/api/tools/index.rst | 2 +- docs/source/user_guide/event_tables.rst | 154 ++++++++++++++++++ fastplotlib/graphics/__init__.py | 3 - fastplotlib/layouts/_plot_area.py | 3 +- fastplotlib/tools/__init__.py | 3 + .../_tooltip.py => tools/_textbox.py} | 0 30 files changed, 321 insertions(+), 50 deletions(-) create mode 100644 docs/source/api/graphic_features/Scale.rst delete mode 100644 docs/source/api/tools/GraphicTooltip.rst create mode 100644 docs/source/api/tools/TextBox.rst rename fastplotlib/{graphics/_tooltip.py => tools/_textbox.py} (100%) diff --git a/docs/source/api/graphic_features/Scale.rst b/docs/source/api/graphic_features/Scale.rst new file mode 100644 index 000000000..b0ef07a79 --- /dev/null +++ b/docs/source/api/graphic_features/Scale.rst @@ -0,0 +1,35 @@ +.. _api.Scale: + +Scale +***** + +===== +Scale +===== +.. currentmodule:: fastplotlib.graphics.features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: Scale_api + + Scale + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: Scale_api + + Scale.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: Scale_api + + Scale.add_event_handler + Scale.block_events + Scale.clear_event_handlers + Scale.remove_event_handler + Scale.set_value + diff --git a/docs/source/api/graphic_features/index.rst b/docs/source/api/graphic_features/index.rst index cd11544be..71268ddab 100644 --- a/docs/source/api/graphic_features/index.rst +++ b/docs/source/api/graphic_features/index.rst @@ -48,6 +48,7 @@ Graphic Features Name Offset Rotation + Scale Alpha AlphaMode Visible diff --git a/docs/source/api/graphics/Graphic.rst b/docs/source/api/graphics/Graphic.rst index da6424e3e..f94892949 100644 --- a/docs/source/api/graphics/Graphic.rst +++ b/docs/source/api/graphics/Graphic.rst @@ -30,7 +30,9 @@ Properties Graphic.offset Graphic.right_click_menu Graphic.rotation + Graphic.scale Graphic.supported_events + Graphic.tooltip_format Graphic.visible Graphic.world_object @@ -42,6 +44,9 @@ Methods Graphic.add_axes Graphic.add_event_handler Graphic.clear_event_handlers + Graphic.format_pick_info + Graphic.map_model_to_world + Graphic.map_world_to_model Graphic.remove_event_handler Graphic.rotate diff --git a/docs/source/api/graphics/ImageGraphic.rst b/docs/source/api/graphics/ImageGraphic.rst index 457ba27ee..e6d02c54b 100644 --- a/docs/source/api/graphics/ImageGraphic.rst +++ b/docs/source/api/graphics/ImageGraphic.rst @@ -34,7 +34,9 @@ Properties ImageGraphic.offset ImageGraphic.right_click_menu ImageGraphic.rotation + ImageGraphic.scale ImageGraphic.supported_events + ImageGraphic.tooltip_format ImageGraphic.visible ImageGraphic.vmax ImageGraphic.vmin @@ -52,6 +54,9 @@ Methods ImageGraphic.add_polygon_selector ImageGraphic.add_rectangle_selector ImageGraphic.clear_event_handlers + ImageGraphic.format_pick_info + ImageGraphic.map_model_to_world + ImageGraphic.map_world_to_model ImageGraphic.remove_event_handler ImageGraphic.reset_vmin_vmax ImageGraphic.rotate diff --git a/docs/source/api/graphics/ImageVolumeGraphic.rst b/docs/source/api/graphics/ImageVolumeGraphic.rst index 8adbc7ac7..8031f12f1 100644 --- a/docs/source/api/graphics/ImageVolumeGraphic.rst +++ b/docs/source/api/graphics/ImageVolumeGraphic.rst @@ -37,11 +37,13 @@ Properties ImageVolumeGraphic.plane ImageVolumeGraphic.right_click_menu ImageVolumeGraphic.rotation + ImageVolumeGraphic.scale ImageVolumeGraphic.shininess ImageVolumeGraphic.step_size ImageVolumeGraphic.substep_size ImageVolumeGraphic.supported_events ImageVolumeGraphic.threshold + ImageVolumeGraphic.tooltip_format ImageVolumeGraphic.visible ImageVolumeGraphic.vmax ImageVolumeGraphic.vmin @@ -55,6 +57,9 @@ Methods ImageVolumeGraphic.add_axes ImageVolumeGraphic.add_event_handler ImageVolumeGraphic.clear_event_handlers + ImageVolumeGraphic.format_pick_info + ImageVolumeGraphic.map_model_to_world + ImageVolumeGraphic.map_world_to_model ImageVolumeGraphic.remove_event_handler ImageVolumeGraphic.reset_vmin_vmax ImageVolumeGraphic.rotate diff --git a/docs/source/api/graphics/LineCollection.rst b/docs/source/api/graphics/LineCollection.rst index ffbb52f2b..5d0603ab7 100644 --- a/docs/source/api/graphics/LineCollection.rst +++ b/docs/source/api/graphics/LineCollection.rst @@ -38,8 +38,10 @@ Properties LineCollection.right_click_menu LineCollection.rotation LineCollection.rotations + LineCollection.scale LineCollection.supported_events LineCollection.thickness + LineCollection.tooltip_format LineCollection.visible LineCollection.visibles LineCollection.world_object @@ -57,6 +59,9 @@ Methods LineCollection.add_polygon_selector LineCollection.add_rectangle_selector LineCollection.clear_event_handlers + LineCollection.format_pick_info + LineCollection.map_model_to_world + LineCollection.map_world_to_model LineCollection.remove_event_handler LineCollection.remove_graphic LineCollection.rotate diff --git a/docs/source/api/graphics/LineGraphic.rst b/docs/source/api/graphics/LineGraphic.rst index ddcb00c41..428e8ef56 100644 --- a/docs/source/api/graphics/LineGraphic.rst +++ b/docs/source/api/graphics/LineGraphic.rst @@ -33,9 +33,11 @@ Properties LineGraphic.offset LineGraphic.right_click_menu LineGraphic.rotation + LineGraphic.scale LineGraphic.size_space LineGraphic.supported_events LineGraphic.thickness + LineGraphic.tooltip_format LineGraphic.visible LineGraphic.world_object @@ -51,6 +53,9 @@ Methods LineGraphic.add_polygon_selector LineGraphic.add_rectangle_selector LineGraphic.clear_event_handlers + LineGraphic.format_pick_info + LineGraphic.map_model_to_world + LineGraphic.map_world_to_model LineGraphic.remove_event_handler LineGraphic.rotate diff --git a/docs/source/api/graphics/LineStack.rst b/docs/source/api/graphics/LineStack.rst index 4373454be..e7ac21343 100644 --- a/docs/source/api/graphics/LineStack.rst +++ b/docs/source/api/graphics/LineStack.rst @@ -38,8 +38,10 @@ Properties LineStack.right_click_menu LineStack.rotation LineStack.rotations + LineStack.scale LineStack.supported_events LineStack.thickness + LineStack.tooltip_format LineStack.visible LineStack.visibles LineStack.world_object @@ -57,6 +59,9 @@ Methods LineStack.add_polygon_selector LineStack.add_rectangle_selector LineStack.clear_event_handlers + LineStack.format_pick_info + LineStack.map_model_to_world + LineStack.map_world_to_model LineStack.remove_event_handler LineStack.remove_graphic LineStack.rotate diff --git a/docs/source/api/graphics/MeshGraphic.rst b/docs/source/api/graphics/MeshGraphic.rst index 5e2c5dac5..ec27f1e4e 100644 --- a/docs/source/api/graphics/MeshGraphic.rst +++ b/docs/source/api/graphics/MeshGraphic.rst @@ -38,7 +38,9 @@ Properties MeshGraphic.positions MeshGraphic.right_click_menu MeshGraphic.rotation + MeshGraphic.scale MeshGraphic.supported_events + MeshGraphic.tooltip_format MeshGraphic.visible MeshGraphic.world_object @@ -50,6 +52,9 @@ Methods MeshGraphic.add_axes MeshGraphic.add_event_handler MeshGraphic.clear_event_handlers + MeshGraphic.format_pick_info + MeshGraphic.map_model_to_world + MeshGraphic.map_world_to_model MeshGraphic.remove_event_handler MeshGraphic.rotate diff --git a/docs/source/api/graphics/PolygonGraphic.rst b/docs/source/api/graphics/PolygonGraphic.rst index f9446f425..94c75f999 100644 --- a/docs/source/api/graphics/PolygonGraphic.rst +++ b/docs/source/api/graphics/PolygonGraphic.rst @@ -39,7 +39,9 @@ Properties PolygonGraphic.positions PolygonGraphic.right_click_menu PolygonGraphic.rotation + PolygonGraphic.scale PolygonGraphic.supported_events + PolygonGraphic.tooltip_format PolygonGraphic.visible PolygonGraphic.world_object @@ -51,6 +53,9 @@ Methods PolygonGraphic.add_axes PolygonGraphic.add_event_handler PolygonGraphic.clear_event_handlers + PolygonGraphic.format_pick_info + PolygonGraphic.map_model_to_world + PolygonGraphic.map_world_to_model PolygonGraphic.remove_event_handler PolygonGraphic.rotate diff --git a/docs/source/api/graphics/ScatterGraphic.rst b/docs/source/api/graphics/ScatterGraphic.rst index 7f4336abe..cf8e1224d 100644 --- a/docs/source/api/graphics/ScatterGraphic.rst +++ b/docs/source/api/graphics/ScatterGraphic.rst @@ -40,9 +40,11 @@ Properties ScatterGraphic.point_rotations ScatterGraphic.right_click_menu ScatterGraphic.rotation + ScatterGraphic.scale ScatterGraphic.size_space ScatterGraphic.sizes ScatterGraphic.supported_events + ScatterGraphic.tooltip_format ScatterGraphic.visible ScatterGraphic.world_object @@ -54,6 +56,9 @@ Methods ScatterGraphic.add_axes ScatterGraphic.add_event_handler ScatterGraphic.clear_event_handlers + ScatterGraphic.format_pick_info + ScatterGraphic.map_model_to_world + ScatterGraphic.map_world_to_model ScatterGraphic.remove_event_handler ScatterGraphic.rotate diff --git a/docs/source/api/graphics/SurfaceGraphic.rst b/docs/source/api/graphics/SurfaceGraphic.rst index 385ce2432..228dbede1 100644 --- a/docs/source/api/graphics/SurfaceGraphic.rst +++ b/docs/source/api/graphics/SurfaceGraphic.rst @@ -39,7 +39,9 @@ Properties SurfaceGraphic.positions SurfaceGraphic.right_click_menu SurfaceGraphic.rotation + SurfaceGraphic.scale SurfaceGraphic.supported_events + SurfaceGraphic.tooltip_format SurfaceGraphic.visible SurfaceGraphic.world_object @@ -51,6 +53,9 @@ Methods SurfaceGraphic.add_axes SurfaceGraphic.add_event_handler SurfaceGraphic.clear_event_handlers + SurfaceGraphic.format_pick_info + SurfaceGraphic.map_model_to_world + SurfaceGraphic.map_world_to_model SurfaceGraphic.remove_event_handler SurfaceGraphic.rotate diff --git a/docs/source/api/graphics/TextGraphic.rst b/docs/source/api/graphics/TextGraphic.rst index 0de52942b..da4909686 100644 --- a/docs/source/api/graphics/TextGraphic.rst +++ b/docs/source/api/graphics/TextGraphic.rst @@ -34,8 +34,10 @@ Properties TextGraphic.outline_thickness TextGraphic.right_click_menu TextGraphic.rotation + TextGraphic.scale TextGraphic.supported_events TextGraphic.text + TextGraphic.tooltip_format TextGraphic.visible TextGraphic.world_object @@ -47,6 +49,9 @@ Methods TextGraphic.add_axes TextGraphic.add_event_handler TextGraphic.clear_event_handlers + TextGraphic.format_pick_info + TextGraphic.map_model_to_world + TextGraphic.map_world_to_model TextGraphic.remove_event_handler TextGraphic.rotate diff --git a/docs/source/api/graphics/VectorsGraphic.rst b/docs/source/api/graphics/VectorsGraphic.rst index 4a629f5db..ec7d891c0 100644 --- a/docs/source/api/graphics/VectorsGraphic.rst +++ b/docs/source/api/graphics/VectorsGraphic.rst @@ -32,7 +32,9 @@ Properties VectorsGraphic.positions VectorsGraphic.right_click_menu VectorsGraphic.rotation + VectorsGraphic.scale VectorsGraphic.supported_events + VectorsGraphic.tooltip_format VectorsGraphic.visible VectorsGraphic.world_object @@ -44,6 +46,9 @@ Methods VectorsGraphic.add_axes VectorsGraphic.add_event_handler VectorsGraphic.clear_event_handlers + VectorsGraphic.format_pick_info + VectorsGraphic.map_model_to_world + VectorsGraphic.map_world_to_model VectorsGraphic.remove_event_handler VectorsGraphic.rotate diff --git a/docs/source/api/layouts/figure.rst b/docs/source/api/layouts/figure.rst index e306710be..54e91b24f 100644 --- a/docs/source/api/layouts/figure.rst +++ b/docs/source/api/layouts/figure.rst @@ -28,8 +28,6 @@ Properties Figure.names Figure.renderer Figure.shape - Figure.show_tooltips - Figure.tooltip_manager Methods ~~~~~~~ diff --git a/docs/source/api/layouts/imgui_figure.rst b/docs/source/api/layouts/imgui_figure.rst index 959a98743..46e0c6ed3 100644 --- a/docs/source/api/layouts/imgui_figure.rst +++ b/docs/source/api/layouts/imgui_figure.rst @@ -31,8 +31,6 @@ Properties ImguiFigure.names ImguiFigure.renderer ImguiFigure.shape - ImguiFigure.show_tooltips - ImguiFigure.tooltip_manager Methods ~~~~~~~ diff --git a/docs/source/api/layouts/subplot.rst b/docs/source/api/layouts/subplot.rst index 93db00a2e..0916859b9 100644 --- a/docs/source/api/layouts/subplot.rst +++ b/docs/source/api/layouts/subplot.rst @@ -40,6 +40,7 @@ Properties Subplot.selectors Subplot.title Subplot.toolbar + Subplot.tooltip Subplot.viewport Methods @@ -67,8 +68,10 @@ Methods Subplot.clear_animations Subplot.delete_graphic Subplot.get_figure + Subplot.get_pick_info Subplot.insert_graphic Subplot.map_screen_to_world + Subplot.map_world_to_screen Subplot.remove_animation Subplot.remove_graphic diff --git a/docs/source/api/selectors/LinearRegionSelector.rst b/docs/source/api/selectors/LinearRegionSelector.rst index 35b5ae1f4..eb48497cd 100644 --- a/docs/source/api/selectors/LinearRegionSelector.rst +++ b/docs/source/api/selectors/LinearRegionSelector.rst @@ -35,8 +35,10 @@ Properties LinearRegionSelector.parent LinearRegionSelector.right_click_menu LinearRegionSelector.rotation + LinearRegionSelector.scale LinearRegionSelector.selection LinearRegionSelector.supported_events + LinearRegionSelector.tooltip_format LinearRegionSelector.vertex_color LinearRegionSelector.visible LinearRegionSelector.world_object @@ -49,9 +51,12 @@ Methods LinearRegionSelector.add_axes LinearRegionSelector.add_event_handler LinearRegionSelector.clear_event_handlers + LinearRegionSelector.format_pick_info LinearRegionSelector.get_selected_data LinearRegionSelector.get_selected_index LinearRegionSelector.get_selected_indices + LinearRegionSelector.map_model_to_world + LinearRegionSelector.map_world_to_model LinearRegionSelector.remove_event_handler LinearRegionSelector.rotate diff --git a/docs/source/api/selectors/LinearSelector.rst b/docs/source/api/selectors/LinearSelector.rst index 9cbe6fb26..2aa334748 100644 --- a/docs/source/api/selectors/LinearSelector.rst +++ b/docs/source/api/selectors/LinearSelector.rst @@ -35,8 +35,10 @@ Properties LinearSelector.parent LinearSelector.right_click_menu LinearSelector.rotation + LinearSelector.scale LinearSelector.selection LinearSelector.supported_events + LinearSelector.tooltip_format LinearSelector.vertex_color LinearSelector.visible LinearSelector.world_object @@ -49,9 +51,12 @@ Methods LinearSelector.add_axes LinearSelector.add_event_handler LinearSelector.clear_event_handlers + LinearSelector.format_pick_info LinearSelector.get_selected_data LinearSelector.get_selected_index LinearSelector.get_selected_indices + LinearSelector.map_model_to_world + LinearSelector.map_world_to_model LinearSelector.remove_event_handler LinearSelector.rotate diff --git a/docs/source/api/selectors/RectangleSelector.rst b/docs/source/api/selectors/RectangleSelector.rst index dc9727069..51f6801a4 100644 --- a/docs/source/api/selectors/RectangleSelector.rst +++ b/docs/source/api/selectors/RectangleSelector.rst @@ -35,8 +35,10 @@ Properties RectangleSelector.parent RectangleSelector.right_click_menu RectangleSelector.rotation + RectangleSelector.scale RectangleSelector.selection RectangleSelector.supported_events + RectangleSelector.tooltip_format RectangleSelector.vertex_color RectangleSelector.visible RectangleSelector.world_object @@ -49,9 +51,12 @@ Methods RectangleSelector.add_axes RectangleSelector.add_event_handler RectangleSelector.clear_event_handlers + RectangleSelector.format_pick_info RectangleSelector.get_selected_data RectangleSelector.get_selected_index RectangleSelector.get_selected_indices + RectangleSelector.map_model_to_world + RectangleSelector.map_world_to_model RectangleSelector.remove_event_handler RectangleSelector.rotate diff --git a/docs/source/api/tools/GraphicTooltip.rst b/docs/source/api/tools/GraphicTooltip.rst deleted file mode 100644 index 948a1392d..000000000 --- a/docs/source/api/tools/GraphicTooltip.rst +++ /dev/null @@ -1,40 +0,0 @@ -.. _api.GraphicTooltip: - -GraphicTooltip -************** - -============== -GraphicTooltip -============== -.. currentmodule:: fastplotlib - -Constructor -~~~~~~~~~~~ -.. autosummary:: - :toctree: GraphicTooltip_api - - GraphicTooltip - -Properties -~~~~~~~~~~ -.. autosummary:: - :toctree: GraphicTooltip_api - - GraphicTooltip.background_color - GraphicTooltip.font_size - GraphicTooltip.outline_color - GraphicTooltip.padding - GraphicTooltip.text_color - GraphicTooltip.world_object - -Methods -~~~~~~~ -.. autosummary:: - :toctree: GraphicTooltip_api - - GraphicTooltip.clear - GraphicTooltip.display - GraphicTooltip.register - GraphicTooltip.unregister - GraphicTooltip.unregister_all - diff --git a/docs/source/api/tools/HistogramLUTTool.rst b/docs/source/api/tools/HistogramLUTTool.rst index 429f958e2..b3498dd68 100644 --- a/docs/source/api/tools/HistogramLUTTool.rst +++ b/docs/source/api/tools/HistogramLUTTool.rst @@ -32,7 +32,9 @@ Properties HistogramLUTTool.offset HistogramLUTTool.right_click_menu HistogramLUTTool.rotation + HistogramLUTTool.scale HistogramLUTTool.supported_events + HistogramLUTTool.tooltip_format HistogramLUTTool.visible HistogramLUTTool.vmax HistogramLUTTool.vmin @@ -46,6 +48,9 @@ Methods HistogramLUTTool.add_axes HistogramLUTTool.add_event_handler HistogramLUTTool.clear_event_handlers + HistogramLUTTool.format_pick_info + HistogramLUTTool.map_model_to_world + HistogramLUTTool.map_world_to_model HistogramLUTTool.remove_event_handler HistogramLUTTool.rotate HistogramLUTTool.set_data diff --git a/docs/source/api/tools/TextBox.rst b/docs/source/api/tools/TextBox.rst new file mode 100644 index 000000000..b202f4270 --- /dev/null +++ b/docs/source/api/tools/TextBox.rst @@ -0,0 +1,38 @@ +.. _api.TextBox: + +TextBox +******* + +======= +TextBox +======= +.. currentmodule:: fastplotlib + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: TextBox_api + + TextBox + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: TextBox_api + + TextBox.background_color + TextBox.font_size + TextBox.outline_color + TextBox.padding + TextBox.position + TextBox.text_color + TextBox.visible + +Methods +~~~~~~~ +.. autosummary:: + :toctree: TextBox_api + + TextBox.clear + TextBox.display + diff --git a/docs/source/api/tools/Tooltip.rst b/docs/source/api/tools/Tooltip.rst index c36d4ad5f..8e017370e 100644 --- a/docs/source/api/tools/Tooltip.rst +++ b/docs/source/api/tools/Tooltip.rst @@ -21,11 +21,14 @@ Properties :toctree: Tooltip_api Tooltip.background_color + Tooltip.continuous_update + Tooltip.enabled Tooltip.font_size Tooltip.outline_color Tooltip.padding + Tooltip.position Tooltip.text_color - Tooltip.world_object + Tooltip.visible Methods ~~~~~~~ diff --git a/docs/source/api/tools/index.rst b/docs/source/api/tools/index.rst index c81dba106..2bff8fb50 100644 --- a/docs/source/api/tools/index.rst +++ b/docs/source/api/tools/index.rst @@ -5,6 +5,6 @@ Tools :maxdepth: 1 HistogramLUTTool + TextBox Tooltip - GraphicTooltip Cursor diff --git a/docs/source/user_guide/event_tables.rst b/docs/source/user_guide/event_tables.rst index ba53c3411..42f168bea 100644 --- a/docs/source/user_guide/event_tables.rst +++ b/docs/source/user_guide/event_tables.rst @@ -113,6 +113,17 @@ rotation | value | np.ndarray[float, float, float, float] | new rotation quaternion | +----------+----------------------------------------+-------------------------+ +scale +^^^^^ + +**event info dict** + ++----------+----------------------------------------+-------------+ +| dict key | type | description | ++==========+========================================+=============+ +| value | np.ndarray[float, float, float, float] | new scale | ++----------+----------------------------------------+-------------+ + alpha ^^^^^ @@ -378,6 +389,17 @@ rotation | value | np.ndarray[float, float, float, float] | new rotation quaternion | +----------+----------------------------------------+-------------------------+ +scale +^^^^^ + +**event info dict** + ++----------+----------------------------------------+-------------+ +| dict key | type | description | ++==========+========================================+=============+ +| value | np.ndarray[float, float, float, float] | new scale | ++----------+----------------------------------------+-------------+ + alpha ^^^^^ @@ -526,6 +548,17 @@ rotation | value | np.ndarray[float, float, float, float] | new rotation quaternion | +----------+----------------------------------------+-------------------------+ +scale +^^^^^ + +**event info dict** + ++----------+----------------------------------------+-------------+ +| dict key | type | description | ++==========+========================================+=============+ +| value | np.ndarray[float, float, float, float] | new scale | ++----------+----------------------------------------+-------------+ + alpha ^^^^^ @@ -751,6 +784,17 @@ rotation | value | np.ndarray[float, float, float, float] | new rotation quaternion | +----------+----------------------------------------+-------------------------+ +scale +^^^^^ + +**event info dict** + ++----------+----------------------------------------+-------------+ +| dict key | type | description | ++==========+========================================+=============+ +| value | np.ndarray[float, float, float, float] | new scale | ++----------+----------------------------------------+-------------+ + alpha ^^^^^ @@ -853,6 +897,17 @@ rotation | value | np.ndarray[float, float, float, float] | new rotation quaternion | +----------+----------------------------------------+-------------------------+ +scale +^^^^^ + +**event info dict** + ++----------+----------------------------------------+-------------+ +| dict key | type | description | ++==========+========================================+=============+ +| value | np.ndarray[float, float, float, float] | new scale | ++----------+----------------------------------------+-------------+ + alpha ^^^^^ @@ -996,6 +1051,17 @@ rotation | value | np.ndarray[float, float, float, float] | new rotation quaternion | +----------+----------------------------------------+-------------------------+ +scale +^^^^^ + +**event info dict** + ++----------+----------------------------------------+-------------+ +| dict key | type | description | ++==========+========================================+=============+ +| value | np.ndarray[float, float, float, float] | new scale | ++----------+----------------------------------------+-------------+ + alpha ^^^^^ @@ -1124,6 +1190,17 @@ rotation | value | np.ndarray[float, float, float, float] | new rotation quaternion | +----------+----------------------------------------+-------------------------+ +scale +^^^^^ + +**event info dict** + ++----------+----------------------------------------+-------------+ +| dict key | type | description | ++==========+========================================+=============+ +| value | np.ndarray[float, float, float, float] | new scale | ++----------+----------------------------------------+-------------+ + alpha ^^^^^ @@ -1252,6 +1329,17 @@ rotation | value | np.ndarray[float, float, float, float] | new rotation quaternion | +----------+----------------------------------------+-------------------------+ +scale +^^^^^ + +**event info dict** + ++----------+----------------------------------------+-------------+ +| dict key | type | description | ++==========+========================================+=============+ +| value | np.ndarray[float, float, float, float] | new scale | ++----------+----------------------------------------+-------------+ + alpha ^^^^^ @@ -1387,6 +1475,17 @@ rotation | value | np.ndarray[float, float, float, float] | new rotation quaternion | +----------+----------------------------------------+-------------------------+ +scale +^^^^^ + +**event info dict** + ++----------+----------------------------------------+-------------+ +| dict key | type | description | ++==========+========================================+=============+ +| value | np.ndarray[float, float, float, float] | new scale | ++----------+----------------------------------------+-------------+ + alpha ^^^^^ @@ -1541,6 +1640,17 @@ rotation | value | np.ndarray[float, float, float, float] | new rotation quaternion | +----------+----------------------------------------+-------------------------+ +scale +^^^^^ + +**event info dict** + ++----------+----------------------------------------+-------------+ +| dict key | type | description | ++==========+========================================+=============+ +| value | np.ndarray[float, float, float, float] | new scale | ++----------+----------------------------------------+-------------+ + alpha ^^^^^ @@ -1695,6 +1805,17 @@ rotation | value | np.ndarray[float, float, float, float] | new rotation quaternion | +----------+----------------------------------------+-------------------------+ +scale +^^^^^ + +**event info dict** + ++----------+----------------------------------------+-------------+ +| dict key | type | description | ++==========+========================================+=============+ +| value | np.ndarray[float, float, float, float] | new scale | ++----------+----------------------------------------+-------------+ + alpha ^^^^^ @@ -1794,6 +1915,17 @@ rotation | value | np.ndarray[float, float, float, float] | new rotation quaternion | +----------+----------------------------------------+-------------------------+ +scale +^^^^^ + +**event info dict** + ++----------+----------------------------------------+-------------+ +| dict key | type | description | ++==========+========================================+=============+ +| value | np.ndarray[float, float, float, float] | new scale | ++----------+----------------------------------------+-------------+ + alpha ^^^^^ @@ -1895,6 +2027,17 @@ rotation | value | np.ndarray[float, float, float, float] | new rotation quaternion | +----------+----------------------------------------+-------------------------+ +scale +^^^^^ + +**event info dict** + ++----------+----------------------------------------+-------------+ +| dict key | type | description | ++==========+========================================+=============+ +| value | np.ndarray[float, float, float, float] | new scale | ++----------+----------------------------------------+-------------+ + alpha ^^^^^ @@ -1996,6 +2139,17 @@ rotation | value | np.ndarray[float, float, float, float] | new rotation quaternion | +----------+----------------------------------------+-------------------------+ +scale +^^^^^ + +**event info dict** + ++----------+----------------------------------------+-------------+ +| dict key | type | description | ++==========+========================================+=============+ +| value | np.ndarray[float, float, float, float] | new scale | ++----------+----------------------------------------+-------------+ + alpha ^^^^^ diff --git a/fastplotlib/graphics/__init__.py b/fastplotlib/graphics/__init__.py index d4a51ce16..3d01e4a35 100644 --- a/fastplotlib/graphics/__init__.py +++ b/fastplotlib/graphics/__init__.py @@ -7,7 +7,6 @@ from .mesh import MeshGraphic, SurfaceGraphic, PolygonGraphic from .text import TextGraphic from .line_collection import LineCollection, LineStack -from ._tooltip import TextBox, Tooltip __all__ = [ @@ -23,6 +22,4 @@ "TextGraphic", "LineCollection", "LineStack", - "TextBox", - "Tooltip", ] diff --git a/fastplotlib/layouts/_plot_area.py b/fastplotlib/layouts/_plot_area.py index 7d8aacb76..8966a6c3e 100644 --- a/fastplotlib/layouts/_plot_area.py +++ b/fastplotlib/layouts/_plot_area.py @@ -10,10 +10,11 @@ from ._utils import create_controller from ..graphics._base import Graphic, WORLD_OBJECT_TO_GRAPHIC -from ..graphics import ImageGraphic, Tooltip +from ..graphics import ImageGraphic from ..graphics.selectors._base_selector import BaseSelector from ._graphic_methods_mixin import GraphicMethodsMixin from ..legends import Legend +from ..tools import Tooltip try: diff --git a/fastplotlib/tools/__init__.py b/fastplotlib/tools/__init__.py index f75137bd2..761183f76 100644 --- a/fastplotlib/tools/__init__.py +++ b/fastplotlib/tools/__init__.py @@ -1,7 +1,10 @@ from ._histogram_lut import HistogramLUTTool +from ._textbox import TextBox, Tooltip from ._cursor import Cursor __all__ = [ "HistogramLUTTool", + "TextBox", + "Tooltip", "Cursor", ] diff --git a/fastplotlib/graphics/_tooltip.py b/fastplotlib/tools/_textbox.py similarity index 100% rename from fastplotlib/graphics/_tooltip.py rename to fastplotlib/tools/_textbox.py From c60f1fe8e34297cff8c86590d86273cfd8af10c1 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 7 Dec 2025 03:42:12 -0500 Subject: [PATCH 12/34] cursor manages tooltips --- examples/misc/cursor_manual.py | 4 +++- fastplotlib/tools/_cursor.py | 10 ++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/examples/misc/cursor_manual.py b/examples/misc/cursor_manual.py index 7b328e99e..3f940e7a6 100644 --- a/examples/misc/cursor_manual.py +++ b/examples/misc/cursor_manual.py @@ -20,7 +20,7 @@ img2 = np.zeros(img1.shape) img2[50:, 20:] = img1[:-50, :-20] -figure = fpl.Figure((1, 2)) +figure = fpl.Figure((1, 2), size=(700, 500)) # add images figure[0, 0].add_image(img1) @@ -41,6 +41,8 @@ def transform_func(pos): figure.show() +# you can hide the canvas cursor +figure.canvas.set_cursor("none") # NOTE: fpl.loop.run() should not be used for interactive sessions # See the "JupyterLab and IPython" section in the user guide diff --git a/fastplotlib/tools/_cursor.py b/fastplotlib/tools/_cursor.py index 7538b7021..0371bfe21 100644 --- a/fastplotlib/tools/_cursor.py +++ b/fastplotlib/tools/_cursor.py @@ -11,9 +11,9 @@ class Cursor: def __init__( self, - mode: Literal["crosshair", "marker"], + mode: Literal["crosshair", "marker"] = "crosshair", size: float = 1.0, # in screen space - color: str | Sequence[float] | pygfx.Color | np.ndarray = "r", + color: str | Sequence[float] | pygfx.Color | np.ndarray = "w", marker: str = "+", edge_color: str | Sequence[float] | pygfx.Color | np.ndarray = "k", edge_width: float = 0.5, @@ -287,6 +287,9 @@ def add_subplot(self, subplot: Subplot, transform: Callable | None = None): self._cursors[subplot] = cursor self._transforms[subplot] = transform + # let cursor manage tooltips + subplot.renderer.remove_event_handler(subplot._fpl_set_tooltip, "pointer_move") + def remove_subplot(self, subplot: Subplot): """remove cursor from subplot""" if subplot not in self._cursors.keys(): @@ -294,6 +297,9 @@ def remove_subplot(self, subplot: Subplot): subplot.scene.remove(self._cursors.pop(subplot)) + # give back tooltip control to the subplot + subplot.renderer.add_event_handler(subplot._fpl_set_tooltip, "pointer_move") + def clear(self): """remove from all subplots""" for subplot in self._cursors.keys(): From 32493b1e6c29bf656bb7043c7e8f934d3aa4da83 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 7 Dec 2025 03:46:08 -0500 Subject: [PATCH 13/34] update examples --- examples/line_collection/line_collection.py | 2 +- examples/line_collection/line_stack.py | 20 -------------------- examples/misc/cursor_manual.py | 2 +- 3 files changed, 2 insertions(+), 22 deletions(-) diff --git a/examples/line_collection/line_collection.py b/examples/line_collection/line_collection.py index 2ddfbe2ed..e3eea7392 100644 --- a/examples/line_collection/line_collection.py +++ b/examples/line_collection/line_collection.py @@ -29,7 +29,7 @@ def make_circle(center, radius: float, n_points: int = 75) -> np.ndarray: pos_xy = np.vstack(circles) -figure = fpl.Figure(size=(700, 560), show_tooltips=True) +figure = fpl.Figure(size=(700, 560)) figure[0, 0].add_line_collection(circles, cmap="jet", thickness=5) diff --git a/examples/line_collection/line_stack.py b/examples/line_collection/line_stack.py index 829708cb7..4376c18b4 100644 --- a/examples/line_collection/line_stack.py +++ b/examples/line_collection/line_stack.py @@ -21,7 +21,6 @@ figure = fpl.Figure( size=(700, 560), - show_tooltips=True ) line_stack = figure[0, 0].add_line_stack( @@ -32,25 +31,6 @@ ) -def tooltip_info(ev): - """A custom function to display the index of the graphic within the collection.""" - index = ev.pick_info["vertex_index"] # index of the line datapoint being hovered - - # get index of the hovered line within the line stack - line_index = np.where(line_stack.graphics == ev.graphic)[0].item() - info = f"line index: {line_index}\n" - - # append data value info - info += "\n".join(f"{dim}: {val}" for dim, val in zip("xyz", ev.graphic.data[index])) - - # return str to display in tooltip - return info - -# register the line stack with the custom tooltip function -figure.tooltip_manager.register( - line_stack, custom_info=tooltip_info -) - figure.show(maintain_aspect=False) diff --git a/examples/misc/cursor_manual.py b/examples/misc/cursor_manual.py index 3f940e7a6..d3bab51aa 100644 --- a/examples/misc/cursor_manual.py +++ b/examples/misc/cursor_manual.py @@ -20,7 +20,7 @@ img2 = np.zeros(img1.shape) img2[50:, 20:] = img1[:-50, :-20] -figure = fpl.Figure((1, 2), size=(700, 500)) +figure = fpl.Figure((1, 2), size=(700, 450)) # add images figure[0, 0].add_image(img1) From ed297f5979996784dda1b222e0a547cd782b1f10 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 7 Dec 2025 03:48:05 -0500 Subject: [PATCH 14/34] black --- fastplotlib/graphics/_base.py | 20 +++++++++++++++----- fastplotlib/graphics/_vectors.py | 6 ++++-- fastplotlib/graphics/mesh.py | 4 +++- fastplotlib/layouts/_plot_area.py | 5 +---- fastplotlib/tools/_cursor.py | 8 ++++++-- 5 files changed, 29 insertions(+), 14 deletions(-) diff --git a/fastplotlib/graphics/_base.py b/fastplotlib/graphics/_base.py index 285b1cc6b..7ea419c55 100644 --- a/fastplotlib/graphics/_base.py +++ b/fastplotlib/graphics/_base.py @@ -286,7 +286,9 @@ def _set_world_object(self, wo: pygfx.WorldObject): # add to world object -> graphic mapping if isinstance(wo, pygfx.Group): for child in wo.children: - if isinstance(child, (pygfx.Image, pygfx.Volume, pygfx.Points, pygfx.Line)): + if isinstance( + child, (pygfx.Image, pygfx.Volume, pygfx.Points, pygfx.Line) + ): # need to call int() on it since it's a numpy array with 1 element # and numpy arrays aren't hashable global_id = child.id @@ -496,7 +498,9 @@ def my_handler(event): feature = getattr(self, f"_{t}") feature.remove_event_handler(wrapper) - def map_model_to_world(self, position: tuple[float, float, float] | tuple[float, float]) -> np.ndarray: + def map_model_to_world( + self, position: tuple[float, float, float] | tuple[float, float] + ) -> np.ndarray: """ map position from model (data) space to world space, basically applies the world affine transform @@ -517,12 +521,16 @@ def map_model_to_world(self, position: tuple[float, float, float] | tuple[float, position = [*position, self.offset[-1]] if len(position) != 3: - raise ValueError(f"position must be tuple indicating (x, y, z) position in *model space*") + raise ValueError( + f"position must be tuple indicating (x, y, z) position in *model space*" + ) # apply world transform to project from model space to world space return la.vec_transform(position, self.world_object.world.matrix) - def map_world_to_model(self, position: tuple[float, float, float] | tuple[float, float]) -> np.ndarray: + def map_world_to_model( + self, position: tuple[float, float, float] | tuple[float, float] + ) -> np.ndarray: """ map position from world space to model (data) space, basically applies the inverse world affine transform @@ -543,7 +551,9 @@ def map_world_to_model(self, position: tuple[float, float, float] | tuple[float, position = [*position, self.offset[-1]] if len(position) != 3: - raise ValueError(f"position must be tuple indicating (x, y, z) position in *model space*") + raise ValueError( + f"position must be tuple indicating (x, y, z) position in *model space*" + ) return la.vec_transform(position, self.world_object.world.inverse_matrix) diff --git a/fastplotlib/graphics/_vectors.py b/fastplotlib/graphics/_vectors.py index bd4f25dd8..be90db538 100644 --- a/fastplotlib/graphics/_vectors.py +++ b/fastplotlib/graphics/_vectors.py @@ -173,8 +173,10 @@ def directions(self, new_directions): def format_pick_info(self, pick_info: dict) -> str: index = pick_info["instance_index"] - info = (f"position: {self.positions[index]}\n" - f"direction: {self.directions[index]}") + info = ( + f"position: {self.positions[index]}\n" + f"direction: {self.directions[index]}" + ) return info diff --git a/fastplotlib/graphics/mesh.py b/fastplotlib/graphics/mesh.py index f072b63ca..0e1ac42a3 100644 --- a/fastplotlib/graphics/mesh.py +++ b/fastplotlib/graphics/mesh.py @@ -306,7 +306,9 @@ def format_pick_info(self, pick_info: dict) -> str: # if vertex buffer sizes change then the pointer event can have outdated pick info? return "error, buffer size changed" - info = "\n".join(f"{dim}: {val:.4g}" for dim, val in zip("xyz", self.positions[vertex_index])) + info = "\n".join( + f"{dim}: {val:.4g}" for dim, val in zip("xyz", self.positions[vertex_index]) + ) return info diff --git a/fastplotlib/layouts/_plot_area.py b/fastplotlib/layouts/_plot_area.py index 8966a6c3e..fdce9e589 100644 --- a/fastplotlib/layouts/_plot_area.py +++ b/fastplotlib/layouts/_plot_area.py @@ -453,10 +453,7 @@ def _fpl_update_tooltip_render(self): else: # default formatter for this graphic info = graphic.format_pick_info(pick_info) - self._tooltip.display( - self._tooltip.position, - info - ) + self._tooltip.display(self._tooltip.position, info) return # tooltip cleared if none of the above condiitionals reached the tooltip display call diff --git a/fastplotlib/tools/_cursor.py b/fastplotlib/tools/_cursor.py index 0371bfe21..e31aff302 100644 --- a/fastplotlib/tools/_cursor.py +++ b/fastplotlib/tools/_cursor.py @@ -251,7 +251,9 @@ def position(self, pos: tuple[float, float]): if pick_info is not None: graphic = pick_info["graphic"] - if graphic._fpl_support_tooltip: # some graphics don't support tooltips, ex: Text + if ( + graphic._fpl_support_tooltip + ): # some graphics don't support tooltips, ex: Text if graphic.tooltip_format is not None: # custom formatter info = graphic.tooltip_format @@ -271,7 +273,9 @@ def add_subplot(self, subplot: Subplot, transform: Callable | None = None): raise KeyError(f"The given subplot has already been added to this cursor") if (not callable(transform)) and (transform is not None): - raise TypeError(f"`transform` must be a callable or `None`, you passed: {transform}") + raise TypeError( + f"`transform` must be a callable or `None`, you passed: {transform}" + ) if self.mode == "marker": cursor = self._create_marker() From 40f3357315bc5ee19d4419951c9c3939f47678fd Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 7 Dec 2025 04:20:38 -0500 Subject: [PATCH 15/34] cleanup --- fastplotlib/graphics/_base.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/fastplotlib/graphics/_base.py b/fastplotlib/graphics/_base.py index 7ea419c55..6123fb50f 100644 --- a/fastplotlib/graphics/_base.py +++ b/fastplotlib/graphics/_base.py @@ -585,11 +585,6 @@ def _fpl_prepare_del(self): for global_id in self._world_object_ids: WORLD_OBJECT_TO_GRAPHIC.pop(global_id) - # prepare del in tooltip remove tooltip - if self._tooltip is not None: - self._tooltip._fpl_prepare_del() - del self._tooltip - # remove axes if added to this graphic if self._axes is not None: self._plot_area.scene.remove(self._axes) From a3252ebe5c62e0cccfd63b701869d8a3f1c72c44 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 7 Dec 2025 04:30:59 -0500 Subject: [PATCH 16/34] docstrings --- fastplotlib/tools/_cursor.py | 35 +++++++++++++++++++++++++++++------ 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/fastplotlib/tools/_cursor.py b/fastplotlib/tools/_cursor.py index e31aff302..40da86c5b 100644 --- a/fastplotlib/tools/_cursor.py +++ b/fastplotlib/tools/_cursor.py @@ -41,10 +41,10 @@ def __init__( marker shape, used if mode == 'marker' edge_color: str | Sequence[float] | pygfx.Color | np.ndarray, default "k" - marker edge color, used if mode == 'marker' + marker edge color, used if ``mode`` == 'marker' edge_width: float, default 0.5 - marker edge widget, used if mode == 'marker' + marker edge widget, used if ``mode`` == 'marker' alpha: float, default 0.7 alpha (transparency) of the cursor @@ -74,6 +74,7 @@ def __init__( @property def mode(self) -> Literal["crosshair", "marker"]: + """cursor mode, one of 'crosshair' or 'marker'""" return self._mode @mode.setter @@ -98,6 +99,7 @@ def mode(self, mode: Literal["crosshair", "marker"]): @property def size(self) -> float: + """size of marker or crosshair line thickness""" return self._size @size.setter @@ -114,6 +116,7 @@ def size(self, new_size: float): @property def size_space(self) -> Literal["screen", "world"]: + """interpret cursor size in screen or world space""" return self._size_space @size_space.setter @@ -136,6 +139,7 @@ def size_space(self, space: Literal["screen", "world"]): @property def color(self) -> pygfx.Color: + """cursor color""" return self._color @color.setter @@ -149,6 +153,7 @@ def color(self, new_color): @property def marker(self) -> str: + """cursor marker shape, if `mode` is 'marker'""" return self._marker @marker.setter @@ -161,6 +166,7 @@ def marker(self, new_marker: str): @property def edge_color(self) -> pygfx.Color: + """cursor marker edge color, if `mode` is 'marker'""" return self._edge_color @edge_color.setter @@ -175,6 +181,7 @@ def edge_color(self, new_color: str | Sequence | np.ndarray | pygfx.Color): @property def edge_width(self) -> float: + """cursor marker edge width, if `mode` is 'marker'""" return self._edge_width @edge_width.setter @@ -187,6 +194,7 @@ def edge_width(self, new_width: float): @property def alpha(self) -> float: + """cursor alpha value""" return self._alpha @alpha.setter @@ -198,6 +206,7 @@ def alpha(self, value: float): @property def pause(self) -> bool: + """pause the cursor, if True the cursor will not respond to mouse pointer events""" return self._pause @pause.setter @@ -206,7 +215,7 @@ def pause(self, pause: bool): @property def position(self) -> tuple[float, float]: - """x, y position in world space""" + """(x, y) position in world space""" return tuple(self._position) @position.setter @@ -268,7 +277,19 @@ def position(self, pos: tuple[float, float]): subplot.tooltip.clear() def add_subplot(self, subplot: Subplot, transform: Callable | None = None): - """add this cursor to a subplot, with an optional position transform function""" + """ + Add add a subplot to this cursor, with an optional position transform function + + Parameters + ---------- + subplot: Subplot + subplot to add + + transform: Callable[[tuple[float, float]], tuple[float, float]] + a transform function that takes the cursor's position and returns a transformed + position at which the cursor will visually appear. + + """ if subplot in self._cursors.keys(): raise KeyError(f"The given subplot has already been added to this cursor") @@ -295,7 +316,7 @@ def add_subplot(self, subplot: Subplot, transform: Callable | None = None): subplot.renderer.remove_event_handler(subplot._fpl_set_tooltip, "pointer_move") def remove_subplot(self, subplot: Subplot): - """remove cursor from subplot""" + """remove a subplot""" if subplot not in self._cursors.keys(): raise KeyError("cursor not in given supblot") @@ -305,11 +326,12 @@ def remove_subplot(self, subplot: Subplot): subplot.renderer.add_event_handler(subplot._fpl_set_tooltip, "pointer_move") def clear(self): - """remove from all subplots""" + """remove all subplots""" for subplot in self._cursors.keys(): self.remove_subplot(subplot) def _create_marker(self) -> pygfx.Points: + # creates a Point object, used for "marker" mode point = pygfx.Points( pygfx.Geometry(positions=np.array([[*self.position, 0]], dtype=np.float32)), pygfx.PointsMarkerMaterial( @@ -331,6 +353,7 @@ def _create_marker(self) -> pygfx.Points: return point def _create_crosshair(self) -> pygfx.Group: + # Creates two infinite lines, used for "crosshair" mode x, y = self.position line_h_data = np.array( [ From c6133cd04067aec306b98caef46ea4f78823f0f5 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 7 Dec 2025 04:34:04 -0500 Subject: [PATCH 17/34] update example --- examples/misc/{cursor_manual.py => cursor_transform.py} | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) rename examples/misc/{cursor_manual.py => cursor_transform.py} (91%) diff --git a/examples/misc/cursor_manual.py b/examples/misc/cursor_transform.py similarity index 91% rename from examples/misc/cursor_manual.py rename to examples/misc/cursor_transform.py index d3bab51aa..7f5e901b5 100644 --- a/examples/misc/cursor_manual.py +++ b/examples/misc/cursor_transform.py @@ -2,7 +2,7 @@ Cursor transform ================ -Create a cursor and add them to subplots with a transform function. An example usecase is image registration. +Create a cursor and add them to subplots with a transform function. A common usecase is image registration. """ # test_example = False @@ -39,6 +39,9 @@ def transform_func(pos): # add second subplot with a transform cursor.add_subplot(figure[0, 1], transform=transform_func) +# programmatically set cursor position +cursor.position = (400, 120) + figure.show() # you can hide the canvas cursor From 75d13feff1b37a5439c914537b518424ab2eb2e8 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 7 Dec 2025 04:43:59 -0500 Subject: [PATCH 18/34] update example --- examples/misc/cursor_transform.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/misc/cursor_transform.py b/examples/misc/cursor_transform.py index 7f5e901b5..46478d8ce 100644 --- a/examples/misc/cursor_transform.py +++ b/examples/misc/cursor_transform.py @@ -39,12 +39,12 @@ def transform_func(pos): # add second subplot with a transform cursor.add_subplot(figure[0, 1], transform=transform_func) -# programmatically set cursor position -cursor.position = (400, 120) - figure.show() -# you can hide the canvas cursor +# you can programmatically set cursor position +cursor.position = (400, 120) + +# you can hide the canvas cursor, this is different and has nothing to do with the fastplotlib Cursor! figure.canvas.set_cursor("none") # NOTE: fpl.loop.run() should not be used for interactive sessions From 6d535e74d540ff51bdd5452adfca4360ec6ee4fc Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 16 Dec 2025 23:18:25 -0500 Subject: [PATCH 19/34] add image space transforms examples --- docs/source/conf.py | 1 + examples/spaces_transforms/README.rst | 2 + examples/spaces_transforms/rotation_image.py | 92 ++++++++++++++++ examples/spaces_transforms/scaling_image.py | 92 ++++++++++++++++ examples/spaces_transforms/translate_image.py | 93 ++++++++++++++++ .../translation_scaling_image.py | 97 +++++++++++++++++ .../translation_scaling_rotation_image.py | 102 ++++++++++++++++++ examples/tests/testutils.py | 1 + fastplotlib/graphics/_base.py | 8 +- fastplotlib/layouts/_plot_area.py | 4 +- 10 files changed, 486 insertions(+), 6 deletions(-) create mode 100644 examples/spaces_transforms/README.rst create mode 100644 examples/spaces_transforms/rotation_image.py create mode 100644 examples/spaces_transforms/scaling_image.py create mode 100644 examples/spaces_transforms/translate_image.py create mode 100644 examples/spaces_transforms/translation_scaling_image.py create mode 100644 examples/spaces_transforms/translation_scaling_rotation_image.py diff --git a/docs/source/conf.py b/docs/source/conf.py index 8547e9ae7..edc172dad 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -68,6 +68,7 @@ "../../examples/text", "../../examples/events", "../../examples/selection_tools", + "../../examples/spaces_transforms", "../../examples/machine_learning", "../../examples/guis", "../../examples/ipywidgets", diff --git a/examples/spaces_transforms/README.rst b/examples/spaces_transforms/README.rst new file mode 100644 index 000000000..55747c2a8 --- /dev/null +++ b/examples/spaces_transforms/README.rst @@ -0,0 +1,2 @@ +Spaces and transforms +===================== diff --git a/examples/spaces_transforms/rotation_image.py b/examples/spaces_transforms/rotation_image.py new file mode 100644 index 000000000..8a67505ef --- /dev/null +++ b/examples/spaces_transforms/rotation_image.py @@ -0,0 +1,92 @@ +""" +Rotate image +============ + +This examples illustrates the various spaces that you may need to map between, +plots an image to show these mappings. +""" + +# test_example = True +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl + +figure = fpl.Figure(size=(700, 560)) + +# an image to demonstrate some data in model/data space +image_data = np.array( + [ + [0, 1, 2], + [3, 4, 5], + [5, 6, 7], + [8, 9, 10], + ] +) +image = figure[0, 0].add_image(image_data, cmap="turbo") + + +# a scatter that will be in the same space as the image +# used to indicates a few points on the image +scatter_data = np.array([[2, 3], [0, 1]]) +scatter = figure[0, 0].add_scatter( + scatter_data, + sizes=15, + colors=["blue", "red"], +) + +# text to indicate the scatter point positions in all spaces +text_0 = figure[0, 0].add_text( + text="", + anchor="bottom-left", + face_color="w", + outline_color="k", + outline_thickness=0.5, +) +text_1 = figure[0, 0].add_text( + text="", + anchor="bottom-left", + face_color="w", + outline_color="k", + outline_thickness=0.5, +) + +# rotation of pi/4 as a quaternion +rotation_quat = (np.cos(np.pi / 8), np.sin(np.pi / 8), 0, 0) +image.rotation = rotation_quat +scatter.rotation = rotation_quat + + +def update_text(): + # get the position of the scatter points in world space + # graphics can map from model <-> world space + point_0_world = scatter.map_model_to_world(scatter.data[0]) + point_1_world = scatter.map_model_to_world(scatter.data[1]) + + # text is always just set in world space + text_0.offset = point_0_world + text_1.offset = point_1_world + + # use subplot to map to world <-> screen space + point_0_screen = figure[0, 0].map_world_to_screen(point_0_world) + point_1_screen = figure[0, 0].map_world_to_screen(point_1_world) + + # set text to display model, world and screen space position of the 2 points + text_0.text = ( + f"model pos: [{', '.join(str(round(p, 2)) for p in scatter.data[0])}]\n" + f"world pos: [{', '.join(str(round(p, 2)) for p in point_0_world)}]\n" + f"screen pos: [{', '.join(str(round(p)) for p in point_0_screen)}]" + ) + + text_1.text = ( + f"model pos: [{', '.join(str(round(p, 2)) for p in scatter.data[1])}]\n" + f"world pos: [{', '.join(str(round(p, 2)) for p in point_1_world)}]\n" + f"screen pos: [{', '.join(str(round(p)) for p in point_1_screen)}]" + ) + + +figure.add_animations(update_text) + +figure.show() + +fpl.loop.run() diff --git a/examples/spaces_transforms/scaling_image.py b/examples/spaces_transforms/scaling_image.py new file mode 100644 index 000000000..850b1b460 --- /dev/null +++ b/examples/spaces_transforms/scaling_image.py @@ -0,0 +1,92 @@ +""" +Scale image +=========== + +This examples illustrates the various spaces that you may need to map between, +plots an image to show these mappings. +""" + +# test_example = True +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl + +figure = fpl.Figure(size=(700, 560)) + +# an image to demonstrate some data in model/data space +image_data = np.array( + [ + [0, 1, 2], + [3, 4, 5], + [5, 6, 7], + [8, 9, 10], + ] +) +image = figure[0, 0].add_image(image_data, cmap="turbo") + + +# a scatter that will be in the same space as the image +# used to indicates a few points on the image +scatter_data = np.array([[2, 3], [0, 1]]) +scatter = figure[0, 0].add_scatter( + scatter_data, + sizes=15, + colors=["blue", "red"], +) + +# text to indicate the scatter point positions in all spaces +text_0 = figure[0, 0].add_text( + text="", + anchor="bottom-left", + face_color="w", + outline_color="k", + outline_thickness=0.5, +) +text_1 = figure[0, 0].add_text( + text="", + anchor="bottom-left", + face_color="w", + outline_color="k", + outline_thickness=0.5, +) + + +scaling = (2, 0.5, 1.0) # scale (x, y, z) +image.scale = scaling +scatter.scale = scaling + + +def update_text(): + # get the position of the scatter points in world space + # graphics can map from model <-> world space + point_0_world = scatter.map_model_to_world(scatter.data[0]) + point_1_world = scatter.map_model_to_world(scatter.data[1]) + + # text is always just set in world space + text_0.offset = point_0_world + text_1.offset = point_1_world + + # use subplot to map to world <-> screen space + point_0_screen = figure[0, 0].map_world_to_screen(point_0_world) + point_1_screen = figure[0, 0].map_world_to_screen(point_1_world) + + # set text to display model, world and screen space position of the 2 points + text_0.text = ( + f"model pos: {scatter.data[0]}\n" + f"world pos: {point_0_world}\n" + f"screen pos: [{', '.join(str(round(p)) for p in point_0_screen)}]" + ) + + text_1.text = ( + f"model pos: {scatter.data[1]}\n" + f"world pos: {point_1_world}\n" + f"screen pos: [{', '.join(str(round(p)) for p in point_1_screen)}]" + ) + + +figure.add_animations(update_text) + +figure.show() + +fpl.loop.run() diff --git a/examples/spaces_transforms/translate_image.py b/examples/spaces_transforms/translate_image.py new file mode 100644 index 000000000..d2043490e --- /dev/null +++ b/examples/spaces_transforms/translate_image.py @@ -0,0 +1,93 @@ +""" +Translate image +=============== + +This examples illustrates the various spaces that you may need to map between, +plots an image to show these mappings. +""" + +# test_example = True +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl + +figure = fpl.Figure(size=(700, 560)) + +# an image to demonstrate some data in model/data space +image_data = np.array( + [ + [0, 1, 2], + [3, 4, 5], + [5, 6, 7], + [8, 9, 10], + ] +) +image = figure[0, 0].add_image(image_data, cmap="turbo") + + +# a scatter that will be in the same space as the image +# used to indicates a few points on the image +scatter_data = np.array([[2, 3], [0, 1]]) +scatter = figure[0, 0].add_scatter( + scatter_data, + sizes=15, + colors=["blue", "red"], +) + +# text to indicate the scatter point positions in all spaces +text_0 = figure[0, 0].add_text( + text="", + anchor="bottom-left", + face_color="w", + outline_color="k", + outline_thickness=0.5, +) +text_1 = figure[0, 0].add_text( + text="", + anchor="bottom-left", + face_color="w", + outline_color="k", + outline_thickness=0.5, +) + + +# translation +translation = (2, 3, 0) # x, y, z translation +image.offset = translation +scatter.offset = translation + + +def update_text(): + # get the position of the scatter points in world space + # graphics can map from model <-> world space + point_0_world = scatter.map_model_to_world(scatter.data[0]) + point_1_world = scatter.map_model_to_world(scatter.data[1]) + + # text is always just set in world space + text_0.offset = point_0_world + text_1.offset = point_1_world + + # use subplot to map to world <-> screen space + point_0_screen = figure[0, 0].map_world_to_screen(point_0_world) + point_1_screen = figure[0, 0].map_world_to_screen(point_1_world) + + # set text to display model, world and screen space position of the 2 points + text_0.text = ( + f"model pos: {scatter.data[0]}\n" + f"world pos: {point_0_world}\n" + f"screen pos: [{', '.join(str(round(p)) for p in point_0_screen)}]" + ) + + text_1.text = ( + f"model pos: {scatter.data[1]}\n" + f"world pos: {point_1_world}\n" + f"screen pos: [{', '.join(str(round(p)) for p in point_1_screen)}]" + ) + + +figure.add_animations(update_text) + +figure.show() + +fpl.loop.run() diff --git a/examples/spaces_transforms/translation_scaling_image.py b/examples/spaces_transforms/translation_scaling_image.py new file mode 100644 index 000000000..34829b4a8 --- /dev/null +++ b/examples/spaces_transforms/translation_scaling_image.py @@ -0,0 +1,97 @@ +""" +Translate and scale image +========================= + +This examples illustrates the various spaces that you may need to map between, +plots an image to show these mappings. +""" + +# test_example = True +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl + +figure = fpl.Figure(size=(700, 560)) + +# an image to demonstrate some data in model/data space +image_data = np.array( + [ + [0, 1, 2], + [3, 4, 5], + [5, 6, 7], + [8, 9, 10], + ] +) +image = figure[0, 0].add_image(image_data, cmap="turbo") + + +# a scatter that will be in the same space as the image +# used to indicates a few points on the image +scatter_data = np.array([[2, 3], [0, 1]]) +scatter = figure[0, 0].add_scatter( + scatter_data, + sizes=15, + colors=["blue", "red"], +) + +# text to indicate the scatter point positions in all spaces +text_0 = figure[0, 0].add_text( + text="", + anchor="bottom-left", + face_color="w", + outline_color="k", + outline_thickness=0.5, +) +text_1 = figure[0, 0].add_text( + text="", + anchor="bottom-left", + face_color="w", + outline_color="k", + outline_thickness=0.5, +) + + +# translation and scaling +translation = (2, 3, 0) # x, y, z translation +image.offset = translation +scatter.offset = translation + +scaling = (2, 0.5, 1.0) # scale (x, y, z) +image.scale = scaling +scatter.scale = scaling + + +def update_text(): + # get the position of the scatter points in world space + # graphics can map from model <-> world space + point_0_world = scatter.map_model_to_world(scatter.data[0]) + point_1_world = scatter.map_model_to_world(scatter.data[1]) + + # text is always just set in world space + text_0.offset = point_0_world + text_1.offset = point_1_world + + # use subplot to map to world <-> screen space + point_0_screen = figure[0, 0].map_world_to_screen(point_0_world) + point_1_screen = figure[0, 0].map_world_to_screen(point_1_world) + + # set text to display model, world and screen space position of the 2 points + text_0.text = ( + f"model pos: [{', '.join(str(round(p, 2)) for p in scatter.data[0])}]\n" + f"world pos: [{', '.join(str(round(p, 2)) for p in point_0_world)}]\n" + f"screen pos: [{', '.join(str(round(p)) for p in point_0_screen)}]" + ) + + text_1.text = ( + f"model pos: [{', '.join(str(round(p, 2)) for p in scatter.data[1])}]\n" + f"world pos: [{', '.join(str(round(p, 2)) for p in point_1_world)}]\n" + f"screen pos: [{', '.join(str(round(p)) for p in point_1_screen)}]" + ) + + +figure.add_animations(update_text) + +figure.show() + +fpl.loop.run() diff --git a/examples/spaces_transforms/translation_scaling_rotation_image.py b/examples/spaces_transforms/translation_scaling_rotation_image.py new file mode 100644 index 000000000..71bdb743f --- /dev/null +++ b/examples/spaces_transforms/translation_scaling_rotation_image.py @@ -0,0 +1,102 @@ +""" +Translate scale and rotate image +================================ + +This examples illustrates the various spaces that you may need to map between, +plots an image to show these mappings. +""" + +# test_example = True +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl + +figure = fpl.Figure(size=(700, 560)) + +# an image to demonstrate some data in model/data space +image_data = np.array( + [ + [0, 1, 2], + [3, 4, 5], + [5, 6, 7], + [8, 9, 10], + ] +) +image = figure[0, 0].add_image(image_data, cmap="turbo") + + +# a scatter that will be in the same space as the image +# used to indicates a few points on the image +scatter_data = np.array([[2, 3], [0, 1]]) +scatter = figure[0, 0].add_scatter( + scatter_data, + sizes=15, + colors=["blue", "red"], +) + +# text to indicate the scatter point positions in all spaces +text_0 = figure[0, 0].add_text( + text="", + anchor="bottom-left", + face_color="w", + outline_color="k", + outline_thickness=0.5, +) +text_1 = figure[0, 0].add_text( + text="", + anchor="bottom-left", + face_color="w", + outline_color="k", + outline_thickness=0.5, +) + + +# translation and scaling +translation = (2, 3, 0) # x, y, z translation +image.offset = translation +scatter.offset = translation + +scaling = (2, 0.5, 1.0) # scale (x, y, z) +image.scale = scaling +scatter.scale = scaling + +# rotation of pi/4 as a quaternion +rotation_quat = (np.cos(np.pi / 8), np.sin(np.pi / 8), 0, 0) +image.rotation = rotation_quat +scatter.rotation = rotation_quat + + +def update_text(): + # get the position of the scatter points in world space + # graphics can map from model <-> world space + point_0_world = scatter.map_model_to_world(scatter.data[0]) + point_1_world = scatter.map_model_to_world(scatter.data[1]) + + # text is always just set in world space + text_0.offset = point_0_world + text_1.offset = point_1_world + + # use subplot to map to world <-> screen space + point_0_screen = figure[0, 0].map_world_to_screen(point_0_world) + point_1_screen = figure[0, 0].map_world_to_screen(point_1_world) + + # set text to display model, world and screen space position of the 2 points + text_0.text = ( + f"model pos: [{', '.join(str(round(p, 2)) for p in scatter.data[0])}]\n" + f"world pos: [{', '.join(str(round(p, 2)) for p in point_0_world)}]\n" + f"screen pos: [{', '.join(str(round(p)) for p in point_0_screen)}]" + ) + + text_1.text = ( + f"model pos: [{', '.join(str(round(p, 2)) for p in scatter.data[1])}]\n" + f"world pos: [{', '.join(str(round(p, 2)) for p in point_1_world)}]\n" + f"screen pos: [{', '.join(str(round(p)) for p in point_1_screen)}]" + ) + + +figure.add_animations(update_text) + +figure.show() + +fpl.loop.run() diff --git a/examples/tests/testutils.py b/examples/tests/testutils.py index aad729c7a..e279809e3 100644 --- a/examples/tests/testutils.py +++ b/examples/tests/testutils.py @@ -30,6 +30,7 @@ "window_layouts/*.py", "events/*.py", "selection_tools/*.py", + "spaces_transforms/*.py", "misc/*.py", "guis/*.py", ] diff --git a/fastplotlib/graphics/_base.py b/fastplotlib/graphics/_base.py index 6123fb50f..0b8838adf 100644 --- a/fastplotlib/graphics/_base.py +++ b/fastplotlib/graphics/_base.py @@ -499,7 +499,7 @@ def my_handler(event): feature.remove_event_handler(wrapper) def map_model_to_world( - self, position: tuple[float, float, float] | tuple[float, float] + self, position: tuple[float, float, float] | tuple[float, float] | np.ndarray ) -> np.ndarray: """ map position from model (data) space to world space, basically applies the world affine transform @@ -522,14 +522,14 @@ def map_model_to_world( if len(position) != 3: raise ValueError( - f"position must be tuple indicating (x, y, z) position in *model space*" + f"position must be tuple or array indicating (x, y, z) position in *model space*" ) # apply world transform to project from model space to world space return la.vec_transform(position, self.world_object.world.matrix) def map_world_to_model( - self, position: tuple[float, float, float] | tuple[float, float] + self, position: tuple[float, float, float] | tuple[float, float] | np.ndarray ) -> np.ndarray: """ map position from world space to model (data) space, basically applies the inverse world affine transform @@ -552,7 +552,7 @@ def map_world_to_model( if len(position) != 3: raise ValueError( - f"position must be tuple indicating (x, y, z) position in *model space*" + f"position must be tuple or array indicating (x, y, z) position in *model space*" ) return la.vec_transform(position, self.world_object.world.inverse_matrix) diff --git a/fastplotlib/layouts/_plot_area.py b/fastplotlib/layouts/_plot_area.py index fdce9e589..6943132bc 100644 --- a/fastplotlib/layouts/_plot_area.py +++ b/fastplotlib/layouts/_plot_area.py @@ -350,9 +350,9 @@ def map_screen_to_world( # default z is zero for now return np.array([*pos_world[:2], 0]) - def map_world_to_screen(self, pos: tuple[float, float, float]): + def map_world_to_screen(self, pos: tuple[float, float, float] | np.ndarray) -> tuple[float, float]: """ - Map world position to screen (canvas) posiition + Map world position to screen (canvas) position Parameters ---------- From 00b51a9d9c2aa747b61418c34897e2e0716190e4 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 16 Dec 2025 23:56:00 -0500 Subject: [PATCH 20/34] finish space transforms examples --- examples/spaces_transforms/rotation_image.py | 4 +- examples/spaces_transforms/rotation_line.py | 89 +++++++++++++++++ examples/spaces_transforms/scaling_image.py | 4 +- examples/spaces_transforms/scaling_line.py | 89 +++++++++++++++++ examples/spaces_transforms/translate_image.py | 4 +- examples/spaces_transforms/translate_line.py | 90 +++++++++++++++++ .../translation_scaling_image.py | 4 +- .../translation_scaling_line.py | 94 ++++++++++++++++++ .../translation_scaling_rotation_image.py | 2 +- .../translation_scaling_rotation_line.py | 99 +++++++++++++++++++ 10 files changed, 474 insertions(+), 5 deletions(-) create mode 100644 examples/spaces_transforms/rotation_line.py create mode 100644 examples/spaces_transforms/scaling_line.py create mode 100644 examples/spaces_transforms/translate_line.py create mode 100644 examples/spaces_transforms/translation_scaling_line.py create mode 100644 examples/spaces_transforms/translation_scaling_rotation_line.py diff --git a/examples/spaces_transforms/rotation_image.py b/examples/spaces_transforms/rotation_image.py index 8a67505ef..ad876b175 100644 --- a/examples/spaces_transforms/rotation_image.py +++ b/examples/spaces_transforms/rotation_image.py @@ -28,11 +28,13 @@ # a scatter that will be in the same space as the image # used to indicates a few points on the image -scatter_data = np.array([[2, 3], [0, 1]]) +scatter_data = np.array([[0, 1], [2, 3]]) scatter = figure[0, 0].add_scatter( scatter_data, sizes=15, colors=["blue", "red"], + edge_colors="w", + edge_width=2.0, ) # text to indicate the scatter point positions in all spaces diff --git a/examples/spaces_transforms/rotation_line.py b/examples/spaces_transforms/rotation_line.py new file mode 100644 index 000000000..696444659 --- /dev/null +++ b/examples/spaces_transforms/rotation_line.py @@ -0,0 +1,89 @@ +""" +Rotate line +=========== + +This examples illustrates the various spaces that you may need to map between, +plots a line to show these mappings. +""" + +# test_example = True +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl + +figure = fpl.Figure(size=(700, 560)) + +xs = np.linspace(0, 2 * np.pi, 100) +ys = np.sin(xs) + +# a line to demonstrate some data in model/data space +line_data = np.column_stack([xs, ys]) +line = figure[0, 0].add_line(line_data, cmap="jet", thickness=10) + +# a scatter that will be in the same space as the line +# used to indicates a few points on the line +scatter_data = np.array([[np.pi / 4, np.sin(np.pi / 4)], [3 * np.pi / 2 , -1]]) +scatter = figure[0, 0].add_scatter( + scatter_data, + sizes=15, + colors=["blue", "red"], + edge_colors="w", + edge_width=2.0, +) + +# text to indicate the scatter point positions in all spaces +text_0 = figure[0, 0].add_text( + text="", + anchor="bottom-left", + face_color="w", + outline_color="k", + outline_thickness=0.5, +) +text_1 = figure[0, 0].add_text( + text="", + anchor="bottom-left", + face_color="w", + outline_color="k", + outline_thickness=0.5, +) + +# rotation of pi/4 as a quaternion +rotation_quat = (np.cos(np.pi / 8), np.sin(np.pi / 8), 0, 0) +line.rotation = rotation_quat +scatter.rotation = rotation_quat + + +def update_text(): + # get the position of the scatter points in world space + # graphics can map from model <-> world space + point_0_world = scatter.map_model_to_world(scatter.data[0]) + point_1_world = scatter.map_model_to_world(scatter.data[1]) + + # text is always just set in world space + text_0.offset = point_0_world + text_1.offset = point_1_world + + # use subplot to map to world <-> screen space + point_0_screen = figure[0, 0].map_world_to_screen(point_0_world) + point_1_screen = figure[0, 0].map_world_to_screen(point_1_world) + + # set text to display model, world and screen space position of the 2 points + text_0.text = ( + f"model pos: [{', '.join(str(round(p, 2)) for p in scatter.data[0])}]\n" + f"world pos: [{', '.join(str(round(p, 2)) for p in point_0_world)}]\n" + f"screen pos: [{', '.join(str(round(p)) for p in point_0_screen)}]" + ) + + text_1.text = ( + f"model pos: [{', '.join(str(round(p, 2)) for p in scatter.data[1])}]\n" + f"world pos: [{', '.join(str(round(p, 2)) for p in point_1_world)}]\n" + f"screen pos: [{', '.join(str(round(p)) for p in point_1_screen)}]" + ) + + +figure.add_animations(update_text) + +figure.show() + +fpl.loop.run() diff --git a/examples/spaces_transforms/scaling_image.py b/examples/spaces_transforms/scaling_image.py index 850b1b460..93b22ff29 100644 --- a/examples/spaces_transforms/scaling_image.py +++ b/examples/spaces_transforms/scaling_image.py @@ -28,11 +28,13 @@ # a scatter that will be in the same space as the image # used to indicates a few points on the image -scatter_data = np.array([[2, 3], [0, 1]]) +scatter_data = np.array([[0, 1], [2, 3]]) scatter = figure[0, 0].add_scatter( scatter_data, sizes=15, colors=["blue", "red"], + edge_colors="w", + edge_width=2.0, ) # text to indicate the scatter point positions in all spaces diff --git a/examples/spaces_transforms/scaling_line.py b/examples/spaces_transforms/scaling_line.py new file mode 100644 index 000000000..035303597 --- /dev/null +++ b/examples/spaces_transforms/scaling_line.py @@ -0,0 +1,89 @@ +""" +Scale line +=========== + +This examples illustrates the various spaces that you may need to map between, +plots a line to show these mappings. +""" + +# test_example = True +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl + +figure = fpl.Figure(size=(700, 560)) + +xs = np.linspace(0, 2 * np.pi, 100) +ys = np.sin(xs) + +# a line to demonstrate some data in model/data space +line_data = np.column_stack([xs, ys]) +line = figure[0, 0].add_line(line_data, cmap="jet", thickness=10) + +# a scatter that will be in the same space as the line +# used to indicates a few points on the line +scatter_data = np.array([[np.pi / 4, np.sin(np.pi / 4)], [3 * np.pi / 2 , -1]]) +scatter = figure[0, 0].add_scatter( + scatter_data, + sizes=15, + colors=["blue", "red"], + edge_colors="w", + edge_width=2.0, +) + +# text to indicate the scatter point positions in all spaces +text_0 = figure[0, 0].add_text( + text="", + anchor="bottom-left", + face_color="w", + outline_color="k", + outline_thickness=0.5, +) +text_1 = figure[0, 0].add_text( + text="", + anchor="bottom-left", + face_color="w", + outline_color="k", + outline_thickness=0.5, +) + + +scaling = (2, 0.5, 1.0) # scale (x, y, z) +line.scale = scaling +scatter.scale = scaling + + +def update_text(): + # get the position of the scatter points in world space + # graphics can map from model <-> world space + point_0_world = scatter.map_model_to_world(scatter.data[0]) + point_1_world = scatter.map_model_to_world(scatter.data[1]) + + # text is always just set in world space + text_0.offset = point_0_world + text_1.offset = point_1_world + + # use subplot to map to world <-> screen space + point_0_screen = figure[0, 0].map_world_to_screen(point_0_world) + point_1_screen = figure[0, 0].map_world_to_screen(point_1_world) + + # set text to display model, world and screen space position of the 2 points + text_0.text = ( + f"model pos: {scatter.data[0]}\n" + f"world pos: {point_0_world}\n" + f"screen pos: [{', '.join(str(round(p)) for p in point_0_screen)}]" + ) + + text_1.text = ( + f"model pos: {scatter.data[1]}\n" + f"world pos: {point_1_world}\n" + f"screen pos: [{', '.join(str(round(p)) for p in point_1_screen)}]" + ) + + +figure.add_animations(update_text) + +figure.show() + +fpl.loop.run() diff --git a/examples/spaces_transforms/translate_image.py b/examples/spaces_transforms/translate_image.py index d2043490e..77f0d5998 100644 --- a/examples/spaces_transforms/translate_image.py +++ b/examples/spaces_transforms/translate_image.py @@ -28,11 +28,13 @@ # a scatter that will be in the same space as the image # used to indicates a few points on the image -scatter_data = np.array([[2, 3], [0, 1]]) +scatter_data = np.array([[0, 1], [2, 3]]) scatter = figure[0, 0].add_scatter( scatter_data, sizes=15, colors=["blue", "red"], + edge_colors="w", + edge_width=2.0, ) # text to indicate the scatter point positions in all spaces diff --git a/examples/spaces_transforms/translate_line.py b/examples/spaces_transforms/translate_line.py new file mode 100644 index 000000000..5145ea3d2 --- /dev/null +++ b/examples/spaces_transforms/translate_line.py @@ -0,0 +1,90 @@ +""" +Translate line +============== + +This examples illustrates the various spaces that you may need to map between, +plots a line to show these mappings. +""" + +# test_example = True +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl + +figure = fpl.Figure(size=(700, 560)) + +xs = np.linspace(0, 2 * np.pi, 100) +ys = np.sin(xs) + +# a line to demonstrate some data in model/data space +line_data = np.column_stack([xs, ys]) +line = figure[0, 0].add_line(line_data, cmap="jet", thickness=10) + +# a scatter that will be in the same space as the line +# used to indicates a few points on the line +scatter_data = np.array([[np.pi / 4, np.sin(np.pi / 4)], [3 * np.pi / 2 , -1]]) +scatter = figure[0, 0].add_scatter( + scatter_data, + sizes=15, + colors=["blue", "red"], + edge_colors="w", + edge_width=2.0, +) + +# text to indicate the scatter point positions in all spaces +text_0 = figure[0, 0].add_text( + text="", + anchor="bottom-left", + face_color="w", + outline_color="k", + outline_thickness=0.5, +) +text_1 = figure[0, 0].add_text( + text="", + anchor="bottom-left", + face_color="w", + outline_color="k", + outline_thickness=0.5, +) + + +# translation +translation = (2, 3, 0) # x, y, z translation +line.offset = translation +scatter.offset = translation + + +def update_text(): + # get the position of the scatter points in world space + # graphics can map from model <-> world space + point_0_world = scatter.map_model_to_world(scatter.data[0]) + point_1_world = scatter.map_model_to_world(scatter.data[1]) + + # text is always just set in world space + text_0.offset = point_0_world + text_1.offset = point_1_world + + # use subplot to map to world <-> screen space + point_0_screen = figure[0, 0].map_world_to_screen(point_0_world) + point_1_screen = figure[0, 0].map_world_to_screen(point_1_world) + + # set text to display model, world and screen space position of the 2 points + text_0.text = ( + f"model pos: {scatter.data[0]}\n" + f"world pos: {point_0_world}\n" + f"screen pos: [{', '.join(str(round(p)) for p in point_0_screen)}]" + ) + + text_1.text = ( + f"model pos: {scatter.data[1]}\n" + f"world pos: {point_1_world}\n" + f"screen pos: [{', '.join(str(round(p)) for p in point_1_screen)}]" + ) + + +figure.add_animations(update_text) + +figure.show() + +fpl.loop.run() diff --git a/examples/spaces_transforms/translation_scaling_image.py b/examples/spaces_transforms/translation_scaling_image.py index 34829b4a8..4d2ea1906 100644 --- a/examples/spaces_transforms/translation_scaling_image.py +++ b/examples/spaces_transforms/translation_scaling_image.py @@ -28,11 +28,13 @@ # a scatter that will be in the same space as the image # used to indicates a few points on the image -scatter_data = np.array([[2, 3], [0, 1]]) +scatter_data = np.array([[0, 1], [2, 3]]) scatter = figure[0, 0].add_scatter( scatter_data, sizes=15, colors=["blue", "red"], + edge_colors="w", + edge_width=2.0, ) # text to indicate the scatter point positions in all spaces diff --git a/examples/spaces_transforms/translation_scaling_line.py b/examples/spaces_transforms/translation_scaling_line.py new file mode 100644 index 000000000..fbe6cd153 --- /dev/null +++ b/examples/spaces_transforms/translation_scaling_line.py @@ -0,0 +1,94 @@ +""" +Translate and scale line +======================== + +This examples illustrates the various spaces that you may need to map between, +plots a line to show these mappings. +""" + +# test_example = True +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl + +figure = fpl.Figure(size=(700, 560)) + +xs = np.linspace(0, 2 * np.pi, 100) +ys = np.sin(xs) + +# a line to demonstrate some data in model/data space +line_data = np.column_stack([xs, ys]) +line = figure[0, 0].add_line(line_data, cmap="jet", thickness=10) + +# a scatter that will be in the same space as the line +# used to indicates a few points on the line +scatter_data = np.array([[np.pi / 4, np.sin(np.pi / 4)], [3 * np.pi / 2 , -1]]) +scatter = figure[0, 0].add_scatter( + scatter_data, + sizes=15, + colors=["blue", "red"], + edge_colors="w", + edge_width=2.0, +) + +# text to indicate the scatter point positions in all spaces +text_0 = figure[0, 0].add_text( + text="", + anchor="bottom-left", + face_color="w", + outline_color="k", + outline_thickness=0.5, +) +text_1 = figure[0, 0].add_text( + text="", + anchor="bottom-left", + face_color="w", + outline_color="k", + outline_thickness=0.5, +) + + +# translation and scaling +translation = (2, 3, 0) # x, y, z translation +line.offset = translation +scatter.offset = translation + +scaling = (2, 0.5, 1.0) # scale (x, y, z) +line.scale = scaling +scatter.scale = scaling + + +def update_text(): + # get the position of the scatter points in world space + # graphics can map from model <-> world space + point_0_world = scatter.map_model_to_world(scatter.data[0]) + point_1_world = scatter.map_model_to_world(scatter.data[1]) + + # text is always just set in world space + text_0.offset = point_0_world + text_1.offset = point_1_world + + # use subplot to map to world <-> screen space + point_0_screen = figure[0, 0].map_world_to_screen(point_0_world) + point_1_screen = figure[0, 0].map_world_to_screen(point_1_world) + + # set text to display model, world and screen space position of the 2 points + text_0.text = ( + f"model pos: [{', '.join(str(round(p, 2)) for p in scatter.data[0])}]\n" + f"world pos: [{', '.join(str(round(p, 2)) for p in point_0_world)}]\n" + f"screen pos: [{', '.join(str(round(p)) for p in point_0_screen)}]" + ) + + text_1.text = ( + f"model pos: [{', '.join(str(round(p, 2)) for p in scatter.data[1])}]\n" + f"world pos: [{', '.join(str(round(p, 2)) for p in point_1_world)}]\n" + f"screen pos: [{', '.join(str(round(p)) for p in point_1_screen)}]" + ) + + +figure.add_animations(update_text) + +figure.show() + +fpl.loop.run() diff --git a/examples/spaces_transforms/translation_scaling_rotation_image.py b/examples/spaces_transforms/translation_scaling_rotation_image.py index 71bdb743f..7b3fe129d 100644 --- a/examples/spaces_transforms/translation_scaling_rotation_image.py +++ b/examples/spaces_transforms/translation_scaling_rotation_image.py @@ -28,7 +28,7 @@ # a scatter that will be in the same space as the image # used to indicates a few points on the image -scatter_data = np.array([[2, 3], [0, 1]]) +scatter_data = np.array([[0, 1], [2, 3]]) scatter = figure[0, 0].add_scatter( scatter_data, sizes=15, diff --git a/examples/spaces_transforms/translation_scaling_rotation_line.py b/examples/spaces_transforms/translation_scaling_rotation_line.py new file mode 100644 index 000000000..8ad31a034 --- /dev/null +++ b/examples/spaces_transforms/translation_scaling_rotation_line.py @@ -0,0 +1,99 @@ +""" +Translate scale and rotate line +=============================== + +This examples illustrates the various spaces that you may need to map between, +plots a line to show these mappings. +""" + +# test_example = True +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl + +figure = fpl.Figure(size=(700, 560)) + +xs = np.linspace(0, 2 * np.pi, 100) +ys = np.sin(xs) + +# a line to demonstrate some data in model/data space +line_data = np.column_stack([xs, ys]) +line = figure[0, 0].add_line(line_data, cmap="jet", thickness=10) + +# a scatter that will be in the same space as the line +# used to indicates a few points on the line +scatter_data = np.array([[np.pi / 4, np.sin(np.pi / 4)], [3 * np.pi / 2 , -1]]) +scatter = figure[0, 0].add_scatter( + scatter_data, + sizes=15, + colors=["blue", "red"], + edge_colors="w", + edge_width=2.0, +) + +# text to indicate the scatter point positions in all spaces +text_0 = figure[0, 0].add_text( + text="", + anchor="bottom-left", + face_color="w", + outline_color="k", + outline_thickness=0.5, +) +text_1 = figure[0, 0].add_text( + text="", + anchor="bottom-left", + face_color="w", + outline_color="k", + outline_thickness=0.5, +) + + +# translation and scaling +translation = (2, 3, 0) # x, y, z translation +line.offset = translation +scatter.offset = translation + +scaling = (2, 0.5, 1.0) # scale (x, y, z) +line.scale = scaling +scatter.scale = scaling + +# rotation of pi/4 as a quaternion +rotation_quat = (np.cos(np.pi / 8), np.sin(np.pi / 8), 0, 0) +line.rotation = rotation_quat +scatter.rotation = rotation_quat + + +def update_text(): + # get the position of the scatter points in world space + # graphics can map from model <-> world space + point_0_world = scatter.map_model_to_world(scatter.data[0]) + point_1_world = scatter.map_model_to_world(scatter.data[1]) + + # text is always just set in world space + text_0.offset = point_0_world + text_1.offset = point_1_world + + # use subplot to map to world <-> screen space + point_0_screen = figure[0, 0].map_world_to_screen(point_0_world) + point_1_screen = figure[0, 0].map_world_to_screen(point_1_world) + + # set text to display model, world and screen space position of the 2 points + text_0.text = ( + f"model pos: [{', '.join(str(round(p, 2)) for p in scatter.data[0])}]\n" + f"world pos: [{', '.join(str(round(p, 2)) for p in point_0_world)}]\n" + f"screen pos: [{', '.join(str(round(p)) for p in point_0_screen)}]" + ) + + text_1.text = ( + f"model pos: [{', '.join(str(round(p, 2)) for p in scatter.data[1])}]\n" + f"world pos: [{', '.join(str(round(p, 2)) for p in point_1_world)}]\n" + f"screen pos: [{', '.join(str(round(p)) for p in point_1_screen)}]" + ) + + +figure.add_animations(update_text) + +figure.show() + +fpl.loop.run() From 1cafa743b645866307d646624626f6da2e60348a Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 17 Dec 2025 00:06:57 -0500 Subject: [PATCH 21/34] typing --- fastplotlib/tools/_cursor.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/fastplotlib/tools/_cursor.py b/fastplotlib/tools/_cursor.py index 40da86c5b..c72d937c2 100644 --- a/fastplotlib/tools/_cursor.py +++ b/fastplotlib/tools/_cursor.py @@ -55,8 +55,8 @@ def __init__( """ - self._cursors: dict[Subplot, [pygfx.Points | pygfx.Group[pygfx.Line]]] = dict() - self._transforms: dict[Subplot, [Callable | None]] = dict() + self._cursors: dict[Subplot, pygfx.Points | pygfx.Group[pygfx.Line]] = dict() + self._transforms: dict[Subplot, Callable | None] = dict() self._mode = None self.mode = mode @@ -278,14 +278,14 @@ def position(self, pos: tuple[float, float]): def add_subplot(self, subplot: Subplot, transform: Callable | None = None): """ - Add add a subplot to this cursor, with an optional position transform function + Add a subplot to this cursor, with an optional position transform function Parameters ---------- subplot: Subplot subplot to add - transform: Callable[[tuple[float, float]], tuple[float, float]] + transform: Callable[[tuple[float, float]], tuple[float, float]] | None a transform function that takes the cursor's position and returns a transformed position at which the cursor will visually appear. From f585007ef7349b4717c7c69eaeb50de781e21eeb Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 17 Dec 2025 00:09:40 -0500 Subject: [PATCH 22/34] black --- fastplotlib/layouts/_plot_area.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/fastplotlib/layouts/_plot_area.py b/fastplotlib/layouts/_plot_area.py index 6943132bc..fed48b739 100644 --- a/fastplotlib/layouts/_plot_area.py +++ b/fastplotlib/layouts/_plot_area.py @@ -350,7 +350,9 @@ def map_screen_to_world( # default z is zero for now return np.array([*pos_world[:2], 0]) - def map_world_to_screen(self, pos: tuple[float, float, float] | np.ndarray) -> tuple[float, float]: + def map_world_to_screen( + self, pos: tuple[float, float, float] | np.ndarray + ) -> tuple[float, float]: """ Map world position to screen (canvas) position From a64ce675f16db5f9a2a17a3e6a0419686f9bfd42 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 17 Dec 2025 00:20:08 -0500 Subject: [PATCH 23/34] docstrings --- fastplotlib/tools/_textbox.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/fastplotlib/tools/_textbox.py b/fastplotlib/tools/_textbox.py index 276024a7f..9d2b60939 100644 --- a/fastplotlib/tools/_textbox.py +++ b/fastplotlib/tools/_textbox.py @@ -1,6 +1,3 @@ -from functools import partial -from typing import Literal - import numpy as np import pygfx @@ -175,6 +172,7 @@ def padding(self, padding_xy: tuple[float, float]): @property def visible(self) -> bool: + """get or set the visibility""" return self._fpl_world_object.visible @visible.setter @@ -183,7 +181,7 @@ def visible(self, visible: bool): def display(self, position: tuple[float, float], info: str): """ - display tooltip at the given position in screen space + display at the given position in screen space Parameters ---------- @@ -202,7 +200,7 @@ def display(self, position: tuple[float, float], info: str): def _draw_tooltip(self, pos: tuple[float, float]): """ - Set the position of the tooltip + Sets the positions of the world objects so it's draw at the given position Parameters ---------- @@ -239,6 +237,7 @@ def _draw_tooltip(self, pos: tuple[float, float]): self._line.geometry.positions.update_range() def clear(self, *args): + """clear the text box and make it invisible""" self._text.set_text("") self._fpl_world_object.visible = False @@ -252,6 +251,7 @@ def __init__(self): @property def enabled(self) -> bool: + """enable or disable the tooltip""" return self._enabled @enabled.setter From a0f74c23999d8f9d5a066626b44e4ef5dff865d5 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 17 Dec 2025 00:21:39 -0500 Subject: [PATCH 24/34] update examples --- examples/spaces_transforms/scaling_image.py | 8 ++++---- examples/spaces_transforms/scaling_line.py | 8 ++++---- examples/spaces_transforms/translate_image.py | 8 ++++---- examples/spaces_transforms/translate_line.py | 8 ++++---- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/examples/spaces_transforms/scaling_image.py b/examples/spaces_transforms/scaling_image.py index 93b22ff29..5d025fe31 100644 --- a/examples/spaces_transforms/scaling_image.py +++ b/examples/spaces_transforms/scaling_image.py @@ -75,14 +75,14 @@ def update_text(): # set text to display model, world and screen space position of the 2 points text_0.text = ( - f"model pos: {scatter.data[0]}\n" - f"world pos: {point_0_world}\n" + f"model pos: [{', '.join(str(round(p, 2)) for p in scatter.data[0])}]\n" + f"world pos: [{', '.join(str(round(p, 2)) for p in point_0_world)}]\n" f"screen pos: [{', '.join(str(round(p)) for p in point_0_screen)}]" ) text_1.text = ( - f"model pos: {scatter.data[1]}\n" - f"world pos: {point_1_world}\n" + f"model pos: [{', '.join(str(round(p, 2)) for p in scatter.data[1])}]\n" + f"world pos: [{', '.join(str(round(p, 2)) for p in point_1_world)}]\n" f"screen pos: [{', '.join(str(round(p)) for p in point_1_screen)}]" ) diff --git a/examples/spaces_transforms/scaling_line.py b/examples/spaces_transforms/scaling_line.py index 035303597..1be4c038a 100644 --- a/examples/spaces_transforms/scaling_line.py +++ b/examples/spaces_transforms/scaling_line.py @@ -70,14 +70,14 @@ def update_text(): # set text to display model, world and screen space position of the 2 points text_0.text = ( - f"model pos: {scatter.data[0]}\n" - f"world pos: {point_0_world}\n" + f"model pos: [{', '.join(str(round(p, 2)) for p in scatter.data[0])}]\n" + f"world pos: [{', '.join(str(round(p, 2)) for p in point_0_world)}]\n" f"screen pos: [{', '.join(str(round(p)) for p in point_0_screen)}]" ) text_1.text = ( - f"model pos: {scatter.data[1]}\n" - f"world pos: {point_1_world}\n" + f"model pos: [{', '.join(str(round(p, 2)) for p in scatter.data[1])}]\n" + f"world pos: [{', '.join(str(round(p, 2)) for p in point_1_world)}]\n" f"screen pos: [{', '.join(str(round(p)) for p in point_1_screen)}]" ) diff --git a/examples/spaces_transforms/translate_image.py b/examples/spaces_transforms/translate_image.py index 77f0d5998..bec08e647 100644 --- a/examples/spaces_transforms/translate_image.py +++ b/examples/spaces_transforms/translate_image.py @@ -76,14 +76,14 @@ def update_text(): # set text to display model, world and screen space position of the 2 points text_0.text = ( - f"model pos: {scatter.data[0]}\n" - f"world pos: {point_0_world}\n" + f"model pos: [{', '.join(str(round(p, 2)) for p in scatter.data[0])}]\n" + f"world pos: [{', '.join(str(round(p, 2)) for p in point_0_world)}]\n" f"screen pos: [{', '.join(str(round(p)) for p in point_0_screen)}]" ) text_1.text = ( - f"model pos: {scatter.data[1]}\n" - f"world pos: {point_1_world}\n" + f"model pos: [{', '.join(str(round(p, 2)) for p in scatter.data[1])}]\n" + f"world pos: [{', '.join(str(round(p, 2)) for p in point_1_world)}]\n" f"screen pos: [{', '.join(str(round(p)) for p in point_1_screen)}]" ) diff --git a/examples/spaces_transforms/translate_line.py b/examples/spaces_transforms/translate_line.py index 5145ea3d2..03a47b995 100644 --- a/examples/spaces_transforms/translate_line.py +++ b/examples/spaces_transforms/translate_line.py @@ -71,14 +71,14 @@ def update_text(): # set text to display model, world and screen space position of the 2 points text_0.text = ( - f"model pos: {scatter.data[0]}\n" - f"world pos: {point_0_world}\n" + f"model pos: [{', '.join(str(round(p, 2)) for p in scatter.data[0])}]\n" + f"world pos: [{', '.join(str(round(p, 2)) for p in point_0_world)}]\n" f"screen pos: [{', '.join(str(round(p)) for p in point_0_screen)}]" ) text_1.text = ( - f"model pos: {scatter.data[1]}\n" - f"world pos: {point_1_world}\n" + f"model pos: [{', '.join(str(round(p, 2)) for p in scatter.data[1])}]\n" + f"world pos: [{', '.join(str(round(p, 2)) for p in point_1_world)}]\n" f"screen pos: [{', '.join(str(round(p)) for p in point_1_screen)}]" ) From 6a648e77c98979861ef9c2f96438e993e01abe8f Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 17 Dec 2025 00:37:27 -0500 Subject: [PATCH 25/34] textbox constructor takes args, docstrings --- fastplotlib/tools/_textbox.py | 46 ++++++++++++++++++++++++++++------- 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/fastplotlib/tools/_textbox.py b/fastplotlib/tools/_textbox.py index 9d2b60939..b1ac4db2a 100644 --- a/fastplotlib/tools/_textbox.py +++ b/fastplotlib/tools/_textbox.py @@ -48,20 +48,47 @@ class MeshMasks: class TextBox: - def __init__(self): + def __init__( + self, + font_size: int = 12, + text_color: str | pygfx.Color | tuple = "w", + background_color: str | pygfx.Color | tuple = (0.1, 0.1, 0.3, 0.95), + outline_color: str | pygfx.Color | tuple = (0.8, 0.8, 1.0, 1.0), + padding: tuple[float, float] = (5, 5), + ): + """ + Create a Textbox + + Parameters + ---------- + font_size: int, default 12 + text font size + + text_color: str | pygfx.Color | tuple, default "w" + text color, interpretable by pygfx.Color + + background_color: str | pygfx.Color | tuple, default (0.1, 0.1, 0.3, 0.95), + background color, interpretable by pygfx.Color + + outline_color: str | pygfx.Color | tuple, default (0.8, 0.8, 1.0, 1.0) + outline color, interpretable by pygfx.Color + + padding: (float, float), default (5, 5) + the amount of pixels in (x, y) by which to extend the rectangle behind the text + + """ + # text object self._text = pygfx.Text( text="", - font_size=12, - screen_space=False, + font_size=font_size, + screen_space=False, # these are added to the overlay render pass so it will actually be in screen space! anchor="bottom-left", material=pygfx.TextMaterial( alpha_mode="blend", aa=True, render_queue=RenderQueue.overlay, - color="w", - outline_color="w", - outline_thickness=0.0, + color=text_color, depth_write=False, depth_test=False, pick_write=False, @@ -73,7 +100,7 @@ def __init__(self): material = pygfx.MeshBasicMaterial( alpha_mode="blend", render_queue=RenderQueue.overlay, - color=(0.1, 0.1, 0.3, 0.95), + color=background_color, depth_write=False, depth_test=False, ) @@ -97,7 +124,7 @@ def __init__(self): alpha_mode="blend", render_queue=RenderQueue.overlay, thickness=1.0, - color=(0.8, 0.8, 1.0, 1.0), + color=outline_color, depth_write=False, depth_test=False, ), @@ -110,7 +137,8 @@ def __init__(self): # padded to bbox so the background box behind the text extends a bit further # making the text easier to read - self._padding = np.array([[5, 5, 0], [-5, -5, 0]], dtype=np.float32) + self._padding = np.zeros(shape=(2, 3), dtype=np.float32) + self.padding = padding # position of the tooltip in screen space self._position = np.array([0.0, 0.0]) From dd65092755b48ed7fe4bdc556d858471f6751d5c Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 17 Dec 2025 00:44:42 -0500 Subject: [PATCH 26/34] True -> true --- examples/spaces_transforms/rotation_image.py | 2 +- examples/spaces_transforms/rotation_line.py | 2 +- examples/spaces_transforms/scaling_image.py | 2 +- examples/spaces_transforms/scaling_line.py | 2 +- examples/spaces_transforms/translate_image.py | 2 +- examples/spaces_transforms/translate_line.py | 2 +- examples/spaces_transforms/translation_scaling_image.py | 2 +- examples/spaces_transforms/translation_scaling_line.py | 2 +- .../spaces_transforms/translation_scaling_rotation_image.py | 2 +- examples/spaces_transforms/translation_scaling_rotation_line.py | 2 +- 10 files changed, 10 insertions(+), 10 deletions(-) diff --git a/examples/spaces_transforms/rotation_image.py b/examples/spaces_transforms/rotation_image.py index ad876b175..ebc6cb3de 100644 --- a/examples/spaces_transforms/rotation_image.py +++ b/examples/spaces_transforms/rotation_image.py @@ -6,7 +6,7 @@ plots an image to show these mappings. """ -# test_example = True +# test_example = true # sphinx_gallery_pygfx_docs = 'screenshot' import numpy as np diff --git a/examples/spaces_transforms/rotation_line.py b/examples/spaces_transforms/rotation_line.py index 696444659..bec820eb8 100644 --- a/examples/spaces_transforms/rotation_line.py +++ b/examples/spaces_transforms/rotation_line.py @@ -6,7 +6,7 @@ plots a line to show these mappings. """ -# test_example = True +# test_example = true # sphinx_gallery_pygfx_docs = 'screenshot' import numpy as np diff --git a/examples/spaces_transforms/scaling_image.py b/examples/spaces_transforms/scaling_image.py index 5d025fe31..878a09010 100644 --- a/examples/spaces_transforms/scaling_image.py +++ b/examples/spaces_transforms/scaling_image.py @@ -6,7 +6,7 @@ plots an image to show these mappings. """ -# test_example = True +# test_example = true # sphinx_gallery_pygfx_docs = 'screenshot' import numpy as np diff --git a/examples/spaces_transforms/scaling_line.py b/examples/spaces_transforms/scaling_line.py index 1be4c038a..0fcdca55e 100644 --- a/examples/spaces_transforms/scaling_line.py +++ b/examples/spaces_transforms/scaling_line.py @@ -6,7 +6,7 @@ plots a line to show these mappings. """ -# test_example = True +# test_example = true # sphinx_gallery_pygfx_docs = 'screenshot' import numpy as np diff --git a/examples/spaces_transforms/translate_image.py b/examples/spaces_transforms/translate_image.py index bec08e647..24a90a064 100644 --- a/examples/spaces_transforms/translate_image.py +++ b/examples/spaces_transforms/translate_image.py @@ -6,7 +6,7 @@ plots an image to show these mappings. """ -# test_example = True +# test_example = true # sphinx_gallery_pygfx_docs = 'screenshot' import numpy as np diff --git a/examples/spaces_transforms/translate_line.py b/examples/spaces_transforms/translate_line.py index 03a47b995..d8821b271 100644 --- a/examples/spaces_transforms/translate_line.py +++ b/examples/spaces_transforms/translate_line.py @@ -6,7 +6,7 @@ plots a line to show these mappings. """ -# test_example = True +# test_example = true # sphinx_gallery_pygfx_docs = 'screenshot' import numpy as np diff --git a/examples/spaces_transforms/translation_scaling_image.py b/examples/spaces_transforms/translation_scaling_image.py index 4d2ea1906..02e3a2d41 100644 --- a/examples/spaces_transforms/translation_scaling_image.py +++ b/examples/spaces_transforms/translation_scaling_image.py @@ -6,7 +6,7 @@ plots an image to show these mappings. """ -# test_example = True +# test_example = true # sphinx_gallery_pygfx_docs = 'screenshot' import numpy as np diff --git a/examples/spaces_transforms/translation_scaling_line.py b/examples/spaces_transforms/translation_scaling_line.py index fbe6cd153..6afbfc11c 100644 --- a/examples/spaces_transforms/translation_scaling_line.py +++ b/examples/spaces_transforms/translation_scaling_line.py @@ -6,7 +6,7 @@ plots a line to show these mappings. """ -# test_example = True +# test_example = true # sphinx_gallery_pygfx_docs = 'screenshot' import numpy as np diff --git a/examples/spaces_transforms/translation_scaling_rotation_image.py b/examples/spaces_transforms/translation_scaling_rotation_image.py index 7b3fe129d..d0060401f 100644 --- a/examples/spaces_transforms/translation_scaling_rotation_image.py +++ b/examples/spaces_transforms/translation_scaling_rotation_image.py @@ -6,7 +6,7 @@ plots an image to show these mappings. """ -# test_example = True +# test_example = true # sphinx_gallery_pygfx_docs = 'screenshot' import numpy as np diff --git a/examples/spaces_transforms/translation_scaling_rotation_line.py b/examples/spaces_transforms/translation_scaling_rotation_line.py index 8ad31a034..e4c245a8e 100644 --- a/examples/spaces_transforms/translation_scaling_rotation_line.py +++ b/examples/spaces_transforms/translation_scaling_rotation_line.py @@ -6,7 +6,7 @@ plots a line to show these mappings. """ -# test_example = True +# test_example = true # sphinx_gallery_pygfx_docs = 'screenshot' import numpy as np From cd0f21672d877090ebcbe55d7d26a76712abb7de Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 17 Dec 2025 01:06:02 -0500 Subject: [PATCH 27/34] update names --- fastplotlib/tools/_cursor.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/fastplotlib/tools/_cursor.py b/fastplotlib/tools/_cursor.py index c72d937c2..6b7946cd0 100644 --- a/fastplotlib/tools/_cursor.py +++ b/fastplotlib/tools/_cursor.py @@ -68,9 +68,9 @@ def __init__( self.alpha = alpha self.size_space = size_space - self._pause = False + self._enabled = True - self._position = [0, 0] + self._position: list[float, float] = [0.0, 0.0] @property def mode(self) -> Literal["crosshair", "marker"]: @@ -205,13 +205,13 @@ def alpha(self, value: float): self._alpha = value @property - def pause(self) -> bool: - """pause the cursor, if True the cursor will not respond to mouse pointer events""" - return self._pause + def enabled(self) -> bool: + """enable/disable the cursor, if False the cursor will not respond to mouse pointer events""" + return self._enabled - @pause.setter - def pause(self, pause: bool): - self._pause = bool(pause) + @enabled.setter + def enabled(self, pause: bool): + self._enabled = bool(pause) @property def position(self) -> tuple[float, float]: @@ -409,7 +409,7 @@ def _create_crosshair(self) -> pygfx.Group: return lines def _pointer_moved(self, subplot, ev: pygfx.PointerEvent): - if self.pause: + if not self.enabled: return pos = subplot.map_screen_to_world(ev) From 70c8088c5e8373357b72b77ea97390c4129c0931 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 17 Dec 2025 01:07:30 -0500 Subject: [PATCH 28/34] update cursor examples --- examples/misc/cursors.py | 4 +-- examples/misc/cursors_marker.py | 47 +++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 examples/misc/cursors_marker.py diff --git a/examples/misc/cursors.py b/examples/misc/cursors.py index 94cfd367a..030c254a4 100644 --- a/examples/misc/cursors.py +++ b/examples/misc/cursors.py @@ -3,6 +3,7 @@ =========== Example with multiple subplots and an interactive cursor that marks the same position in each subplot. +Default crosshair mode. """ # test_example = False @@ -11,7 +12,6 @@ import numpy as np import fastplotlib as fpl import imageio.v3 as iio -from pylinalg import vec_transform, mat_combine # get some data @@ -30,7 +30,7 @@ figure[1, 1].add_line(line_data, colors="r") # creator a cursor in crosshair mode -cursor = fpl.Cursor(mode="crosshair", color="w") +cursor = fpl.Cursor(color="w") # add all subplots to the cursor for subplot in figure: diff --git a/examples/misc/cursors_marker.py b/examples/misc/cursors_marker.py new file mode 100644 index 000000000..1b5437fe4 --- /dev/null +++ b/examples/misc/cursors_marker.py @@ -0,0 +1,47 @@ +""" +Cursor tool, marker mode +======================== + +Example with multiple subplots and an interactive cursor that marks the same position in each subplot. Marker mode. +""" + +# test_example = False +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl +import imageio.v3 as iio + + +# get some data +img1 = iio.imread("imageio:camera.png") +img2 = iio.imread("imageio:wikkie.png") +scatter_data = np.random.normal(loc=256, scale=(50), size=(500)).reshape(250, 2) +line_data = np.random.rand(100, 2) * 512 + +# create a figure +figure = fpl.Figure(shape=(2, 2), size=(700, 750)) + +# plot data +figure[0, 0].add_image(img1, cmap="viridis") +figure[0, 1].add_image(img2) +figure[1, 0].add_scatter(scatter_data, sizes=5, colors="r") +figure[1, 1].add_line(line_data, colors="r") + +# creator a cursor in crosshair mode +cursor = fpl.Cursor(mode="marker", color="w", size=15) + +# add all subplots to the cursor +for subplot in figure: + cursor.add_subplot(subplot) + +# you can also set the cursor position programmatically +cursor.position = (256, 256) + +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() From d4c3eb7aeb7b3ba8ba896d46a736da9af7880ea8 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 17 Dec 2025 01:10:06 -0500 Subject: [PATCH 29/34] new ground truth screenshots for transforms --- examples/screenshots/no-imgui-rotation_image.png | 3 +++ examples/screenshots/no-imgui-rotation_line.png | 3 +++ examples/screenshots/no-imgui-scaling_image.png | 3 +++ examples/screenshots/no-imgui-scaling_line.png | 3 +++ examples/screenshots/no-imgui-translate_image.png | 3 +++ examples/screenshots/no-imgui-translate_line.png | 3 +++ examples/screenshots/no-imgui-translation_scaling_image.png | 3 +++ examples/screenshots/no-imgui-translation_scaling_line.png | 3 +++ .../no-imgui-translation_scaling_rotation_image.png | 3 +++ .../screenshots/no-imgui-translation_scaling_rotation_line.png | 3 +++ examples/screenshots/rotation_image.png | 3 +++ examples/screenshots/rotation_line.png | 3 +++ examples/screenshots/scaling_image.png | 3 +++ examples/screenshots/scaling_line.png | 3 +++ examples/screenshots/translate_image.png | 3 +++ examples/screenshots/translate_line.png | 3 +++ examples/screenshots/translation_scaling_image.png | 3 +++ examples/screenshots/translation_scaling_line.png | 3 +++ examples/screenshots/translation_scaling_rotation_image.png | 3 +++ examples/screenshots/translation_scaling_rotation_line.png | 3 +++ 20 files changed, 60 insertions(+) create mode 100644 examples/screenshots/no-imgui-rotation_image.png create mode 100644 examples/screenshots/no-imgui-rotation_line.png create mode 100644 examples/screenshots/no-imgui-scaling_image.png create mode 100644 examples/screenshots/no-imgui-scaling_line.png create mode 100644 examples/screenshots/no-imgui-translate_image.png create mode 100644 examples/screenshots/no-imgui-translate_line.png create mode 100644 examples/screenshots/no-imgui-translation_scaling_image.png create mode 100644 examples/screenshots/no-imgui-translation_scaling_line.png create mode 100644 examples/screenshots/no-imgui-translation_scaling_rotation_image.png create mode 100644 examples/screenshots/no-imgui-translation_scaling_rotation_line.png create mode 100644 examples/screenshots/rotation_image.png create mode 100644 examples/screenshots/rotation_line.png create mode 100644 examples/screenshots/scaling_image.png create mode 100644 examples/screenshots/scaling_line.png create mode 100644 examples/screenshots/translate_image.png create mode 100644 examples/screenshots/translate_line.png create mode 100644 examples/screenshots/translation_scaling_image.png create mode 100644 examples/screenshots/translation_scaling_line.png create mode 100644 examples/screenshots/translation_scaling_rotation_image.png create mode 100644 examples/screenshots/translation_scaling_rotation_line.png diff --git a/examples/screenshots/no-imgui-rotation_image.png b/examples/screenshots/no-imgui-rotation_image.png new file mode 100644 index 000000000..3780dc87a --- /dev/null +++ b/examples/screenshots/no-imgui-rotation_image.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:62b9923128bebb489e7da928c5d3fc212cc6228b58dbdaf4bcbaabf0ad12b28c +size 50262 diff --git a/examples/screenshots/no-imgui-rotation_line.png b/examples/screenshots/no-imgui-rotation_line.png new file mode 100644 index 000000000..3eddc6ff2 --- /dev/null +++ b/examples/screenshots/no-imgui-rotation_line.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c922741a05bc5ab2f6bf165b909bb14d443d93517700ceba522aa05b8aa26df4 +size 42402 diff --git a/examples/screenshots/no-imgui-scaling_image.png b/examples/screenshots/no-imgui-scaling_image.png new file mode 100644 index 000000000..5d3dbeaff --- /dev/null +++ b/examples/screenshots/no-imgui-scaling_image.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d0481db08929abe0622f933b349746f40077fe930d86deed1a1ab08563ea310b +size 45587 diff --git a/examples/screenshots/no-imgui-scaling_line.png b/examples/screenshots/no-imgui-scaling_line.png new file mode 100644 index 000000000..8fd232e31 --- /dev/null +++ b/examples/screenshots/no-imgui-scaling_line.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:71940e060068b1941f81e8aa66dfb9bae19aa60bd3c4ac848f65ecf42708dc85 +size 43106 diff --git a/examples/screenshots/no-imgui-translate_image.png b/examples/screenshots/no-imgui-translate_image.png new file mode 100644 index 000000000..a875ef91a --- /dev/null +++ b/examples/screenshots/no-imgui-translate_image.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0995cdaf81fc5a25ebdd54545b7be3e4edca6c25896c2aa5ba9d7e4ab0b240e8 +size 44246 diff --git a/examples/screenshots/no-imgui-translate_line.png b/examples/screenshots/no-imgui-translate_line.png new file mode 100644 index 000000000..211c4a5d0 --- /dev/null +++ b/examples/screenshots/no-imgui-translate_line.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b8b3e79aeb1d8d0622e0928932bd98a7ee8a77d370dc7aecc7c1b923608497d7 +size 45889 diff --git a/examples/screenshots/no-imgui-translation_scaling_image.png b/examples/screenshots/no-imgui-translation_scaling_image.png new file mode 100644 index 000000000..a5c7a71d2 --- /dev/null +++ b/examples/screenshots/no-imgui-translation_scaling_image.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ca48b15e42f7e5e2f67152a31e58b2869329d361d21b17718528b9f8f16a4c92 +size 45697 diff --git a/examples/screenshots/no-imgui-translation_scaling_line.png b/examples/screenshots/no-imgui-translation_scaling_line.png new file mode 100644 index 000000000..0c7b625c7 --- /dev/null +++ b/examples/screenshots/no-imgui-translation_scaling_line.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1f2311cbd8a719d9c208d6744df56bba6d592f5e650cedc4c1251b7c5cf2c9b9 +size 42714 diff --git a/examples/screenshots/no-imgui-translation_scaling_rotation_image.png b/examples/screenshots/no-imgui-translation_scaling_rotation_image.png new file mode 100644 index 000000000..418ef1ff4 --- /dev/null +++ b/examples/screenshots/no-imgui-translation_scaling_rotation_image.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0035495345247d02c113c362699b930d11240e50c8bc14b4178457d029701629 +size 46978 diff --git a/examples/screenshots/no-imgui-translation_scaling_rotation_line.png b/examples/screenshots/no-imgui-translation_scaling_rotation_line.png new file mode 100644 index 000000000..15124c89e --- /dev/null +++ b/examples/screenshots/no-imgui-translation_scaling_rotation_line.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:de3cac77e9f6601abf050b67fdd15f14e3fcfa691cc06284379830e9be57f3d4 +size 45515 diff --git a/examples/screenshots/rotation_image.png b/examples/screenshots/rotation_image.png new file mode 100644 index 000000000..85312949a --- /dev/null +++ b/examples/screenshots/rotation_image.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a6399d67da50abbdf7af4430f2bc4264f893d239eb661d3664ead87563169bee +size 51598 diff --git a/examples/screenshots/rotation_line.png b/examples/screenshots/rotation_line.png new file mode 100644 index 000000000..08b09a417 --- /dev/null +++ b/examples/screenshots/rotation_line.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4f66b0698d2f1fc2481767413377e21fa57bc80c9b34aa3e722a63902fc34a1e +size 44395 diff --git a/examples/screenshots/scaling_image.png b/examples/screenshots/scaling_image.png new file mode 100644 index 000000000..f0b2bdb8b --- /dev/null +++ b/examples/screenshots/scaling_image.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e820b72d87156d215f895c0668bef80a4a2d7cafeb1435a5df1ac7515d2336ef +size 47270 diff --git a/examples/screenshots/scaling_line.png b/examples/screenshots/scaling_line.png new file mode 100644 index 000000000..48e71b9ab --- /dev/null +++ b/examples/screenshots/scaling_line.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9f611bbbf7c05b754a35065f7f2117fc8062f0024d209ae1fab049f6e7f2d3b8 +size 44380 diff --git a/examples/screenshots/translate_image.png b/examples/screenshots/translate_image.png new file mode 100644 index 000000000..c0e6dd76e --- /dev/null +++ b/examples/screenshots/translate_image.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2c7fb592ea62eed3be0ff6c7650d176513304e455130b64caebcefc7e5fe48e9 +size 45572 diff --git a/examples/screenshots/translate_line.png b/examples/screenshots/translate_line.png new file mode 100644 index 000000000..4c64bbd74 --- /dev/null +++ b/examples/screenshots/translate_line.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a62c00847ea65187c7025c4eb0ad80767e1609e37d88602424531cbc0c7429a2 +size 46717 diff --git a/examples/screenshots/translation_scaling_image.png b/examples/screenshots/translation_scaling_image.png new file mode 100644 index 000000000..b7d26c937 --- /dev/null +++ b/examples/screenshots/translation_scaling_image.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:393d26c54bb9a0ac690411df262c3b9c3273274edf4787a18f057a1c3e02389e +size 47386 diff --git a/examples/screenshots/translation_scaling_line.png b/examples/screenshots/translation_scaling_line.png new file mode 100644 index 000000000..e3c6835b6 --- /dev/null +++ b/examples/screenshots/translation_scaling_line.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0593fa32a6990c2e05aad1b9314b912dc3e196b499938be49fc7074e610581e0 +size 44521 diff --git a/examples/screenshots/translation_scaling_rotation_image.png b/examples/screenshots/translation_scaling_rotation_image.png new file mode 100644 index 000000000..cd384ba15 --- /dev/null +++ b/examples/screenshots/translation_scaling_rotation_image.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:42352d3bedbb42fdac5e45a789520e9f7be75748a32b12ceea7edabd4f17c500 +size 47418 diff --git a/examples/screenshots/translation_scaling_rotation_line.png b/examples/screenshots/translation_scaling_rotation_line.png new file mode 100644 index 000000000..ea92cdd09 --- /dev/null +++ b/examples/screenshots/translation_scaling_rotation_line.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:25b9c03c40a1b5c91df269f402b953986d996a95660f0c5f4d85c8ef31d479a8 +size 46453 From edca6948d63bd73538637215deb42911abd9f968 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 17 Dec 2025 01:12:15 -0500 Subject: [PATCH 30/34] black --- fastplotlib/tools/_textbox.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/fastplotlib/tools/_textbox.py b/fastplotlib/tools/_textbox.py index b1ac4db2a..46a468ae7 100644 --- a/fastplotlib/tools/_textbox.py +++ b/fastplotlib/tools/_textbox.py @@ -49,12 +49,12 @@ class MeshMasks: class TextBox: def __init__( - self, - font_size: int = 12, - text_color: str | pygfx.Color | tuple = "w", - background_color: str | pygfx.Color | tuple = (0.1, 0.1, 0.3, 0.95), - outline_color: str | pygfx.Color | tuple = (0.8, 0.8, 1.0, 1.0), - padding: tuple[float, float] = (5, 5), + self, + font_size: int = 12, + text_color: str | pygfx.Color | tuple = "w", + background_color: str | pygfx.Color | tuple = (0.1, 0.1, 0.3, 0.95), + outline_color: str | pygfx.Color | tuple = (0.8, 0.8, 1.0, 1.0), + padding: tuple[float, float] = (5, 5), ): """ Create a Textbox From 3570fba161442a82620b57441a7409fd5588724d Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 17 Dec 2025 01:34:54 -0500 Subject: [PATCH 31/34] update --- docs/source/api/tools/Cursor.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/api/tools/Cursor.rst b/docs/source/api/tools/Cursor.rst index c8650f3fe..37a706d34 100644 --- a/docs/source/api/tools/Cursor.rst +++ b/docs/source/api/tools/Cursor.rst @@ -24,9 +24,9 @@ Properties Cursor.color Cursor.edge_color Cursor.edge_width + Cursor.enabled Cursor.marker Cursor.mode - Cursor.pause Cursor.position Cursor.size Cursor.size_space From a7bf44c5bf0473d0b811e399015b905723a2df57 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 17 Dec 2025 02:57:09 -0500 Subject: [PATCH 32/34] revert persist kwarg in PlotArea animations stuff --- fastplotlib/layouts/_plot_area.py | 28 ++++------------------------ 1 file changed, 4 insertions(+), 24 deletions(-) diff --git a/fastplotlib/layouts/_plot_area.py b/fastplotlib/layouts/_plot_area.py index fed48b739..f83dcfbcb 100644 --- a/fastplotlib/layouts/_plot_area.py +++ b/fastplotlib/layouts/_plot_area.py @@ -499,7 +499,6 @@ def add_animations( *funcs: callable, pre_render: bool = True, post_render: bool = False, - persist: bool = False, ): """ Add function(s) that are called on every render cycle. @@ -516,10 +515,6 @@ def add_animations( post_render: bool, default ``False``, optional keyword-only argument if true, these function(s) are called after a render cycle - persist: bool, default False - if True, the animation function will persist even if ``clear_animations()`` is called. - Such functions must be removed explicitly using ``remove_animation()`` - """ for f in funcs: if not callable(f): @@ -530,8 +525,6 @@ def add_animations( self._animate_funcs_pre += funcs if post_render: self._animate_funcs_post += funcs - if persist: - self._animate_funcs_persist += funcs def remove_animation(self, func): """ @@ -556,9 +549,6 @@ def remove_animation(self, func): if func in self._animate_funcs_post: self._animate_funcs_post.remove(func) - if func in self._animate_funcs_persist: - self._animate_funcs_persist.remove(func) - def clear_animations(self, removal: str = None): """ Remove animation functions. @@ -569,37 +559,27 @@ def clear_animations(self, removal: str = None): The type of animation functions to clear. One of 'pre' or 'post'. If `None`, removes all animation functions. """ - to_remove = list() - if removal is None: # remove all for func in self._animate_funcs_pre: - to_remove.append(func) + self._animate_funcs_pre.remove(func) for func in self._animate_funcs_post: - to_remove.append(func) - + self._animate_funcs_post.remove(func) elif removal == "pre": # only pre for func in self._animate_funcs_pre: - to_remove.append(func) - + self._animate_funcs_pre.remove(func) elif removal == "post": # only post for func in self._animate_funcs_post: - to_remove.append(func) + self._animate_funcs_post.remove(func) else: raise ValueError( f"Animation type: {removal} must be one of 'pre' or 'post'. To remove all animation " f"functions, pass `type=None`" ) - for func in to_remove: - if func in self._animate_funcs_persist: - # skip - continue - self.remove_animation(func) - def _sort_images_by_depth(self): """ In general, we want to avoid setting the offset of a graphic, because the From fba524792d09fe0b14c97f3e8193c71cb4a1e231 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 21 Dec 2025 23:46:32 -0800 Subject: [PATCH 33/34] fix --- fastplotlib/graphics/image_volume.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/fastplotlib/graphics/image_volume.py b/fastplotlib/graphics/image_volume.py index 6656c874f..0dc4ee28b 100644 --- a/fastplotlib/graphics/image_volume.py +++ b/fastplotlib/graphics/image_volume.py @@ -421,14 +421,14 @@ def reset_vmin_vmax(self): self.vmax = vmax def format_pick_info(self, pick_info: dict) -> str: - col, row = pick_info["index"] - if ev.graphic.data.value.ndim == 2: - val = ev.graphic.data[row, col] + col, row, z = pick_info["index"] + if self.data.value.ndim == 3: + val = self.data[z, row, col] info = f"{val:.4g}" else: info = "\n".join( f"{channel}: {val:.4g}" - for channel, val in zip("rgba", ev.graphic.data[row, col]) + for channel, val in zip("rgba", self.data[z, row, col]) ) return info From 201b75c2a2bafce0c2649c1b3b41770fce3dffc4 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 21 Dec 2025 23:47:22 -0800 Subject: [PATCH 34/34] message for image volumes --- fastplotlib/graphics/image_volume.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/fastplotlib/graphics/image_volume.py b/fastplotlib/graphics/image_volume.py index 0dc4ee28b..db8f29eaa 100644 --- a/fastplotlib/graphics/image_volume.py +++ b/fastplotlib/graphics/image_volume.py @@ -421,6 +421,8 @@ def reset_vmin_vmax(self): self.vmax = vmax def format_pick_info(self, pick_info: dict) -> str: + return "image volume tooltips supported in next version" + col, row, z = pick_info["index"] if self.data.value.ndim == 3: val = self.data[z, row, col]