From 9205bd19cf508af18e8ad5f0f035a6da0882da68 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 11 Jul 2023 05:22:31 -0400 Subject: [PATCH 1/2] better PlotArea selector indexing error message, add __len__ to PlotArea --- fastplotlib/layouts/_base.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/fastplotlib/layouts/_base.py b/fastplotlib/layouts/_base.py index 5d9d4a6e9..69f50800e 100644 --- a/fastplotlib/layouts/_base.py +++ b/fastplotlib/layouts/_base.py @@ -246,7 +246,7 @@ def add_graphic(self, graphic: Graphic, center: bool = True): """ self._add_or_insert_graphic(graphic=graphic, center=center, action="add") - graphic.position_z = len(self._graphics) + graphic.position_z = len(self) def insert_graphic( self, @@ -505,10 +505,15 @@ def __getitem__(self, name: str): graphic_names = list() for g in self.graphics: graphic_names.append(g.name) + + selector_names = list() for s in self.selectors: - graphic_names.append(s.name) + selector_names.append(s.name) + raise IndexError( - f"no graphic of given name, the current graphics are:\n {graphic_names}" + f"No graphic or selector of given name.\n" + f"The current graphics are:\n {graphic_names}\n" + f"The current selectors are:\n {selector_names}" ) def __str__(self): @@ -529,3 +534,6 @@ def __repr__(self): f"\t{newline.join(graphic.__repr__() for graphic in self.graphics)}" f"\n" ) + + def __len__(self) -> int: + return len(self._graphics) + len(self.selectors) From 192b2b9c2a4830b4cfd505843c678ad2b52238be Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 11 Jul 2023 05:23:28 -0400 Subject: [PATCH 2/2] add polygon selector tool --- fastplotlib/graphics/selectors/__init__.py | 2 + fastplotlib/graphics/selectors/_polygon.py | 138 +++++++++++++++++++++ fastplotlib/layouts/_plot.py | 17 +++ 3 files changed, 157 insertions(+) create mode 100644 fastplotlib/graphics/selectors/_polygon.py diff --git a/fastplotlib/graphics/selectors/__init__.py b/fastplotlib/graphics/selectors/__init__.py index 83162644e..1fb0c453e 100644 --- a/fastplotlib/graphics/selectors/__init__.py +++ b/fastplotlib/graphics/selectors/__init__.py @@ -1,10 +1,12 @@ from ._linear import LinearSelector from ._linear_region import LinearRegionSelector +from ._polygon import PolygonSelector from ._sync import Synchronizer __all__ = [ "LinearSelector", "LinearRegionSelector", + "PolygonSelector", "Synchronizer", ] diff --git a/fastplotlib/graphics/selectors/_polygon.py b/fastplotlib/graphics/selectors/_polygon.py new file mode 100644 index 000000000..aee409542 --- /dev/null +++ b/fastplotlib/graphics/selectors/_polygon.py @@ -0,0 +1,138 @@ +from typing import * + +import numpy as np + +import pygfx + +from ._base_selector import BaseSelector, MoveInfo +from .._base import Graphic + + +class PolygonSelector(Graphic, BaseSelector): + def __init__( + self, + edge_color="magenta", + edge_width: float = 3, + parent: Graphic = None, + name: str = None, + ): + Graphic.__init__(self, name=name) + + self.parent = parent + + group = pygfx.Group() + + self._set_world_object(group) + + self.edge_color = edge_color + self.edge_width = edge_width + + self._move_info: MoveInfo = None + + self._current_mode = None + + def get_vertices(self) -> np.ndarray: + """Get the vertices for the polygon""" + vertices = list() + for child in self.world_object.children: + vertices.append(child.geometry.positions.data[:, :2]) + + return np.vstack(vertices) + + def _add_plot_area_hook(self, plot_area): + self._plot_area = plot_area + + # click to add new segment + self._plot_area.renderer.add_event_handler(self._add_segment, "click") + + # pointer move to change endpoint of segment + self._plot_area.renderer.add_event_handler(self._move_segment_endpoint, "pointer_move") + + # click to finish existing segment + self._plot_area.renderer.add_event_handler(self._finish_segment, "click") + + # double click to finish polygon + self._plot_area.renderer.add_event_handler(self._finish_polygon, "double_click") + + self.position_z = len(self._plot_area) + 10 + + def _add_segment(self, ev): + """After click event, adds a new line segment""" + self._current_mode = "add" + + last_position = self._plot_area.map_screen_to_world(ev) + self._move_info = MoveInfo(last_position=last_position, source=None) + + # line with same position for start and end until mouse moves + data = np.array([last_position, last_position]) + + new_line = pygfx.Line( + geometry=pygfx.Geometry(positions=data.astype(np.float32)), + material=pygfx.LineMaterial(thickness=self.edge_width, color=pygfx.Color(self.edge_color)) + ) + + self.world_object.add(new_line) + + def _move_segment_endpoint(self, ev): + """After mouse pointer move event, moves endpoint of current line segment""" + if self._move_info is None: + return + self._current_mode = "move" + + world_pos = self._plot_area.map_screen_to_world(ev) + + if world_pos is None: + return + + # change endpoint + self.world_object.children[-1].geometry.positions.data[1] = np.array([world_pos]).astype(np.float32) + self.world_object.children[-1].geometry.positions.update_range() + + def _finish_segment(self, ev): + """After click event, ends a line segment""" + # should start a new segment + if self._move_info is None: + return + + # since both _add_segment and _finish_segment use the "click" callback + # this is to block _finish_segment right after a _add_segment call + if self._current_mode == "add": + return + + # just make move info None so that _move_segment_endpoint is not called + # and _add_segment gets triggered for "click" + self._move_info = None + + self._current_mode = "finish-segment" + + def _finish_polygon(self, ev): + """finishes the polygon, disconnects events""" + world_pos = self._plot_area.map_screen_to_world(ev) + + if world_pos is None: + return + + # make new line to connect first and last vertices + data = np.vstack([ + world_pos, + self.world_object.children[0].geometry.positions.data[0] + ]) + + print(data) + + new_line = pygfx.Line( + geometry=pygfx.Geometry(positions=data.astype(np.float32)), + material=pygfx.LineMaterial(thickness=self.edge_width, color=pygfx.Color(self.edge_color)) + ) + + self.world_object.add(new_line) + + handlers = { + self._add_segment: "click", + self._move_segment_endpoint: "pointer_move", + self._finish_segment: "click", + self._finish_polygon: "double_click" + } + + for handler, event in handlers.items(): + self._plot_area.renderer.remove_event_handler(handler, event) diff --git a/fastplotlib/layouts/_plot.py b/fastplotlib/layouts/_plot.py index 2b5cc51b7..268109abb 100644 --- a/fastplotlib/layouts/_plot.py +++ b/fastplotlib/layouts/_plot.py @@ -11,6 +11,7 @@ from ._subplot import Subplot from ._record_mixin import RecordMixin +from ..graphics.selectors import PolygonSelector class Plot(Subplot, RecordMixin): @@ -211,6 +212,15 @@ def __init__(self, plot: Plot): layout=Layout(width="auto"), tooltip="flip", ) + + self.add_polygon_button = Button( + value=False, + disabled=False, + icon="draw-polygon", + layout=Layout(width="auto"), + tooltip="add PolygonSelector" + ) + self.record_button = ToggleButton( value=False, disabled=False, @@ -226,6 +236,7 @@ def __init__(self, plot: Plot): self.panzoom_controller_button, self.maintain_aspect_button, self.flip_camera_button, + self.add_polygon_button, self.record_button, ] ) @@ -235,6 +246,7 @@ def __init__(self, plot: Plot): self.center_scene_button.on_click(self.center_scene) self.maintain_aspect_button.observe(self.maintain_aspect, "value") self.flip_camera_button.on_click(self.flip_camera) + self.add_polygon_button.on_click(self.add_polygon) self.record_button.observe(self.record_plot, "value") def auto_scale(self, obj): @@ -252,6 +264,11 @@ def maintain_aspect(self, obj): def flip_camera(self, obj): self.plot.camera.world.scale_y *= -1 + def add_polygon(self, obj): + ps = PolygonSelector(edge_width=3, edge_color="magenta") + + self.plot.add_graphic(ps, center=False) + def record_plot(self, obj): if self.record_button.value: try: