From 46ccae36c620fa0b84d68f5953544f13801f6cfc Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Mon, 26 May 2025 22:58:40 +0200 Subject: [PATCH 01/19] Implement PolygonSelector --- docs/source/api/graphics/ImageGraphic.rst | 1 + docs/source/api/graphics/LineCollection.rst | 1 + docs/source/api/graphics/LineGraphic.rst | 1 + docs/source/api/graphics/LineStack.rst | 1 + .../graphics/features/_selection_features.py | 94 +++++++++++ fastplotlib/graphics/image.py | 45 +++++- fastplotlib/graphics/line.py | 29 +++- fastplotlib/graphics/line_collection.py | 32 +++- fastplotlib/graphics/selectors/_polygon.py | 153 +++++++++--------- 9 files changed, 280 insertions(+), 77 deletions(-) diff --git a/docs/source/api/graphics/ImageGraphic.rst b/docs/source/api/graphics/ImageGraphic.rst index dd5ff1ccc..21e05f31f 100644 --- a/docs/source/api/graphics/ImageGraphic.rst +++ b/docs/source/api/graphics/ImageGraphic.rst @@ -48,6 +48,7 @@ Methods ImageGraphic.add_linear_region_selector ImageGraphic.add_linear_selector ImageGraphic.add_rectangle_selector + ImageGraphic.add_polygon_selector ImageGraphic.clear_event_handlers ImageGraphic.remove_event_handler ImageGraphic.reset_vmin_vmax diff --git a/docs/source/api/graphics/LineCollection.rst b/docs/source/api/graphics/LineCollection.rst index ad4b7f929..ab10afe86 100644 --- a/docs/source/api/graphics/LineCollection.rst +++ b/docs/source/api/graphics/LineCollection.rst @@ -53,6 +53,7 @@ Methods LineCollection.add_linear_region_selector LineCollection.add_linear_selector LineCollection.add_rectangle_selector + LineCollection.add_polygon_selector LineCollection.clear_event_handlers LineCollection.remove_event_handler LineCollection.remove_graphic diff --git a/docs/source/api/graphics/LineGraphic.rst b/docs/source/api/graphics/LineGraphic.rst index 4302ab56c..02551c034 100644 --- a/docs/source/api/graphics/LineGraphic.rst +++ b/docs/source/api/graphics/LineGraphic.rst @@ -47,6 +47,7 @@ Methods LineGraphic.add_linear_region_selector LineGraphic.add_linear_selector LineGraphic.add_rectangle_selector + LineGraphic.add_polygon_selector LineGraphic.clear_event_handlers LineGraphic.remove_event_handler LineGraphic.rotate diff --git a/docs/source/api/graphics/LineStack.rst b/docs/source/api/graphics/LineStack.rst index db060a4c2..776cf9523 100644 --- a/docs/source/api/graphics/LineStack.rst +++ b/docs/source/api/graphics/LineStack.rst @@ -53,6 +53,7 @@ Methods LineStack.add_linear_region_selector LineStack.add_linear_selector LineStack.add_rectangle_selector + LineStack.add_polygon_selector LineStack.clear_event_handlers LineStack.remove_event_handler LineStack.remove_graphic diff --git a/fastplotlib/graphics/features/_selection_features.py b/fastplotlib/graphics/features/_selection_features.py index 233353401..68aa05e3c 100644 --- a/fastplotlib/graphics/features/_selection_features.py +++ b/fastplotlib/graphics/features/_selection_features.py @@ -1,6 +1,7 @@ from typing import Sequence import numpy as np +import pygfx as gfx from ...utils import mesh_masks from ._base import GraphicFeature, GraphicFeatureEvent, block_reentrance @@ -340,3 +341,96 @@ def set_value(self, selector, value: Sequence[float]): # calls any events self._call_event_handlers(event) + + +class PolygonSelectionFeature(GraphicFeature): + event_info_spec = [ + { + "dict key": "value", + "type": "np.ndarray", + "description": "new array of points that represents the polygon selection", + }, + ] + + event_extra_attrs = [ + { + "attribute": "get_selected_indices", + "type": "callable", + "description": "returns indices under the selector", + }, + { + "attribute": "get_selected_data", + "type": "callable", + "description": "returns data under the selector", + }, + ] + + def __init__( + self, + value: Sequence[tuple[float]], + limits: tuple[float, float, float, float], + ): + super().__init__() + + self._limits = limits + self._value = np.asarray(value).reshape(-1, 3).astype(float) + + @property + def value(self) -> np.ndarray[float]: + """ + The array of the polygon, in data space + """ + return self._value + + @block_reentrance + def set_value(self, selector, value: Sequence[tuple[float]]): + """ + Set the selection of the rectangle selector. + + Parameters + ---------- + selector: PolygonSelector + + value: array + new values (3D points) of the selection + """ + + value = np.asarray(value, dtype=np.float32) + + if not value.shape[1] == 3: + raise TypeError( + "Selection must be an array, tuple, list, or sequence of the shape Nx3." + ) + + # # clip values if they are beyond the limits + # value[:, 0] = value[:2].clip(self._limits[0], self._limits[1]) + # # clip y + # value[:, 1] = value[2:].clip(self._limits[2], self._limits[3]) + + self._value = value + + # TODO: Update the fill mesh + # selector.fill.geometry.positions = ... + + edge_geometry = selector.edge.geometry + + # Need larger buffer? + if len(value) > edge_geometry.positions.nitems: + arr = np.zeros((edge_geometry.positions.nitems * 2, 3), np.float32) + edge_geometry.positions = gfx.Buffer(arr) + + edge_geometry.positions.data[: len(value)] = value + edge_geometry.positions.draw_range = 0, len(value) + edge_geometry.positions.update_full() + + # send event + if len(self._event_handlers) < 1: + return + + event = GraphicFeatureEvent("selection", {"value": self.value}) + + event.get_selected_indices = selector.get_selected_indices + event.get_selected_data = selector.get_selected_data + + # calls any events + self._call_event_handlers(event) diff --git a/fastplotlib/graphics/image.py b/fastplotlib/graphics/image.py index b2a8048b3..8bbc343f2 100644 --- a/fastplotlib/graphics/image.py +++ b/fastplotlib/graphics/image.py @@ -5,7 +5,7 @@ from ..utils import quick_min_max from ._base import Graphic -from .selectors import LinearSelector, LinearRegionSelector, RectangleSelector +from .selectors import LinearSelector, LinearRegionSelector, RectangleSelector, PolygonSelector from .features import ( TextureArray, ImageCmap, @@ -437,3 +437,46 @@ def add_rectangle_selector( selector.offset = selector.offset + (0.0, 0.0, self.offset[-1] + 1) return selector + + def add_polygon_selector( + self, + selection: List[tuple[float, float]] = None, + fill_color=(0, 0, 0.35, 0.2), + **kwargs, + ) -> PolygonSelector: + """ + Add a :class:`.PolygonSelector`. + + Selectors are just ``Graphic`` objects, so you can manage, remove, or delete them + from a plot area just like any other ``Graphic``. + + Parameters + ---------- + selection: List of positions, optional + initial points for the polygon + + """ + # default selection is 25% of the diagonal + if selection is None: + diagonal = math.sqrt( + self._data.value.shape[0] ** 2 + self._data.value.shape[1] ** 2 + ) + + selection = (0, int(diagonal / 4), 0, int(diagonal / 4)) + + # min/max limits are image shape + # rows are ys, columns are xs + limits = (0, self._data.value.shape[1], 0, self._data.value.shape[0]) + + selector = PolygonSelector( + fill_color=fill_color, + parent=self, + **kwargs, + ) + + self._plot_area.add_graphic(selector, center=False) + + # place above this graphic + selector.offset = selector.offset + (0.0, 0.0, self.offset[-1] + 1) + + return selector diff --git a/fastplotlib/graphics/line.py b/fastplotlib/graphics/line.py index ab5b94146..be3cae857 100644 --- a/fastplotlib/graphics/line.py +++ b/fastplotlib/graphics/line.py @@ -5,7 +5,7 @@ import pygfx from ._positions_base import PositionsGraphic -from .selectors import LinearRegionSelector, LinearSelector, RectangleSelector +from .selectors import LinearRegionSelector, LinearSelector, RectangleSelector, PolygonSelector from .features import ( Thickness, VertexPositions, @@ -245,7 +245,7 @@ def add_rectangle_selector( self, selection: tuple[float, float, float, float] = None, **kwargs, - ) -> RectangleSelector: + ) -> RectangleSelector: """ Add a :class:`.RectangleSelector`. @@ -288,6 +288,31 @@ def add_rectangle_selector( return selector + def add_polygon_selector( + self, + selection: List[tuple[float, float]] = None, + **kwargs, + ) -> PolygonSelector: + """ + Add a :class:`.PolygonSelector`. + + Selectors are just ``Graphic`` objects, so you can manage, remove, or delete them from a + plot area just like any other ``Graphic``. + + Parameters + ---------- + selection: (float, float, float, float), optional + initial (xmin, xmax, ymin, ymax) of the selection + """ + selector = PolygonSelector( + parent=self, + **kwargs, + ) + + self._plot_area.add_graphic(selector, center=False) + + return selector + # TODO: this method is a bit of a mess, can refactor later def _get_linear_selector_init_args( self, axis: str, padding diff --git a/fastplotlib/graphics/line_collection.py b/fastplotlib/graphics/line_collection.py index de4139679..3a3559763 100644 --- a/fastplotlib/graphics/line_collection.py +++ b/fastplotlib/graphics/line_collection.py @@ -7,7 +7,7 @@ from ..utils import parse_cmap_values from ._collection_base import CollectionIndexer, GraphicCollection, CollectionFeature from .line import LineGraphic -from .selectors import LinearRegionSelector, LinearSelector, RectangleSelector +from .selectors import LinearRegionSelector, LinearSelector, RectangleSelector, PolygonSelector class _LineCollectionProperties: @@ -486,6 +486,36 @@ def add_rectangle_selector( return selector + def add_polygon_selector( + self, + selection: List[tuple[float, float]] = None, + **kwargs, + ) -> PolygonSelector: + """ + Add a :class:`.PolygonSelector`. Selectors are just ``Graphic`` objects, so you can manage, + remove, or delete them from a plot area just like any other ``Graphic``. + + Parameters + ---------- + selection: List of positions, optional + initial points for the polygon + """ + bbox = self.world_object.get_world_bounding_box() + + + if selection is not None: + selection = [] # TODO: fill selection + + + selector = PolygonSelector( + parent=self, + **kwargs, + ) + + self._plot_area.add_graphic(selector, center=False) + + return selector + def _get_linear_selector_init_args(self, axis, padding): # use bbox to get size and center bbox = self.world_object.get_world_bounding_box() diff --git a/fastplotlib/graphics/selectors/_polygon.py b/fastplotlib/graphics/selectors/_polygon.py index 22e42e63e..fb057e991 100644 --- a/fastplotlib/graphics/selectors/_polygon.py +++ b/fastplotlib/graphics/selectors/_polygon.py @@ -1,65 +1,111 @@ from typing import * -import numpy as np +from numbers import Real +import numpy as np import pygfx -from ._base_selector import BaseSelector, MoveInfo from .._base import Graphic +from .._collection_base import GraphicCollection +from ..features._selection_features import PolygonSelectionFeature +from ._base_selector import BaseSelector, MoveInfo class PolygonSelector(BaseSelector): + _features = {"selection": PolygonSelectionFeature} + + @property + def parent(self) -> Graphic | None: + """Graphic that selector is associated with.""" + return self._parent + + @property + def selection(self) -> np.ndarray[float]: + """ + The polygon as an array of 3D points. + """ + return self._selection.value.copy() + + @selection.setter + def selection(self, selection: np.ndarray[float]): + # set (xmin, xmax, ymin, ymax) of the selector in data space + graphic = self._parent + + if isinstance(graphic, GraphicCollection): + pass + + self._selection.set_value(self, selection) + + @property + def limits(self) -> Tuple[float, float, float, float]: + """Return the limits of the selector.""" + return self._limits + + @limits.setter + def limits(self, values: Tuple[float, float, float, float]): + if len(values) != 4 or not all(map(lambda v: isinstance(v, Real), values)): + raise TypeError("limits must be an iterable of two numeric values") + self._limits = tuple( + map(round, values) + ) # if values are close to zero things get weird so round them + self._selection._limits = self._limits + def __init__( self, edge_color="magenta", - edge_width: float = 3, + edge_thickness: float = 4, parent: Graphic = None, name: str = None, ): - self.parent = parent + self._parent = parent + self._move_info: MoveInfo = None + self._current_mode = None - group = pygfx.Group() + BaseSelector.__init__(self, name=name, parent=parent) + self.edge = pygfx.Line( + pygfx.Geometry(positions=np.zeros((4, 3), np.float32)), + pygfx.LineMaterial(thickness=edge_thickness, color=edge_color), + ) + self.edge.geometry.positions.draw_range = 0, 0 + points = pygfx.Points( + self.edge.geometry, + pygfx.PointsMaterial(size=edge_thickness * 2, color=edge_color), + ) + group = pygfx.Group().add(self.edge, points) self._set_world_object(group) - self.edge_color = edge_color - self.edge_width = edge_width - - self._move_info: MoveInfo = None - - self._current_mode = None - - BaseSelector.__init__(self, name=name) + self._selection = PolygonSelectionFeature([], [0, 0, 0, 0]) - 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]) + self.edge_color = edge_color + self.edge_width = edge_thickness - return np.vstack(vertices) + def get_selected_indices(self): + return [] def _fpl_add_plot_area_hook(self, plot_area): self._plot_area = plot_area + self._plot_area.controller.enabled = False + # click to add new segment - self._plot_area.renderer.add_event_handler(self._add_segment, "click") + self._plot_area.renderer.add_event_handler(self._finish_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): + def _finish_segment(self, ev): """After click event, adds a new line segment""" + + # Don't add two points at the same spot + if self._current_mode == "add": + return self._current_mode = "add" position = self._plot_area.map_screen_to_world(ev) @@ -71,18 +117,9 @@ def _add_segment(self, ev): ) # line with same position for start and end until mouse moves - data = np.array([position, 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), - pick_write=True, - ), - ) + data = np.vstack([self.selection, position]) - self.world_object.add(new_line) + self._selection.set_value(self, data) def _move_segment_endpoint(self, ev): """After mouse pointer move event, moves endpoint of current line segment""" @@ -96,27 +133,9 @@ def _move_segment_endpoint(self, ev): 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" + data = self.selection + data[-1] = world_pos + self._selection.set_value(self, data) def _finish_polygon(self, ev): """finishes the polygon, disconnects events""" @@ -125,26 +144,14 @@ def _finish_polygon(self, 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]] - ) - - 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), - pick_write=True, - ), - ) + # TODO: add point to close loop, or + # self.world_object.children[0].material.loop = True - self.world_object.add(new_line) + self._plot_area.controller.enabled = True handlers = { - self._add_segment: "click", - self._move_segment_endpoint: "pointer_move", self._finish_segment: "click", + self._move_segment_endpoint: "pointer_move", self._finish_polygon: "double_click", } From ee2ae104f4d18a85e5cf5435f7c2977ddd344710 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Mon, 26 May 2025 23:28:43 +0200 Subject: [PATCH 02/19] Add triangulation --- .../graphics/features/_selection_features.py | 29 ++- fastplotlib/graphics/selectors/_polygon.py | 23 +- fastplotlib/utils/triangulation.py | 199 ++++++++++++++++++ 3 files changed, 236 insertions(+), 15 deletions(-) create mode 100644 fastplotlib/utils/triangulation.py diff --git a/fastplotlib/graphics/features/_selection_features.py b/fastplotlib/graphics/features/_selection_features.py index 68aa05e3c..507701257 100644 --- a/fastplotlib/graphics/features/_selection_features.py +++ b/fastplotlib/graphics/features/_selection_features.py @@ -5,6 +5,7 @@ from ...utils import mesh_masks from ._base import GraphicFeature, GraphicFeatureEvent, block_reentrance +from ...utils.triangulation import triangulate class LinearSelectionFeature(GraphicFeature): @@ -409,19 +410,31 @@ def set_value(self, selector, value: Sequence[tuple[float]]): self._value = value + if len(value) >= 3: + indices = triangulate(value) + else: + indices = np.zeros((0, 3), np.int32) + # TODO: Update the fill mesh # selector.fill.geometry.positions = ... - edge_geometry = selector.edge.geometry + geometry = selector.geometry # Need larger buffer? - if len(value) > edge_geometry.positions.nitems: - arr = np.zeros((edge_geometry.positions.nitems * 2, 3), np.float32) - edge_geometry.positions = gfx.Buffer(arr) - - edge_geometry.positions.data[: len(value)] = value - edge_geometry.positions.draw_range = 0, len(value) - edge_geometry.positions.update_full() + if len(value) > geometry.positions.nitems: + arr = np.zeros((geometry.positions.nitems * 2, 3), np.float32) + geometry.positions = gfx.Buffer(arr) + if len(indices) > geometry.indices.nitems: + arr = np.zeros((geometry.indices.nitems * 2, 3), np.int32) + geometry.indices = gfx.Buffer(arr) + + geometry.positions.data[: len(value)] = value + geometry.positions.draw_range = 0, len(value) + geometry.positions.update_full() + + geometry.indices.data[: len(indices)] = indices + geometry.indices.draw_range = 0, len(indices) + geometry.indices.update_full() # send event if len(self._event_handlers) < 1: diff --git a/fastplotlib/graphics/selectors/_polygon.py b/fastplotlib/graphics/selectors/_polygon.py index fb057e991..a85660896 100644 --- a/fastplotlib/graphics/selectors/_polygon.py +++ b/fastplotlib/graphics/selectors/_polygon.py @@ -63,16 +63,25 @@ def __init__( BaseSelector.__init__(self, name=name, parent=parent) - self.edge = pygfx.Line( - pygfx.Geometry(positions=np.zeros((4, 3), np.float32)), + self.geometry = pygfx.Geometry( + positions=np.zeros((8, 3), np.float32), + indices=np.zeros((8, 3), np.int32), + ) + self.geometry.positions.draw_range = 0, 0 + self.geometry.indices.draw_range = 0, 0 + + edge = pygfx.Line( + self.geometry, pygfx.LineMaterial(thickness=edge_thickness, color=edge_color), ) - self.edge.geometry.positions.draw_range = 0, 0 points = pygfx.Points( - self.edge.geometry, + self.geometry, pygfx.PointsMaterial(size=edge_thickness * 2, color=edge_color), ) - group = pygfx.Group().add(self.edge, points) + mesh = pygfx.Mesh( + self.geometry, pygfx.MeshBasicMaterial(color=edge_color, opacity=0.2) + ) + group = pygfx.Group().add(edge, points, mesh) self._set_world_object(group) self._selection = PolygonSelectionFeature([], [0, 0, 0, 0]) @@ -144,8 +153,8 @@ def _finish_polygon(self, ev): if world_pos is None: return - # TODO: add point to close loop, or - # self.world_object.children[0].material.loop = True + # close the loop + self.world_object.children[0].material.loop = True self._plot_area.controller.enabled = True diff --git a/fastplotlib/utils/triangulation.py b/fastplotlib/utils/triangulation.py new file mode 100644 index 000000000..762337e02 --- /dev/null +++ b/fastplotlib/utils/triangulation.py @@ -0,0 +1,199 @@ +import logging + +import numpy as np + + +logger = logging.getLogger("fastplotlib") + + +def triangulate(positions, forbidden_edges=None, method="earcut"): + """Triangulate the given vertex positions. + + Returns an Nx3 integer array of faces that form a surface-mesh over the + given positions, where N is the length of the positions minus 2, + expressed in (local) vertex indices. The faces won't contain any + forbidden_edges. + """ + forbidden_edges = forbidden_edges or [] + + # Anticipating more variations ... + if method == "earcut": + method = "earcut1" + + if method == "naive": + faces = _triangulate_naive(positions, forbidden_edges, method) + elif method == "earcut1": + try: + faces = _triangulate_earcut1(positions, forbidden_edges, method) + except RuntimeError as err: + # I think this should not happen, but if I'm wrong, we still produce a result + logger.warning(str(err)) + faces = _triangulate_naive(positions, forbidden_edges, method) + else: + raise ValueError(f"Invalid triangulation method: {method}") + + # Check result + nverts = len(positions) + nfaces = nverts - 2 + assert len(faces) == nfaces + + return faces + + +def _triangulate_naive(positions, forbidden_edges, method): + """This tesselation algorithm simply creates edges from one vertex to all the others.""" + + nverts = len(positions) + nfaces = nverts - 2 + + # Determine a good point to be a reference + forbidden_start_points = set() + for i1, i2 in forbidden_edges: + forbidden_start_points.add(i1) + forbidden_start_points.add(i2) + for i in range(len(positions)): + if i not in forbidden_start_points: + start_point = i + break + else: + # In real meshes this cannot happen, but it can from the POV of this function's API + raise RuntimeError("Cannot tesselate.") + + # Collect the faces + faces = [] + i0 = start_point + for i in range(start_point, start_point + nfaces): + i1 = (i + 1) % nverts + i2 = (i + 2) % nverts + faces.append([i0, i1, i2]) + return np.array(faces, np.int32) + + +def _triangulate_earcut1(positions, forbidden_edges, method, ref_normal=None): + """This tesselation algorithm uses the earcut algorithm plus some + other features to iteratively create faces. + """ + + # This code is originally from https://github.com/pygfx/gfxmorph/blob/main/gfxmorph/meshfuncs.py + # For now I just copied the implementation. + + # Generate new faces, using the contour as a kind of circular queue. + # We will pop vertices from the queue as we "cut ears off the + # polygon", and as such the contour will become smaller until we + # are left with a triangle. At the last step, when we have a quad, + # we need to take care to take the symmetry into account. + # + # Check all three consecutive vertices, and select the best ear. + # How to do this matters for the eventual result, also because + # selecting a particular ear affects the shape of the remaining + # hole. + # + # We want to avoid faces "folding over", prefer more or less equal + # edge lengths, and pick in such an order that in the last step we + # don't have a crap quad. There is a multitude of possible + # algorithms here. In our case we don't necessarily need the best + # solution since we have a rather iterative (and interactive) + # setting. + # + # Favoring one of the two side-vertices to be close to the contour + # center seems to work well, since it quickly triangulates vertices + # that come inwards and which may otherwise cause slither faces. + # It also promotes faces with good aspect ratio, which is also + # emphasised by scaling the score with the distance to the center. + # This score works considerably better than scoring on aspect ratio + # directly. I think this is because it promotes a better order in + # which ears are cut from the contour. + # + # Although this method helps prevent folded faces, it does not + # guarantee their absence. + + new_faces = [] + qq = list(range(len(positions))) + + while len(qq) > 3: + # Calculate center of the current hole + center = positions[qq].sum(axis=0) / len(qq) + + # Get distance of all points to the center + distances = np.linalg.norm(center - positions[qq], axis=1) + + is_quad = len(qq) == 4 + n_iters = 2 if is_quad else len(qq) + best_i = best_ear = -1 + best_score = -999999999999 + + for i in range(n_iters): + # Get indices of "side vertices", and the actual vertex indices + i1 = i - 1 + i2 = i + 1 + if i == 0: + i1 = len(qq) - 1 + elif i == len(qq) - 1: + i2 = 0 + q1, q0, q2 = qq[i1], qq[i], qq[i2] + + # Is this triangle allowed? + if (q1, q2) in forbidden_edges: + continue + + # Get distance of reference vertex to the center. Using the + # plain distance works, but using the distance from the + # edge better prevents folded faces. + # d_factor = distances[i] + d_factor = distance_of_point_to_line_piece( + positions[q0], positions[q1], positions[q2] + ) + if is_quad: + # If we're considering a quad, we must take symmetry + # into account; the best score might actually be the + # worst score when viewed from the opposite end. + # d_alt = distances[i + 2] + d_alt = distance_of_point_to_line_piece( + positions[qq[i + 2]], positions[q2], positions[q1] + ) + d_factor = min(d_factor, d_alt) + # Get score and see if it's the best so far + this_score = d_factor / max(1e-9, min(distances[i1], distances[i2])) + if this_score > best_score: + best_score = this_score + best_ear = q1, q0, q2 + best_i = i + + # I *think* that as long as the mesh is manifold, there is + # always a solution, because if one of the edges in the final + # quad is forbidden, the other edge cannot be. But just in case, + # we cover the case where we did not find a solution. + if best_i < 0: + raise RuntimeError("Could not tesselate!") + + # Register new face and reduce the contour + new_faces.append(best_ear) + qq.pop(best_i) + + # Only a triangle left. Add the final face - not much to choose + assert len(qq) == 3 + new_faces.append((qq[0], qq[1], qq[2])) + + return np.array(new_faces, np.int32) + + +def distance_of_point_to_line_piece(p1, p2, p3): + """Calculate the distance of point p1 to the line-piece p2-p3.""" + # Also http://mathworld.wolfram.com/Point-LineDistance3-Dimensional.html + # We make use of two ways to calculate the area. One is Heron's formula, + # the other is 0.5 * b * h, where b is the length of the linepiece and h + # is our distance. + norm = np.linalg.norm + d12 = norm(p1 - p2) + d23 = norm(p2 - p3) + d31 = norm(p3 - p1) + s = (d12 + d23 + d31) / 2 # semiperimiter + b = d23 + area = (s * (s - d12) * (s - d23) * (s - d31)) ** 0.5 # Herons formula + h = area * 2 / b # area = b * h / 2 --> h = area * 2 / b + # Is p1 beyond one of the end-points? If so, return distance to closest point. + max_dist = (h * h + b * b) ** 0.5 + if max(d12, d31) > max_dist: + return min(d12, d31) + else: + return h From a1baea6e00de65935247fb0eaed11ea605d0e283 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Tue, 3 Jun 2025 10:59:51 +0200 Subject: [PATCH 03/19] Improve interaction --- fastplotlib/graphics/selectors/_polygon.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/fastplotlib/graphics/selectors/_polygon.py b/fastplotlib/graphics/selectors/_polygon.py index a85660896..d94279a03 100644 --- a/fastplotlib/graphics/selectors/_polygon.py +++ b/fastplotlib/graphics/selectors/_polygon.py @@ -98,7 +98,7 @@ def _fpl_add_plot_area_hook(self, plot_area): self._plot_area.controller.enabled = False # click to add new segment - self._plot_area.renderer.add_event_handler(self._finish_segment, "click") + self._plot_area.renderer.add_event_handler(self._start_finish_segment, "click") # pointer move to change endpoint of segment self._plot_area.renderer.add_event_handler( @@ -109,7 +109,7 @@ def _fpl_add_plot_area_hook(self, plot_area): self.position_z = len(self._plot_area) + 10 - def _finish_segment(self, ev): + def _start_finish_segment(self, ev): """After click event, adds a new line segment""" # Don't add two points at the same spot @@ -126,7 +126,10 @@ def _finish_segment(self, ev): ) # line with same position for start and end until mouse moves - data = np.vstack([self.selection, position]) + if len(self.selection) == 0: + data = np.vstack([self.selection, position, position]) + else: + data = np.vstack([self.selection, position]) self._selection.set_value(self, data) @@ -159,7 +162,7 @@ def _finish_polygon(self, ev): self._plot_area.controller.enabled = True handlers = { - self._finish_segment: "click", + self._start_finish_segment: "click", self._move_segment_endpoint: "pointer_move", self._finish_polygon: "double_click", } From 0604e1ead477f75984ad27e6602df527c8308aaf Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Tue, 3 Jun 2025 11:00:00 +0200 Subject: [PATCH 04/19] Add example --- examples/selection_tools/lasso_selector.py | 66 ++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 examples/selection_tools/lasso_selector.py diff --git a/examples/selection_tools/lasso_selector.py b/examples/selection_tools/lasso_selector.py new file mode 100644 index 000000000..f8ca2924b --- /dev/null +++ b/examples/selection_tools/lasso_selector.py @@ -0,0 +1,66 @@ +""" +Lasso Selectors +=============== + +Example showing how to use a `PolygonSelector` with line collections +""" + +# test_example = false +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl +from itertools import product + +# create a figure +figure = fpl.Figure( + size=(700, 560) +) + + +# generate some data +def make_circle(center, radius: float, n_points: int = 75) -> np.ndarray: + theta = np.linspace(0, 2 * np.pi, n_points) + xs = radius * np.sin(theta) + ys = radius * np.cos(theta) + + return np.column_stack([xs, ys]) + center + + +spatial_dims = (50, 50) + +circles = list() +for center in product(range(0, spatial_dims[0], 9), range(0, spatial_dims[1], 9)): + circles.append(make_circle(center, 3, n_points=75)) + +pos_xy = np.vstack(circles) + +# add image +line_collection = figure[0, 0].add_line_collection(circles, cmap="jet", thickness=5) + +# add polygon selector to image graphic +polygon_selector = line_collection.add_polygon_selector() + + +# add event handler to highlight selected indices +@polygon_selector.add_event_handler("selection") +def color_indices(ev): + line_collection.cmap = "jet" + ixs = ev.get_selected_indices() + + # iterate through each of the selected indices, if the array size > 0 that mean it's under the selection + selected_line_ixs = [i for i in range(len(ixs)) if ixs[i].size > 0] + line_collection[selected_line_ixs].colors = "w" + + +# # manually move selector to make a nice gallery image :D +# polygon_selector.selection = (15, 30, 15, 30) + + +figure.show() + +# NOTE: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively +# please see our docs for using fastplotlib interactively in ipython and jupyter +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() From f8d01ee639dc29bf8a9d78186bf0147fb1a13a78 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Tue, 3 Jun 2025 14:46:30 +0200 Subject: [PATCH 05/19] More robust triangularion --- fastplotlib/utils/mapbox_earcut.py | 835 +++++++++++++++++++++++++++++ fastplotlib/utils/triangulation.py | 161 +----- 2 files changed, 849 insertions(+), 147 deletions(-) create mode 100644 fastplotlib/utils/mapbox_earcut.py diff --git a/fastplotlib/utils/mapbox_earcut.py b/fastplotlib/utils/mapbox_earcut.py new file mode 100644 index 000000000..ecb129593 --- /dev/null +++ b/fastplotlib/utils/mapbox_earcut.py @@ -0,0 +1,835 @@ +# The code below is copied from https://github.com/MIERUNE/earcut-py/blob/cb30bff5458fca224c573187f36d889068ebd4e0/src/earcut/__init__.py +# which is a port of Mapbox' JS earcut (https://github.com/mapbox/earcut) version 2.2.4 +# The code is not modified, except maybe formatting to keep the linter happy. +# +# ISC License +# +# Copyright (c) 2016, Mapbox +# Copyright (c) 2023, MIERUNE Inc. +# +# Permission to use, copy, modify, and/or distribute this software for any purpose +# with or without fee is hereby granted, provided that the above copyright notice +# and this permission notice appear in all copies. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +# REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +# FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +# INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS +# OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF +# THIS SOFTWARE. + +import math +from typing import Optional + + +def earcut(data, hole_indices=None, dim=2): + has_holes = bool(hole_indices) + outer_len = hole_indices[0] * dim if has_holes else len(data) + outer_node = _linked_list(data, 0, outer_len, dim, True) + triangles = [] + + if (not outer_node) or outer_node.next == outer_node.prev: + return triangles + + min_x = min_y = inv_size = None + + if has_holes: + outer_node = _eliminate_holes(data, hole_indices, outer_node, dim) + + # if the shape is not too simple, we'll use z-order curve hash later; calculate polygon bbox + if len(data) > 80 * dim: + min_x = max_x = data[0] + min_y = max_y = data[1] + + for i in range(dim, outer_len, dim): + x = data[i] + y = data[i + 1] + if x < min_x: + min_x = x + if y < min_y: + min_y = y + if x > max_x: + max_x = x + if y > max_y: + max_y = y + + # minX, minY and invSize are later used to transform coords into integers for z-order calculation + inv_size = max(max_x - min_x, max_y - min_y) + inv_size = 32767 / inv_size if inv_size != 0 else 0 + + _earcut_linked(outer_node, triangles, dim, min_x, min_y, inv_size) + + return triangles + + +# create a circular doubly linked list from polygon points in the specified winding order +def _linked_list(data, start, end, dim, clockwise): + last = None + + if clockwise == (_signed_area(data, start, end, dim) > 0): + for i in range(start, end, dim): + last = _insert_node(i, data[i], data[i + 1], last) + else: + for i in reversed(range(start, end, dim)): + last = _insert_node(i, data[i], data[i + 1], last) + + if last and _equals(last, last.next): + _remove_node(last) + last = last.next + + return last + + +# eliminate colinear or duplicate points +def _filter_points(start, end=None): + if not start: + return start + + if not end: + end = start + + p = start + while True: + again = False + + if not p.steiner and (_equals(p, p.next) or _area(p.prev, p, p.next) == 0): + _remove_node(p) + p = end = p.prev + if p == p.next: + break + again = True + + else: + p = p.next + + if (not again) and p == end: + break + + return end + + +# main ear slicing loop which triangulates a polygon (given as a linked list) +def _earcut_linked(ear, triangles, dim, min_x, min_y, inv_size, _pass=0): + if not ear: + return + + # interlink polygon nodes in z-order + if not _pass and inv_size: + _index_curve(ear, min_x, min_y, inv_size) + + stop = ear + + # iterate through ears, slicing them one by one + while ear.prev != ear.next: + prev = ear.prev + next = ear.next + is_ear = ( + _is_ear_hashed(ear, min_x, min_y, inv_size) if inv_size else _is_ear(ear) + ) + + if is_ear: + # cut off the triangle + triangles.append(prev.i // dim) + triangles.append(ear.i // dim) + triangles.append(next.i // dim) + + _remove_node(ear) + + # skipping the next vertex leads to less sliver triangles + ear = next.next + stop = next.next + + continue + + ear = next + + # if we looped through the whole remaining polygon and can't find any more ears + if ear == stop: + # try filtering points and slicing again + if not _pass: + _earcut_linked( + _filter_points(ear), triangles, dim, min_x, min_y, inv_size, 1 + ) + + # if this didn't work, try curing all small self-intersections locally + elif _pass == 1: + ear = _cure_local_intersections(_filter_points(ear), triangles, dim) + _earcut_linked(ear, triangles, dim, min_x, min_y, inv_size, 2) + + # as a last resort, try splitting the remaining polygon into two + elif _pass == 2: + _split_earcut(ear, triangles, dim, min_x, min_y, inv_size) + + break + + +# check whether a polygon node forms a valid ear with adjacent nodes +def _is_ear(ear): + a = ear.prev + b = ear + c = ear.next + + if _area(a, b, c) >= 0: + return False # reflex, can't be an ear + + # now make sure we don't have other points inside the potential ear + ax = a.x + ay = a.y + bx = b.x + by = b.y + cx = c.x + cy = c.y + + # triangle bbox; min & max are calculated like this for speed + x0 = (ax if ax < cx else cx) if ax < bx else (bx if bx < cx else cx) + y0 = (ay if ay < cy else cy) if ay < by else (by if by < cy else cy) + x1 = (ax if ax > cx else cx) if ax > bx else (bx if bx > cx else cx) + y1 = (ay if ay > cy else cy) if ay > by else (by if by > cy else cy) + + p = c.next + while p != a: + if ( + (p.x >= x0 and p.x <= x1 and p.y >= y0 and p.y <= y1) + and _point_in_triangle(ax, ay, bx, by, cx, cy, p.x, p.y) + and _area(p.prev, p, p.next) >= 0 + ): + return False + p = p.next + + return True + + +def _is_ear_hashed(ear, min_x, min_y, inv_size): + a = ear.prev + b = ear + c = ear.next + + if _area(a, b, c) >= 0: + return False # reflex, can't be an ear + + ax = a.x + ay = a.y + bx = b.x + by = b.y + cx = c.x + cy = c.y + + # triangle bbox; min & max are calculated like this for speed + x0 = (ax if ax < cx else cx) if ax < bx else (bx if bx < cx else cx) + y0 = (ay if ay < cy else cy) if ay < by else (by if by < cy else cy) + x1 = (ax if ax > cx else cx) if ax > bx else (bx if bx > cx else cx) + y1 = (ay if ay > cy else cy) if ay > by else (by if by > cy else cy) + + # z-order range for the current triangle bbox + min_z = _z_order(x0, y0, min_x, min_y, inv_size) + max_z = _z_order(x1, y1, min_x, min_y, inv_size) + + p = ear.prev_z + n = ear.next_z + + # look for points inside the triangle in both directions + while p and p.z >= min_z and n and n.z <= max_z: + if ( + (p.x >= x0 and p.x <= x1 and p.y >= y0 and p.y <= y1) + and (p != a and p != c) + and _point_in_triangle(ax, ay, bx, by, cx, cy, p.x, p.y) + and _area(p.prev, p, p.next) >= 0 + ): + return False + p = p.prev_z + + if ( + (n.x >= x0 and n.x <= x1 and n.y >= y0 and n.y <= y1) + and (n != a and n != c) + and _point_in_triangle(ax, ay, bx, by, cx, cy, n.x, n.y) + and _area(n.prev, n, n.next) >= 0 + ): + return False + n = n.next_z + + # look for remaining points in decreasing z-order + while p and p.z >= min_z: + if ( + (p != ear.prev and p != ear.next) + and _point_in_triangle(ax, ay, bx, by, cx, cy, p.x, p.y) + and _area(p.prev, p, p.next) >= 0 + ): + return False + p = p.prev_z + + # look for remaining points in increasing z-order + while n and n.z <= max_z: + if ( + (n != ear.prev and n != ear.next) + and _point_in_triangle(ax, ay, bx, by, cx, cy, n.x, n.y) + and _area(n.prev, n, n.next) >= 0 + ): + return False + n = n.next_z + + return True + + +# go through all polygon nodes and cure small local self-intersections +def _cure_local_intersections(start, triangles, dim): + p = start + while True: + a = p.prev + b = p.next.next + + if ( + not _equals(a, b) + and _intersects(a, p, p.next, b) + and _locally_inside(a, b) + and _locally_inside(b, a) + ): + triangles.append(a.i // dim) + triangles.append(p.i // dim) + triangles.append(b.i // dim) + + # remove two nodes involved + _remove_node(p) + _remove_node(p.next) + + p = start = b + + p = p.next + if p == start: + break + + return _filter_points(p) + + +# try splitting polygon into two and triangulate them independently +def _split_earcut(start, triangles, dim, min_x, min_y, inv_size): + # look for a valid diagonal that divides the polygon into two + a = start + while True: + b = a.next.next + while b != a.prev: + if a.i != b.i and _is_valid_diagonal(a, b): + # split the polygon in two by the diagonal + c = _split_polygon(a, b) + + # filter colinear points around the cuts + a = _filter_points(a, a.next) + c = _filter_points(c, c.next) + + # run earcut on each half + _earcut_linked(a, triangles, dim, min_x, min_y, inv_size) + _earcut_linked(c, triangles, dim, min_x, min_y, inv_size) + return + b = b.next + a = a.next + if a == start: + break + + +# link every hole into the outer loop, producing a single-ring polygon without holes +def _eliminate_holes(data, hole_indices, outer_node, dim): + queue = [] + _len = len(hole_indices) + + for i in range(_len): + start = hole_indices[i] * dim + end = hole_indices[i + 1] * dim if i < _len - 1 else len(data) + lst = _linked_list(data, start, end, dim, False) + if lst: + if lst == lst.next: + lst.steiner = True + queue.append(_get_leftmost(lst)) + + queue.sort(key=lambda i: i.x) + + # process holes from left to right + for q_i in queue: + outer_node = _eliminate_hole(q_i, outer_node) + + return outer_node + + +# find a bridge between vertices that connects hole with an outer ring and and link it +def _eliminate_hole(hole, outer_node): + bridge = _find_hole_bridge(hole, outer_node) + if not bridge: + return outer_node + + bridge_reverse = _split_polygon(bridge, hole) + + _filter_points(bridge_reverse, bridge_reverse.next) + return _filter_points(bridge, bridge.next) + + +# David Eberly's algorithm for finding a bridge between hole and outer polygon +def _find_hole_bridge(hole, outer_node): + p = outer_node + hx = hole.x + hy = hole.y + qx = -math.inf + m = None + + # find a segment intersected by a ray from the hole's leftmost point to the left + # segment's endpoint with lesser x will be potential connection point + while True: + px = p.x + py = p.y + if hy <= py and hy >= p.next.y and p.next.y != py: + x = px + (hy - py) * (p.next.x - px) / (p.next.y - py) + if x <= hx and x > qx: + qx = x + m = p if px < p.next.x else p.next + if x == hx: + # hole touches outer segment; pick leftmost endpoint + return m + p = p.next + if p == outer_node: + break + + if not m: + return None + + # look for points inside the triangle of hole point, segment intersection and endpoint + # if there are no points found, we have a valid connection + # otherwise choose the point of the minimum angle with the ray as connection point + + stop = m + mx = m.x + my = m.y + tan_min = math.inf + + p = m + + while True: + px = p.x + py = p.y + if (hx >= px and px >= mx and hx != px) and _point_in_triangle( + hx if hy < my else qx, + hy, + mx, + my, + qx if hy < my else hx, + hy, + px, + py, + ): + tan = abs(hy - py) / (hx - px) # tangential + + if _locally_inside(p, hole) and ( + tan < tan_min + or ( + tan == tan_min + and (px > m.x or (px == m.x and _sector_contains_sector(m, p))) + ) + ): + m = p + tan_min = tan + + p = p.next + if p == stop: + break + + return m + + +# whether sector in vertex m contains sector in vertex p in the same coordinates +def _sector_contains_sector(m, p): + return _area(m.prev, m, p.prev) < 0 and _area(p.next, m, m.next) < 0 + + +# interlink polygon nodes in z-order +def _index_curve(start, min_x, min_y, inv_size): + p = start + while True: + if p.z is None: + p.z = _z_order(p.x, p.y, min_x, min_y, inv_size) + p.prev_z = p.prev + p.next_z = p.next + p = p.next + if p == start: + break + + p.prev_z.next_z = None + p.prev_z = None + + _sort_linked(p) + + +# Simon Tatham's linked list merge sort algorithm +# http://www.chiark.greenend.org.uk/~sgtatham/algorithms/listsort.html +def _sort_linked(_list): + in_size = 1 + + while True: + p = _list + _list = None + tail = None + num_merges = 0 + + while p: + num_merges += 1 + q = p + p_size = 0 + for i in range(in_size): + p_size += 1 + q = q.next_z + if not q: + break + q_size = in_size + + while p_size > 0 or (q_size > 0 and q): + if p_size != 0 and (q_size == 0 or not q or p.z <= q.z): + e = p + p = p.next_z + p_size -= 1 + else: + e = q + q = q.next_z + q_size -= 1 + + if tail: + tail.next_z = e + else: + _list = e + + e.prev_z = tail + tail = e + + p = q + + tail.next_z = None + in_size *= 2 + + if num_merges <= 1: + break + + return _list + + +# z-order of a point given coords and inverse of the longer side of data bbox +def _z_order(x, y, min_x, min_y, inv_size): + # coords are transformed into non-negative 15-bit integer range + x = int((x - min_x) * inv_size) + y = int((y - min_y) * inv_size) + + x = (x | (x << 8)) & 0x00FF00FF + x = (x | (x << 4)) & 0x0F0F0F0F + x = (x | (x << 2)) & 0x33333333 + x = (x | (x << 1)) & 0x55555555 + + y = (y | (y << 8)) & 0x00FF00FF + y = (y | (y << 4)) & 0x0F0F0F0F + y = (y | (y << 2)) & 0x33333333 + y = (y | (y << 1)) & 0x55555555 + + return x | (y << 1) + + +# find the leftmost node of a polygon ring +def _get_leftmost(start): + p = start + leftmost = start + + while True: + if p.x < leftmost.x or (p.x == leftmost.x and p.y < leftmost.y): + leftmost = p + + p = p.next + if p == start: + break + + return leftmost + + +# check if a point lies within a convex triangle +def _point_in_triangle(ax, ay, bx, by, cx, cy, px, py): + pax = ax - px + pay = ay - py + pbx = bx - px + pby = by - py + pcx = cx - px + pcy = cy - py + return ( + pcx * pay - pax * pcy >= 0 + and pax * pby - pbx * pay >= 0 + and pbx * pcy - pcx * pby >= 0 + ) + + +# check if a diagonal between two polygon nodes is valid (lies in polygon interior) +def _is_valid_diagonal(a, b): + return ( + # dones't intersect other edges + (a.next.i != b.i and a.prev.i != b.i and not _intersects_polygon(a, b)) + and ( + # locally visible + (_locally_inside(a, b) and _locally_inside(b, a) and _middle_inside(a, b)) + # does not create opposite-facing sectors + and (_area(a.prev, a, b.prev) or _area(a, b.prev, b)) + # special zero-length case + or ( + _equals(a, b) + and _area(a.prev, a, a.next) > 0 + and _area(b.prev, b, b.next) > 0 + ) + ) + ) + + +# signed area of a triangle +def _area(p, q, r): + px = p.x + py = p.y + qx = q.x + qy = q.y + rx = r.x + ry = r.y + return (qy - py) * (rx - qx) - (qx - px) * (ry - qy) + + +# check if two points are equal +def _equals(p1, p2): + return p1.x == p2.x and p1.y == p2.y + + +# check if two segments intersect +def _intersects(p1, q1, p2, q2): + o1 = _sign(_area(p1, q1, p2)) + o2 = _sign(_area(p1, q1, q2)) + o3 = _sign(_area(p2, q2, p1)) + o4 = _sign(_area(p2, q2, q1)) + + if ( + (o1 != o2 and o3 != o4) # general case + or ( + o1 == 0 and _on_segment(p1, p2, q1) + ) # p1, q1 and p2 are collinear and p2 lies on p1q1 + or ( + o2 == 0 and _on_segment(p1, q2, q1) + ) # p1, q1 and q2 are collinear and q2 lies on p1q1 + or ( + o3 == 0 and _on_segment(p2, p1, q2) + ) # p2, q2 and p1 are collinear and p1 lies on p2q2 + or ( + o4 == 0 and _on_segment(p2, q1, q2) + ) # p2, q2 and q1 are collinear and q1 lies on p2q2 + ): + return True + + return False + + +# for collinear points p, q, r, check if point q lies on segment pr +def _on_segment(p, q, r): + return ( + q.x <= max(p.x, r.x) + and q.x >= min(p.x, r.x) + and q.y <= max(p.y, r.y) + and q.y >= min(p.y, r.y) + ) + + +def _sign(num): + if num > 0: + return 1 + elif num < 0: + return -1 + else: + return 0 + + +# check if a polygon diagonal intersects any polygon segments +def _intersects_polygon(a, b): + p = a + while True: + pi = p.i + ai = a.i + bi = b.i + pnext = p.next + pnexti = pnext.i + if (pi != ai and pnexti != ai and pi != bi and pnexti != bi) and _intersects( + p, pnext, a, b + ): + return True + + p = pnext + if p == a: + break + + return False + + +# check if a polygon diagonal is locally inside the polygon +def _locally_inside(a, b): + aprev = a.prev + anext = a.next + if _area(aprev, a, anext) < 0: + return _area(a, b, anext) >= 0 and _area(a, aprev, b) >= 0 + else: + return _area(a, b, aprev) < 0 or _area(a, anext, b) < 0 + + +# check if the middle point of a polygon diagonal is inside the polygon +def _middle_inside(a, b): + p = a + inside = False + px = (a.x + b.x) / 2 + py = (a.y + b.y) / 2 + while True: + p_x = p.x + p_y = p.y + p_next = p.next + p_next_y = p_next.y + if ( + (p_y > py) != (p_next_y > py) + and p_next.y != p_y + and (px < (p_next.x - p_x) * (py - p_y) / (p_next_y - p_y) + p_x) + ): + inside = not inside + p = p_next + if p == a: + break + + return inside + + +# link two polygon vertices with a bridge; if the vertices belong to the same ring, it splits polygon into two +# if one belongs to the outer ring and another to a hole, it merges it into a single ring +def _split_polygon(a, b): + a2 = _Node(a.i, a.x, a.y) + b2 = _Node(b.i, b.x, b.y) + an = a.next + bp = b.prev + + a.next = b + b.prev = a + + a2.next = an + an.prev = a2 + b2.next = a2 + a2.prev = b2 + bp.next = b2 + b2.prev = bp + + return b2 + + +# create a node and optionally link it with previous one (in a circular doubly linked list) +def _insert_node(i, x, y, last): + p = _Node(i, x, y) + + if not last: + p.prev = p + p.next = p + + else: + p.next = last.next + p.prev = last + last.next.prev = p + last.next = p + + return p + + +def _remove_node(p): + p.next.prev = p.prev + p.prev.next = p.next + + if p.prev_z: + p.prev_z.next_z = p.next_z + + if p.next_z: + p.next_z.prev_z = p.prev_z + + +class _Node: + __slots__ = ["i", "x", "y", "prev", "next", "z", "prev_z", "next_z", "steiner"] + i: int + x: float + y: float + prev: Optional["_Node"] + next: Optional["_Node"] + z: Optional[int] + prev_z: Optional["_Node"] + next_z: Optional["_Node"] + steiner: bool + + def __init__(self, i, x, y): + # vertex index in coordinates array + self.i = i + + # vertex coordinates + self.x = x + self.y = y + + # previous and next vertex nodes in a polygon ring + self.prev = None + self.next = None + + # z-order curve value + self.z = None + + # previous and next nodes in z-order + self.prev_z = None + self.next_z = None + + # indicates whether this is a steiner point + self.steiner = False + + +def _signed_area(data, start, end, dim): + sum = 0 + j = end - dim + for i in range(start, end, dim): + sum += (data[j] - data[i]) * (data[i + 1] + data[j + 1]) + j = i + + return sum + + +# return a percentage difference between the polygon area and its triangulation area +# used to verify correctness of triangulation +def deviation(data, hole_indices, dim, triangles): + has_holes = hole_indices and len(hole_indices) + outer_len = hole_indices[0] * dim if has_holes else len(data) + + polygon_area = abs(_signed_area(data, 0, outer_len, dim)) + if has_holes: + _len = len(hole_indices) + for i in range(_len): + start = hole_indices[i] * dim + end = hole_indices[i + 1] * dim if i < _len - 1 else len(data) + polygon_area -= abs(_signed_area(data, start, end, dim)) + + triangles_area = 0 + for i in range(0, len(triangles), 3): + a = triangles[i] * dim + b = triangles[i + 1] * dim + c = triangles[i + 2] * dim + triangles_area += abs( + (data[a] - data[c]) * (data[b + 1] - data[a + 1]) + - (data[a] - data[b]) * (data[c + 1] - data[a + 1]) + ) + + if polygon_area == 0 and triangles_area == 0: + return 0 + return abs((triangles_area - polygon_area) / polygon_area) + + +# turn a polygon in a multi-dimensional array form (e.g. as in GeoJSON) into a form Earcut accepts +def flatten(data): + dim = len(data[0][0]) + vertices = [] + holes = [] + hole_index = 0 + + for i in range(len(data)): + for j in range(len(data[i])): + for d in range(dim): + vertices.append(data[i][j][d]) + + if i > 0: + hole_index += len(data[i - 1]) + holes.append(hole_index) + + return (vertices, holes, dim) diff --git a/fastplotlib/utils/triangulation.py b/fastplotlib/utils/triangulation.py index 762337e02..d84ab57ea 100644 --- a/fastplotlib/utils/triangulation.py +++ b/fastplotlib/utils/triangulation.py @@ -1,12 +1,13 @@ import logging import numpy as np +from .mapbox_earcut import earcut as mapbox_earcut logger = logging.getLogger("fastplotlib") -def triangulate(positions, forbidden_edges=None, method="earcut"): +def triangulate(positions, method="earcut"): """Triangulate the given vertex positions. Returns an Nx3 integer array of faces that form a surface-mesh over the @@ -14,37 +15,33 @@ def triangulate(positions, forbidden_edges=None, method="earcut"): expressed in (local) vertex indices. The faces won't contain any forbidden_edges. """ - forbidden_edges = forbidden_edges or [] + if len(positions) < 3: + return np.zeros((0,), np.int32) + if len(positions) == 3: + return np.array([0, 1, 2], np.int32) # Anticipating more variations ... if method == "earcut": - method = "earcut1" + method = "mapbox_earcut" if method == "naive": - faces = _triangulate_naive(positions, forbidden_edges, method) - elif method == "earcut1": - try: - faces = _triangulate_earcut1(positions, forbidden_edges, method) - except RuntimeError as err: - # I think this should not happen, but if I'm wrong, we still produce a result - logger.warning(str(err)) - faces = _triangulate_naive(positions, forbidden_edges, method) + faces = _triangulate_naive(positions) + elif method == "mapbox_earcut": + positions2d = positions[:, :2].flatten() + faces = mapbox_earcut(positions2d) + faces = np.array(faces, np.int32).reshape(-1, 3) else: raise ValueError(f"Invalid triangulation method: {method}") - # Check result - nverts = len(positions) - nfaces = nverts - 2 - assert len(faces) == nfaces - return faces -def _triangulate_naive(positions, forbidden_edges, method): +def _triangulate_naive(positions, forbidden_edges=None): """This tesselation algorithm simply creates edges from one vertex to all the others.""" nverts = len(positions) nfaces = nverts - 2 + forbidden_edges = forbidden_edges or [] # Determine a good point to be a reference forbidden_start_points = set() @@ -67,133 +64,3 @@ def _triangulate_naive(positions, forbidden_edges, method): i2 = (i + 2) % nverts faces.append([i0, i1, i2]) return np.array(faces, np.int32) - - -def _triangulate_earcut1(positions, forbidden_edges, method, ref_normal=None): - """This tesselation algorithm uses the earcut algorithm plus some - other features to iteratively create faces. - """ - - # This code is originally from https://github.com/pygfx/gfxmorph/blob/main/gfxmorph/meshfuncs.py - # For now I just copied the implementation. - - # Generate new faces, using the contour as a kind of circular queue. - # We will pop vertices from the queue as we "cut ears off the - # polygon", and as such the contour will become smaller until we - # are left with a triangle. At the last step, when we have a quad, - # we need to take care to take the symmetry into account. - # - # Check all three consecutive vertices, and select the best ear. - # How to do this matters for the eventual result, also because - # selecting a particular ear affects the shape of the remaining - # hole. - # - # We want to avoid faces "folding over", prefer more or less equal - # edge lengths, and pick in such an order that in the last step we - # don't have a crap quad. There is a multitude of possible - # algorithms here. In our case we don't necessarily need the best - # solution since we have a rather iterative (and interactive) - # setting. - # - # Favoring one of the two side-vertices to be close to the contour - # center seems to work well, since it quickly triangulates vertices - # that come inwards and which may otherwise cause slither faces. - # It also promotes faces with good aspect ratio, which is also - # emphasised by scaling the score with the distance to the center. - # This score works considerably better than scoring on aspect ratio - # directly. I think this is because it promotes a better order in - # which ears are cut from the contour. - # - # Although this method helps prevent folded faces, it does not - # guarantee their absence. - - new_faces = [] - qq = list(range(len(positions))) - - while len(qq) > 3: - # Calculate center of the current hole - center = positions[qq].sum(axis=0) / len(qq) - - # Get distance of all points to the center - distances = np.linalg.norm(center - positions[qq], axis=1) - - is_quad = len(qq) == 4 - n_iters = 2 if is_quad else len(qq) - best_i = best_ear = -1 - best_score = -999999999999 - - for i in range(n_iters): - # Get indices of "side vertices", and the actual vertex indices - i1 = i - 1 - i2 = i + 1 - if i == 0: - i1 = len(qq) - 1 - elif i == len(qq) - 1: - i2 = 0 - q1, q0, q2 = qq[i1], qq[i], qq[i2] - - # Is this triangle allowed? - if (q1, q2) in forbidden_edges: - continue - - # Get distance of reference vertex to the center. Using the - # plain distance works, but using the distance from the - # edge better prevents folded faces. - # d_factor = distances[i] - d_factor = distance_of_point_to_line_piece( - positions[q0], positions[q1], positions[q2] - ) - if is_quad: - # If we're considering a quad, we must take symmetry - # into account; the best score might actually be the - # worst score when viewed from the opposite end. - # d_alt = distances[i + 2] - d_alt = distance_of_point_to_line_piece( - positions[qq[i + 2]], positions[q2], positions[q1] - ) - d_factor = min(d_factor, d_alt) - # Get score and see if it's the best so far - this_score = d_factor / max(1e-9, min(distances[i1], distances[i2])) - if this_score > best_score: - best_score = this_score - best_ear = q1, q0, q2 - best_i = i - - # I *think* that as long as the mesh is manifold, there is - # always a solution, because if one of the edges in the final - # quad is forbidden, the other edge cannot be. But just in case, - # we cover the case where we did not find a solution. - if best_i < 0: - raise RuntimeError("Could not tesselate!") - - # Register new face and reduce the contour - new_faces.append(best_ear) - qq.pop(best_i) - - # Only a triangle left. Add the final face - not much to choose - assert len(qq) == 3 - new_faces.append((qq[0], qq[1], qq[2])) - - return np.array(new_faces, np.int32) - - -def distance_of_point_to_line_piece(p1, p2, p3): - """Calculate the distance of point p1 to the line-piece p2-p3.""" - # Also http://mathworld.wolfram.com/Point-LineDistance3-Dimensional.html - # We make use of two ways to calculate the area. One is Heron's formula, - # the other is 0.5 * b * h, where b is the length of the linepiece and h - # is our distance. - norm = np.linalg.norm - d12 = norm(p1 - p2) - d23 = norm(p2 - p3) - d31 = norm(p3 - p1) - s = (d12 + d23 + d31) / 2 # semiperimiter - b = d23 - area = (s * (s - d12) * (s - d23) * (s - d31)) ** 0.5 # Herons formula - h = area * 2 / b # area = b * h / 2 --> h = area * 2 / b - # Is p1 beyond one of the end-points? If so, return distance to closest point. - max_dist = (h * h + b * b) ** 0.5 - if max(d12, d31) > max_dist: - return min(d12, d31) - else: - return h From 91779e3014f1da8571371d061bc9d513f94237a7 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Tue, 3 Jun 2025 14:53:59 +0200 Subject: [PATCH 06/19] tweaks --- ...{lasso_selector.py => polygon_selector.py} | 8 +- fastplotlib/graphics/image.py | 9 +- fastplotlib/graphics/line.py | 27 ++- fastplotlib/graphics/line_collection.py | 27 ++- .../graphics/selectors/_base_selector.py | 3 +- fastplotlib/graphics/selectors/_polygon.py | 166 ++++++++++++++---- fastplotlib/graphics/selectors/_rectangle.py | 4 +- 7 files changed, 194 insertions(+), 50 deletions(-) rename examples/selection_tools/{lasso_selector.py => polygon_selector.py} (86%) diff --git a/examples/selection_tools/lasso_selector.py b/examples/selection_tools/polygon_selector.py similarity index 86% rename from examples/selection_tools/lasso_selector.py rename to examples/selection_tools/polygon_selector.py index f8ca2924b..55110b8d7 100644 --- a/examples/selection_tools/lasso_selector.py +++ b/examples/selection_tools/polygon_selector.py @@ -1,8 +1,8 @@ """ -Lasso Selectors -=============== +Polygon Selectors +================= -Example showing how to use a `PolygonSelector` with line collections +Example showing how to use a `PolygonSelector` (a.k.a. lasso selector) with line collections """ # test_example = false @@ -39,7 +39,7 @@ def make_circle(center, radius: float, n_points: int = 75) -> np.ndarray: line_collection = figure[0, 0].add_line_collection(circles, cmap="jet", thickness=5) # add polygon selector to image graphic -polygon_selector = line_collection.add_polygon_selector() +polygon_selector = line_collection.add_polygon_selector(fill_color="#ff00ff22", edge_color="#FFF", vertex_color="#FFF") # add event handler to highlight selected indices diff --git a/fastplotlib/graphics/image.py b/fastplotlib/graphics/image.py index 8bbc343f2..e396ce145 100644 --- a/fastplotlib/graphics/image.py +++ b/fastplotlib/graphics/image.py @@ -5,7 +5,12 @@ from ..utils import quick_min_max from ._base import Graphic -from .selectors import LinearSelector, LinearRegionSelector, RectangleSelector, PolygonSelector +from .selectors import ( + LinearSelector, + LinearRegionSelector, + RectangleSelector, + PolygonSelector, +) from .features import ( TextureArray, ImageCmap, @@ -169,7 +174,6 @@ def __init__( # iterate through each texture chunk and create # an _ImageTIle, offset the tile using the data indices for texture, chunk_index, data_slice in self._data: - # create an ImageTile using the texture for this chunk img = _ImageTile( geometry=pygfx.Geometry(grid=texture), @@ -469,6 +473,7 @@ def add_polygon_selector( limits = (0, self._data.value.shape[1], 0, self._data.value.shape[0]) selector = PolygonSelector( + limits, fill_color=fill_color, parent=self, **kwargs, diff --git a/fastplotlib/graphics/line.py b/fastplotlib/graphics/line.py index be3cae857..0b5551b24 100644 --- a/fastplotlib/graphics/line.py +++ b/fastplotlib/graphics/line.py @@ -5,7 +5,12 @@ import pygfx from ._positions_base import PositionsGraphic -from .selectors import LinearRegionSelector, LinearSelector, RectangleSelector, PolygonSelector +from .selectors import ( + LinearRegionSelector, + LinearSelector, + RectangleSelector, + PolygonSelector, +) from .features import ( Thickness, VertexPositions, @@ -245,7 +250,7 @@ def add_rectangle_selector( self, selection: tuple[float, float, float, float] = None, **kwargs, - ) -> RectangleSelector: + ) -> RectangleSelector: """ Add a :class:`.RectangleSelector`. @@ -304,7 +309,25 @@ def add_polygon_selector( selection: (float, float, float, float), optional initial (xmin, xmax, ymin, ymax) of the selection """ + + # remove any nans + data = self.data.value[~np.any(np.isnan(self.data.value), axis=1)] + + x_axis_vals = data[:, 0] + y_axis_vals = data[:, 1] + + ymin = np.floor(y_axis_vals.min()).astype(int) + ymax = np.ceil(y_axis_vals.max()).astype(int) + + # default selection is 25% of the image + if selection is None: + selection = [] + + # min/max limits + limits = (x_axis_vals[0], x_axis_vals[-1], ymin * 1.5, ymax * 1.5) + selector = PolygonSelector( + limits, parent=self, **kwargs, ) diff --git a/fastplotlib/graphics/line_collection.py b/fastplotlib/graphics/line_collection.py index 3a3559763..d629ddd50 100644 --- a/fastplotlib/graphics/line_collection.py +++ b/fastplotlib/graphics/line_collection.py @@ -7,7 +7,12 @@ from ..utils import parse_cmap_values from ._collection_base import CollectionIndexer, GraphicCollection, CollectionFeature from .line import LineGraphic -from .selectors import LinearRegionSelector, LinearSelector, RectangleSelector, PolygonSelector +from .selectors import ( + LinearRegionSelector, + LinearSelector, + RectangleSelector, + PolygonSelector, +) class _LineCollectionProperties: @@ -198,19 +203,19 @@ def __init__( if not isinstance(thickness, (float, int)): if len(thickness) != len(data): raise ValueError( - f"len(thickness) != len(data)\n" f"{len(thickness)} != {len(data)}" + f"len(thickness) != len(data)\n{len(thickness)} != {len(data)}" ) if names is not None: if len(names) != len(data): raise ValueError( - f"len(names) != len(data)\n" f"{len(names)} != {len(data)}" + f"len(names) != len(data)\n{len(names)} != {len(data)}" ) if metadatas is not None: if len(metadatas) != len(data): raise ValueError( - f"len(metadata) != len(data)\n" f"{len(metadatas)} != {len(data)}" + f"len(metadata) != len(data)\n{len(metadatas)} != {len(data)}" ) if kwargs_lines is not None: @@ -502,12 +507,24 @@ def add_polygon_selector( """ bbox = self.world_object.get_world_bounding_box() + xdata = np.array(self.data[:, 0]) + xmin, xmax = (np.nanmin(xdata), np.nanmax(xdata)) + + ydata = np.array(self.data[:, 1]) + ymin = np.floor(ydata.min()).astype(int) + + ymax = np.ptp(bbox[:, 1]) + + if selection is None: + selection = [] + + limits = (xmin, xmax, ymin - (ymax * 1.5 - ymax), ymax * 1.5) if selection is not None: selection = [] # TODO: fill selection - selector = PolygonSelector( + limits, parent=self, **kwargs, ) diff --git a/fastplotlib/graphics/selectors/_base_selector.py b/fastplotlib/graphics/selectors/_base_selector.py index b74bcf759..1542d2bad 100644 --- a/fastplotlib/graphics/selectors/_base_selector.py +++ b/fastplotlib/graphics/selectors/_base_selector.py @@ -215,7 +215,7 @@ def _fpl_add_plot_area_hook(self, plot_area): wo.add_event_handler(self._toggle_arrow_key_moveable, "double_click") for fill in self._fill: - if fill.material.color_is_transparent: + if fill.material.color.a < 1 or fill.material.opacity < 1: self._pfunc_fill = partial(self._check_fill_pointer_event, fill) self._plot_area.renderer.add_event_handler( self._pfunc_fill, "pointer_down" @@ -392,7 +392,6 @@ def _move_to_pointer(self, ev): self._move_graphic(move_info) def _pointer_enter(self, ev): - if self._hover_responsive is None: return diff --git a/fastplotlib/graphics/selectors/_polygon.py b/fastplotlib/graphics/selectors/_polygon.py index d94279a03..8b22ffea8 100644 --- a/fastplotlib/graphics/selectors/_polygon.py +++ b/fastplotlib/graphics/selectors/_polygon.py @@ -13,6 +13,7 @@ class PolygonSelector(BaseSelector): _features = {"selection": PolygonSelectionFeature} + _last_click = (-10, -10, 0) @property def parent(self) -> Graphic | None: @@ -52,14 +53,17 @@ def limits(self, values: Tuple[float, float, float, float]): def __init__( self, - edge_color="magenta", - edge_thickness: float = 4, + limits: Sequence[float], parent: Graphic = None, + fill_color=(0, 0, 0.35, 0.2), + edge_color=(0.8, 0.6, 0), + edge_thickness: float = 8, + vertex_color=(0.7, 0.4, 0), + vertex_size: float = 8, name: str = None, ): self._parent = parent self._move_info: MoveInfo = None - self._current_mode = None BaseSelector.__init__(self, name=name, parent=parent) @@ -76,11 +80,9 @@ def __init__( ) points = pygfx.Points( self.geometry, - pygfx.PointsMaterial(size=edge_thickness * 2, color=edge_color), - ) - mesh = pygfx.Mesh( - self.geometry, pygfx.MeshBasicMaterial(color=edge_color, opacity=0.2) + pygfx.PointsMaterial(size=vertex_size, color=vertex_color), ) + mesh = pygfx.Mesh(self.geometry, pygfx.MeshBasicMaterial(color=fill_color)) group = pygfx.Group().add(edge, points, mesh) self._set_world_object(group) @@ -89,8 +91,82 @@ def __init__( self.edge_color = edge_color self.edge_width = edge_thickness - def get_selected_indices(self): - return [] + def get_selected_indices( + self, graphic: Graphic = None + ) -> np.ndarray | tuple[np.ndarray]: + """ + Returns the indices of the ``Graphic`` data bounded by the current selection. + + These are the data indices which correspond to the data under the selector. + + Parameters + ---------- + graphic: Graphic, default ``None`` + If provided, returns the selection indices from this graphic instead of the graphic set as ``parent`` + + Returns + ------- + Union[np.ndarray, List[np.ndarray]] + data indicies of the selection + | tuple of [row_indices, col_indices] if the graphic is an image + | list of indices along the x-dimension for each line if graphic is a line collection + | array of indices along the x-dimension if graphic is a line + """ + # get indices from source + source = self._get_source(graphic) + + # selector (xmin, xmax, ymin, ymax) values + polygon = self.selection[:, :2] + + # Get bounding box to be able to do first selection + xmin, xmax = polygon[:, 0].min(), polygon[:, 0].max() + ymin, ymax = polygon[:, 1].min(), polygon[:, 1].max() + + # image data does not need to check for mode because the selector is always bounded + # to the image + if "Image" in source.__class__.__name__: + shape = source.data.value.shape + col_ixs = np.arange(max(0, xmin), min(xmax, shape[1] - 1), dtype=int) + row_ixs = np.arange(max(0, ymin), min(ymax, shape[0] - 1), dtype=int) + indices = [] + for y in row_ixs: + for x in col_ixs: + p = np.array([x, y], np.float32) + if point_in_polygon((x, y), polygon): + indices.append(p) + return np.array(indices, np.int32).reshape(-1, 2) + + if "Line" in source.__class__.__name__: + if isinstance(source, GraphicCollection): + ixs = list() + for g in source.graphics: + points = g.data.value[:, :2] + g.offset[:2] + g_ixs = np.where( + (points[:, 0] >= xmin) + & (points[:, 0] <= xmax) + & (points[:, 1] >= ymin) + & (points[:, 1] <= ymax) + )[0] + g_ixs = np.array( + [i for i in g_ixs if point_in_polygon(points[i], polygon)], + g_ixs.dtype, + ) + ixs.append(g_ixs) + else: + # map only this graphic + points = source.data.value[:2] + ixs = np.where( + (points[:, 0] >= xmin) + & (points[:, 0] <= xmax) + & (points[:, 1] >= ymin) + & (points[:, 1] <= ymax) + )[0] + ixs = np.array( + [i for i in ixs if point_in_polygon(points[i], polygon)], + ixs.dtype, + ) + + return ixs def _fpl_add_plot_area_hook(self, plot_area): self._plot_area = plot_area @@ -98,38 +174,46 @@ def _fpl_add_plot_area_hook(self, plot_area): self._plot_area.controller.enabled = False # click to add new segment - self._plot_area.renderer.add_event_handler(self._start_finish_segment, "click") + self._plot_area.renderer.add_event_handler(self._on_click, "click") # pointer move to change endpoint of segment self._plot_area.renderer.add_event_handler( self._move_segment_endpoint, "pointer_move" ) - # 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 _start_finish_segment(self, ev): - """After click event, adds a new line segment""" + def _on_click(self, ev): + last_click = self._last_click + self._last_click = ev.x, ev.y, ev.time_stamp - # Don't add two points at the same spot - if self._current_mode == "add": + world_pos = self._plot_area.map_screen_to_world(ev) + if world_pos is None: return - self._current_mode = "add" - position = self._plot_area.map_screen_to_world(ev) + if np.linalg.norm([last_click[0] - ev.x, last_click[1] - ev.y]) > 5: + self._start_finish_segment(world_pos) + elif (ev.time_stamp - last_click[2]) < 2: + self._last_click = (-10, -10, 0) + self._finish_polygon(world_pos) + else: + pass # a too slow double click + + def _start_finish_segment(self, world_pos): + """After click event, adds a new line segment""" + self._move_info = MoveInfo( start_selection=None, - start_position=position, - delta=np.zeros_like(position), + start_position=world_pos, + delta=np.zeros_like(world_pos), source=None, ) # line with same position for start and end until mouse moves if len(self.selection) == 0: - data = np.vstack([self.selection, position, position]) + data = np.vstack([self.selection, world_pos, world_pos]) else: - data = np.vstack([self.selection, position]) + data = np.vstack([self.selection, world_pos]) self._selection.set_value(self, data) @@ -137,10 +221,8 @@ 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 @@ -149,23 +231,43 @@ def _move_segment_endpoint(self, ev): data[-1] = world_pos self._selection.set_value(self, data) - def _finish_polygon(self, ev): + def _finish_polygon(self, world_pos): """finishes the polygon, disconnects events""" - world_pos = self._plot_area.map_screen_to_world(ev) - - if world_pos is None: - return - # close the loop self.world_object.children[0].material.loop = True self._plot_area.controller.enabled = True handlers = { - self._start_finish_segment: "click", + self._on_click: "click", self._move_segment_endpoint: "pointer_move", - self._finish_polygon: "double_click", } for handler, event in handlers.items(): self._plot_area.renderer.remove_event_handler(handler, event) + + +def is_left(p0, p1, p2): + """Test if point p2 is left of the line formed by p0 → p1""" + return (p1[0] - p0[0]) * (p2[1] - p0[1]) - (p2[0] - p0[0]) * (p1[1] - p0[1]) + + +def point_in_polygon(point, polygon): + """Determines if the point is inside the polygon using the winding number algorithm.""" + wn = 0 # winding number counter + n = len(polygon) + + for i in range(n): + p0 = polygon[i] + p1 = polygon[(i + 1) % n] + + if p0[1] <= point[1]: # start y <= point.y + if p1[1] > point[1]: # upward crossing + if is_left(p0, p1, point) > 0: + wn += 1 # point is left of edge + else: # start y > point.y + if p1[1] <= point[1]: # downward crossing + if is_left(p0, p1, point) < 0: + wn -= 1 # point is right of edge + + return wn != 0 diff --git a/fastplotlib/graphics/selectors/_rectangle.py b/fastplotlib/graphics/selectors/_rectangle.py index e3dd3887e..1e277f302 100644 --- a/fastplotlib/graphics/selectors/_rectangle.py +++ b/fastplotlib/graphics/selectors/_rectangle.py @@ -337,7 +337,6 @@ def get_selected_data( f"`mode` must be one of 'full', 'partial', or 'ignore', you have passed {mode}" ) if "Line" in source.__class__.__name__: - if isinstance(source, GraphicCollection): data_selections: List[np.ndarray] = list() @@ -431,7 +430,7 @@ def get_selected_indices( Parameters ---------- graphic: Graphic, default ``None`` - If provided, returns the selection indices from this graphic instrad of the graphic set as ``parent`` + If provided, returns the selection indices from this graphic instead of the graphic set as ``parent`` Returns ------- @@ -479,7 +478,6 @@ def get_selected_indices( return ixs def _move_graphic(self, move_info: MoveInfo): - # If this the first move in this drag, store initial selection if move_info.start_selection is None: move_info.start_selection = self.selection From b20d5a8c0267932acd09780358923d2d65f7e3c9 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Tue, 3 Jun 2025 14:59:31 +0200 Subject: [PATCH 07/19] apply limiy --- fastplotlib/graphics/features/_selection_features.py | 7 +++---- fastplotlib/graphics/selectors/_polygon.py | 1 + 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/fastplotlib/graphics/features/_selection_features.py b/fastplotlib/graphics/features/_selection_features.py index 507701257..e04746c82 100644 --- a/fastplotlib/graphics/features/_selection_features.py +++ b/fastplotlib/graphics/features/_selection_features.py @@ -403,10 +403,9 @@ def set_value(self, selector, value: Sequence[tuple[float]]): "Selection must be an array, tuple, list, or sequence of the shape Nx3." ) - # # clip values if they are beyond the limits - # value[:, 0] = value[:2].clip(self._limits[0], self._limits[1]) - # # clip y - # value[:, 1] = value[2:].clip(self._limits[2], self._limits[3]) + # clip values if they are beyond the limits + value[:, 0] = value[:, 0].clip(self._limits[0], self._limits[1]) + value[:, 1] = value[:, 1].clip(self._limits[2], self._limits[3]) self._value = value diff --git a/fastplotlib/graphics/selectors/_polygon.py b/fastplotlib/graphics/selectors/_polygon.py index 8b22ffea8..ece0951d7 100644 --- a/fastplotlib/graphics/selectors/_polygon.py +++ b/fastplotlib/graphics/selectors/_polygon.py @@ -90,6 +90,7 @@ def __init__( self.edge_color = edge_color self.edge_width = edge_thickness + self.limits = limits def get_selected_indices( self, graphic: Graphic = None From 3de437b70fa02c6e4e5b946230ad7ff854dc538a Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Tue, 3 Jun 2025 15:02:56 +0200 Subject: [PATCH 08/19] avoid artifact --- fastplotlib/graphics/features/_selection_features.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/fastplotlib/graphics/features/_selection_features.py b/fastplotlib/graphics/features/_selection_features.py index e04746c82..bf18902a4 100644 --- a/fastplotlib/graphics/features/_selection_features.py +++ b/fastplotlib/graphics/features/_selection_features.py @@ -428,10 +428,12 @@ def set_value(self, selector, value: Sequence[tuple[float]]): geometry.indices = gfx.Buffer(arr) geometry.positions.data[: len(value)] = value + geometry.positions.data[len(value)] = value[-1] if len(value) else (0, 0, 0) geometry.positions.draw_range = 0, len(value) geometry.positions.update_full() geometry.indices.data[: len(indices)] = indices + geometry.indices.data[len(indices)] = 0 geometry.indices.draw_range = 0, len(indices) geometry.indices.update_full() From d3bab84f133a5a5d1eafc314d512591e19a528c0 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Tue, 3 Jun 2025 15:19:59 +0200 Subject: [PATCH 09/19] add get_selected_data --- fastplotlib/graphics/selectors/_polygon.py | 126 +++++++++++++++++++++ 1 file changed, 126 insertions(+) diff --git a/fastplotlib/graphics/selectors/_polygon.py b/fastplotlib/graphics/selectors/_polygon.py index ece0951d7..9a0bc0a67 100644 --- a/fastplotlib/graphics/selectors/_polygon.py +++ b/fastplotlib/graphics/selectors/_polygon.py @@ -1,3 +1,4 @@ +import warnings from typing import * from numbers import Real @@ -92,6 +93,129 @@ def __init__( self.edge_width = edge_thickness self.limits = limits + def get_selected_data( + self, graphic: Graphic = None, mode: str = "full" + ) -> Union[np.ndarray, List[np.ndarray]]: + """ + Get the ``Graphic`` data bounded by the current selection. + Returns a view of the data array. + + If the ``Graphic`` is a collection, such as a ``LineStack``, it returns a list of views of the full array. + Can be performed on the ``parent`` Graphic or on another graphic by passing to the ``graphic`` arg. + + Parameters + ---------- + graphic: Graphic, optional, default ``None`` + if provided, returns the data selection from this graphic instead of the graphic set as ``parent`` + mode: str, default 'full' + One of 'full', 'partial', or 'ignore'. Indicates how selected data should be returned based on the + selectors position over the graphic. Only used for ``LineGraphic``, ``LineCollection``, and ``LineStack`` + | If 'full', will return all data bounded by the x and y limits of the selector even if partial indices + along one axis are not fully covered by the selector. + | If 'partial' will return only the data that is bounded by the selector, missing indices not bounded by the + selector will be set to NaNs + | If 'ignore', will only return data for graphics that have indices completely bounded by the selector + + Returns + ------- + np.ndarray or List[np.ndarray] + view or list of views of the full array, returns empty array if selection is empty + """ + source = self._get_source(graphic) + ixs = self.get_selected_indices(source) + + # do not need to check for mode for images, because the selector is bounded by the image shape + # will always be `full` + if "Image" in source.__class__.__name__: + return source.data[ixs[:, 1], ixs[:, 0]] + + if mode not in ["full", "partial", "ignore"]: + raise ValueError( + f"`mode` must be one of 'full', 'partial', or 'ignore', you have passed {mode}" + ) + if "Line" in source.__class__.__name__: + if isinstance(source, GraphicCollection): + data_selections: List[np.ndarray] = list() + + for i, g in enumerate(source.graphics): + # want to keep same length as the original line collection + if ixs[i].size == 0: + data_selections.append( + np.array([], dtype=np.float32).reshape(0, 3) + ) + else: + # s gives entire slice of data along the x + s = slice( + ixs[i][0], ixs[i][-1] + 1 + ) # add 1 because these are direct indices + # slices n_datapoints dim + + # calculate missing ixs using set difference + # then calculate shift + missing_ixs = ( + np.setdiff1d(np.arange(ixs[i][0], ixs[i][-1] + 1), ixs[i]) + - ixs[i][0] + ) + + match mode: + # take all ixs, ignore missing + case "full": + data_selections.append(g.data[s]) + # set missing ixs data to NaNs + case "partial": + if len(missing_ixs) > 0: + data = g.data[s].copy() + data[missing_ixs] = np.nan + data_selections.append(data) + else: + data_selections.append(g.data[s]) + # ignore lines that do not have full ixs to start + case "ignore": + if len(missing_ixs) > 0: + data_selections.append( + np.array([], dtype=np.float32).reshape(0, 3) + ) + else: + data_selections.append(g.data[s]) + return data_selections + else: # for lines + if ixs.size == 0: + # empty selection + return np.array([], dtype=np.float32).reshape(0, 3) + + s = slice( + ixs[0], ixs[-1] + 1 + ) # add 1 to end because these are direct indices + # slices n_datapoints dim + # slice with min, max is faster than using all the indices + + # get missing ixs + missing_ixs = np.setdiff1d(np.arange(ixs[0], ixs[-1] + 1), ixs) - ixs[0] + + match mode: + # return all, do not care about missing + case "full": + return source.data[s] + # set missing to NaNs + case "partial": + if len(missing_ixs) > 0: + data = source.data[s].copy() + data[missing_ixs] = np.nan + return data + else: + return source.data[s] + # missing means nothing will be returned even if selector is partially over data + # warn the user and return empty + case "ignore": + if len(missing_ixs) > 0: + warnings.warn( + "You have selected 'ignore' mode. Selected graphic has incomplete indices. " + "Move the selector or change the mode to one of `partial` or `full`." + ) + return np.array([], dtype=np.float32) + else: + return source.data[s] + def get_selected_indices( self, graphic: Graphic = None ) -> np.ndarray | tuple[np.ndarray]: @@ -182,6 +306,8 @@ def _fpl_add_plot_area_hook(self, plot_area): self._move_segment_endpoint, "pointer_move" ) + self.__.add_event_handler(pfunc_down, "pointer_down") + self.position_z = len(self._plot_area) + 10 def _on_click(self, ev): From 79c49528bd9971d190625cb30140cf7ec7e9781c Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Thu, 3 Jul 2025 16:47:27 +0200 Subject: [PATCH 10/19] wip interaction --- fastplotlib/graphics/image.py | 8 +- fastplotlib/graphics/line.py | 5 +- fastplotlib/graphics/line_collection.py | 9 +- .../graphics/selectors/_base_selector.py | 2 + fastplotlib/graphics/selectors/_polygon.py | 147 +++++++++++------- 5 files changed, 94 insertions(+), 77 deletions(-) diff --git a/fastplotlib/graphics/image.py b/fastplotlib/graphics/image.py index e396ce145..195789086 100644 --- a/fastplotlib/graphics/image.py +++ b/fastplotlib/graphics/image.py @@ -460,19 +460,13 @@ def add_polygon_selector( initial points for the polygon """ - # default selection is 25% of the diagonal - if selection is None: - diagonal = math.sqrt( - self._data.value.shape[0] ** 2 + self._data.value.shape[1] ** 2 - ) - - selection = (0, int(diagonal / 4), 0, int(diagonal / 4)) # min/max limits are image shape # rows are ys, columns are xs limits = (0, self._data.value.shape[1], 0, self._data.value.shape[0]) selector = PolygonSelector( + selection, limits, fill_color=fill_color, parent=self, diff --git a/fastplotlib/graphics/line.py b/fastplotlib/graphics/line.py index 0b5551b24..fc3b00687 100644 --- a/fastplotlib/graphics/line.py +++ b/fastplotlib/graphics/line.py @@ -319,14 +319,11 @@ def add_polygon_selector( ymin = np.floor(y_axis_vals.min()).astype(int) ymax = np.ceil(y_axis_vals.max()).astype(int) - # default selection is 25% of the image - if selection is None: - selection = [] - # min/max limits limits = (x_axis_vals[0], x_axis_vals[-1], ymin * 1.5, ymax * 1.5) selector = PolygonSelector( + selection, limits, parent=self, **kwargs, diff --git a/fastplotlib/graphics/line_collection.py b/fastplotlib/graphics/line_collection.py index d629ddd50..e26b4a63b 100644 --- a/fastplotlib/graphics/line_collection.py +++ b/fastplotlib/graphics/line_collection.py @@ -452,7 +452,7 @@ def add_linear_region_selector( def add_rectangle_selector( self, - selection: tuple[float, float, float, float] = None, + selection: tuple[float, float, float] = None, **kwargs, ) -> RectangleSelector: """ @@ -515,15 +515,10 @@ def add_polygon_selector( ymax = np.ptp(bbox[:, 1]) - if selection is None: - selection = [] - limits = (xmin, xmax, ymin - (ymax * 1.5 - ymax), ymax * 1.5) - if selection is not None: - selection = [] # TODO: fill selection - selector = PolygonSelector( + selection, limits, parent=self, **kwargs, diff --git a/fastplotlib/graphics/selectors/_base_selector.py b/fastplotlib/graphics/selectors/_base_selector.py index 1542d2bad..e53b7ae02 100644 --- a/fastplotlib/graphics/selectors/_base_selector.py +++ b/fastplotlib/graphics/selectors/_base_selector.py @@ -28,6 +28,8 @@ class MoveInfo: # WorldObject or "key" event source: WorldObject | str + ref: Any = None + # key bindings used to move the selector key_bind_direction = { diff --git a/fastplotlib/graphics/selectors/_polygon.py b/fastplotlib/graphics/selectors/_polygon.py index 9a0bc0a67..9fc02db5c 100644 --- a/fastplotlib/graphics/selectors/_polygon.py +++ b/fastplotlib/graphics/selectors/_polygon.py @@ -15,6 +15,7 @@ class PolygonSelector(BaseSelector): _features = {"selection": PolygonSelectionFeature} _last_click = (-10, -10, 0) + _move_mode = None @property def parent(self) -> Graphic | None: @@ -54,17 +55,20 @@ def limits(self, values: Tuple[float, float, float, float]): def __init__( self, + selection: Optional[Sequence[Tuple[float]]], limits: Sequence[float], parent: Graphic = None, + resizable: bool = True, fill_color=(0, 0, 0.35, 0.2), edge_color=(0.8, 0.6, 0), - edge_thickness: float = 8, + edge_thickness: float = 4, vertex_color=(0.7, 0.4, 0), vertex_size: float = 8, name: str = None, ): self._parent = parent self._move_info: MoveInfo = None + self._resizable = bool(resizable) BaseSelector.__init__(self, name=name, parent=parent) @@ -75,19 +79,30 @@ def __init__( self.geometry.positions.draw_range = 0, 0 self.geometry.indices.draw_range = 0, 0 - edge = pygfx.Line( + self._edge = pygfx.Line( self.geometry, - pygfx.LineMaterial(thickness=edge_thickness, color=edge_color), + pygfx.LineMaterial( + thickness=edge_thickness, color=edge_color, pick_write=False + ), ) - points = pygfx.Points( + self._points = pygfx.Points( self.geometry, - pygfx.PointsMaterial(size=vertex_size, color=vertex_color), + pygfx.PointsMaterial( + size=vertex_size, color=vertex_color, pick_write=False + ), ) - mesh = pygfx.Mesh(self.geometry, pygfx.MeshBasicMaterial(color=fill_color)) - group = pygfx.Group().add(edge, points, mesh) + self._mesh = pygfx.Mesh( + self.geometry, pygfx.MeshBasicMaterial(color=fill_color, pick_write=False) + ) + group = pygfx.Group().add(self._edge, self._points, self._mesh) self._set_world_object(group) - self._selection = PolygonSelectionFeature([], [0, 0, 0, 0]) + # if selection is None: + if selection is None: + self._move_mode = "create" # picked up by _fpl_add_plot_area_hook in a sec + selection = [(0, 0, 0)] + self.geometry.positions.draw_range = 0, 1 + self._selection = PolygonSelectionFeature(selection, (0, 0, 0, 0)) self.edge_color = edge_color self.edge_width = edge_thickness @@ -233,7 +248,7 @@ def get_selected_indices( ------- Union[np.ndarray, List[np.ndarray]] data indicies of the selection - | tuple of [row_indices, col_indices] if the graphic is an image + | array of (x, y) indices if the graphic is an image | list of indices along the x-dimension for each line if graphic is a line collection | array of indices along the x-dimension if graphic is a line """ @@ -296,82 +311,96 @@ def get_selected_indices( def _fpl_add_plot_area_hook(self, plot_area): self._plot_area = plot_area - self._plot_area.controller.enabled = False - - # click to add new segment - self._plot_area.renderer.add_event_handler(self._on_click, "click") - # pointer move to change endpoint of segment self._plot_area.renderer.add_event_handler( - self._move_segment_endpoint, "pointer_move" + self._on_pointer_down, "pointer_down" ) - - self.__.add_event_handler(pfunc_down, "pointer_down") + self._plot_area.renderer.add_event_handler( + self._on_pointer_move, "pointer_move" + ) + self._plot_area.renderer.add_event_handler(self._on_pointer_up, "pointer_up") self.position_z = len(self._plot_area) + 10 - def _on_click(self, ev): - last_click = self._last_click - self._last_click = ev.x, ev.y, ev.time_stamp + if self._move_mode == "create": + self._start_move_mode_create() + def _start_move_mode_create(self): + self._plot_area.controller.enabled = False + self._move_mode = "create" + + def _on_pointer_down(self, ev): world_pos = self._plot_area.map_screen_to_world(ev) if world_pos is None: return - if np.linalg.norm([last_click[0] - ev.x, last_click[1] - ev.y]) > 5: - self._start_finish_segment(world_pos) - elif (ev.time_stamp - last_click[2]) < 2: - self._last_click = (-10, -10, 0) - self._finish_polygon(world_pos) - else: - pass # a too slow double click + if self._move_mode == "create": + last_click = self._last_click + self._last_click = ev.x, ev.y, ev.time_stamp + if np.linalg.norm([last_click[0] - ev.x, last_click[1] - ev.y]) > 5: + self._add_polygon_vertex(world_pos) + elif (ev.time_stamp - last_click[2]) < 2: + self._last_click = (-10, -10, 0) + self._finish_polygon(world_pos) + else: + pass # a too slow double click - def _start_finish_segment(self, world_pos): - """After click event, adds a new line segment""" + elif self._move_mode is None: + # No move mode, so we can initiate a drag if we clicked on a vertex - self._move_info = MoveInfo( - start_selection=None, - start_position=world_pos, - delta=np.zeros_like(world_pos), - source=None, - ) - - # line with same position for start and end until mouse moves - if len(self.selection) == 0: - data = np.vstack([self.selection, world_pos, world_pos]) - else: - data = np.vstack([self.selection, world_pos]) - - self._selection.set_value(self, data) + self._move_mode = "drag" + self._move_info = MoveInfo( + start_selection=self.selection, + start_position=world_pos, + delta=np.zeros_like(world_pos), + source=None, + ) + # TODO: initite drag, eirher on an existing vertex, or a new one + # TODO: also delete vertices by double clicking them? + breakpoint() - def _move_segment_endpoint(self, ev): + def _on_pointer_move(self, ev): """After mouse pointer move event, moves endpoint of current line segment""" - if self._move_info is None: + if self._move_mode is None: return - world_pos = self._plot_area.map_screen_to_world(ev) if world_pos is None: return - # change endpoint - data = self.selection - data[-1] = world_pos + if self._move_mode == "create": + # change endpoint + data = self.selection + data[-1] = world_pos + self._selection.set_value(self, data) + + def _on_pointer_up(self, ev): + if self._move_mode == "drag": + self._move_mode = None + self._move_info = None + + def _add_polygon_vertex(self, world_pos): + """After click event, adds a new line segment""" + + # self._move_info = MoveInfo( + # start_selection=None, + # start_position=world_pos, + # delta=np.zeros_like(world_pos), + # source=None, + # ) + print(world_pos) + # line with same position for start and end until mouse moves + if len(self.selection) == 0: + data = np.vstack([self.selection, world_pos, world_pos]) + else: + data = np.vstack([self.selection, world_pos]) + self._selection.set_value(self, data) def _finish_polygon(self, world_pos): """finishes the polygon, disconnects events""" - self.world_object.children[0].material.loop = True - self._plot_area.controller.enabled = True - - handlers = { - self._on_click: "click", - self._move_segment_endpoint: "pointer_move", - } - - for handler, event in handlers.items(): - self._plot_area.renderer.remove_event_handler(handler, event) + self._move_mode = None def is_left(p0, p1, p2): From 44e2b06a43113e1f66bb9654c6a4cf0e03bfc2c1 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 4 Jul 2025 13:57:31 +0200 Subject: [PATCH 11/19] Progress on interaction --- .../graphics/features/_selection_features.py | 4 +- fastplotlib/graphics/selectors/_polygon.py | 187 +++++++++++------- 2 files changed, 120 insertions(+), 71 deletions(-) diff --git a/fastplotlib/graphics/features/_selection_features.py b/fastplotlib/graphics/features/_selection_features.py index bf18902a4..e15ca18f4 100644 --- a/fastplotlib/graphics/features/_selection_features.py +++ b/fastplotlib/graphics/features/_selection_features.py @@ -428,12 +428,12 @@ def set_value(self, selector, value: Sequence[tuple[float]]): geometry.indices = gfx.Buffer(arr) geometry.positions.data[: len(value)] = value - geometry.positions.data[len(value)] = value[-1] if len(value) else (0, 0, 0) + geometry.positions.data[len(value):] = value[-1] if len(value) else (0, 0, 0) geometry.positions.draw_range = 0, len(value) geometry.positions.update_full() geometry.indices.data[: len(indices)] = indices - geometry.indices.data[len(indices)] = 0 + geometry.indices.data[len(indices):] = 0 geometry.indices.draw_range = 0, len(indices) geometry.indices.update_full() diff --git a/fastplotlib/graphics/selectors/_polygon.py b/fastplotlib/graphics/selectors/_polygon.py index 9fc02db5c..64a1c436e 100644 --- a/fastplotlib/graphics/selectors/_polygon.py +++ b/fastplotlib/graphics/selectors/_polygon.py @@ -1,6 +1,7 @@ import warnings from typing import * +from dataclasses import dataclass from numbers import Real import numpy as np @@ -9,13 +10,18 @@ from .._base import Graphic from .._collection_base import GraphicCollection from ..features._selection_features import PolygonSelectionFeature -from ._base_selector import BaseSelector, MoveInfo +from ._base_selector import BaseSelector + + +@dataclass +class MoveInfo: + mode: str + index: int + snap_index: int class PolygonSelector(BaseSelector): _features = {"selection": PolygonSelectionFeature} - _last_click = (-10, -10, 0) - _move_mode = None @property def parent(self) -> Graphic | None: @@ -63,14 +69,14 @@ def __init__( edge_color=(0.8, 0.6, 0), edge_thickness: float = 4, vertex_color=(0.7, 0.4, 0), - vertex_size: float = 8, + vertex_size: float = 12, name: str = None, ): self._parent = parent - self._move_info: MoveInfo = None self._resizable = bool(resizable) BaseSelector.__init__(self, name=name, parent=parent) + self._move_info = MoveInfo("none", -1, -1) self.geometry = pygfx.Geometry( positions=np.zeros((8, 3), np.float32), @@ -79,28 +85,32 @@ def __init__( self.geometry.positions.draw_range = 0, 0 self.geometry.indices.draw_range = 0, 0 - self._edge = pygfx.Line( + self._line = pygfx.Line( self.geometry, pygfx.LineMaterial( - thickness=edge_thickness, color=edge_color, pick_write=False + thickness=edge_thickness, color=edge_color, pick_write=True ), ) self._points = pygfx.Points( self.geometry, - pygfx.PointsMaterial( - size=vertex_size, color=vertex_color, pick_write=False - ), + pygfx.PointsMaterial(size=vertex_size, color=vertex_color, pick_write=True), ) + self._indicator = pygfx.Points( + pygfx.Geometry(positions=[[0, 0, 0]]), + pygfx.PointsMaterial(size=15, color=vertex_color, opacity=0.3), + ) + self._indicator.visible = False + self._points.local.z = 0.01 # move it slightly towards the camera self._mesh = pygfx.Mesh( self.geometry, pygfx.MeshBasicMaterial(color=fill_color, pick_write=False) ) - group = pygfx.Group().add(self._edge, self._points, self._mesh) + group = pygfx.Group().add(self._line, self._points, self._mesh, self._indicator) self._set_world_object(group) # if selection is None: if selection is None: - self._move_mode = "create" # picked up by _fpl_add_plot_area_hook in a sec - selection = [(0, 0, 0)] + self._move_info.mode = "create" + selection = [] self.geometry.positions.draw_range = 0, 1 self._selection = PolygonSelectionFeature(selection, (0, 0, 0, 0)) @@ -258,6 +268,9 @@ def get_selected_indices( # selector (xmin, xmax, ymin, ymax) values polygon = self.selection[:, :2] + if len(polygon) == 0: + return None + # Get bounding box to be able to do first selection xmin, xmax = polygon[:, 0].min(), polygon[:, 0].max() ymin, ymax = polygon[:, 1].min(), polygon[:, 1].max() @@ -322,85 +335,121 @@ def _fpl_add_plot_area_hook(self, plot_area): self.position_z = len(self._plot_area) + 10 - if self._move_mode == "create": - self._start_move_mode_create() + if self._move_info.mode == "create": + self._start_move_mode("create", -1) - def _start_move_mode_create(self): + def _start_move_mode(self, what, index): self._plot_area.controller.enabled = False - self._move_mode = "create" + self._move_info.mode = what + self._move_info.index = index + self._move_info.snap_index = None + self._indicator.visible = True + + def _end_move_mode(self): + if self._move_info.mode == "create": + self.world_object.children[0].material.loop = True + self._plot_area.controller.enabled = True + self._move_info.mode = None + self._indicator.visible = False def _on_pointer_down(self, ev): world_pos = self._plot_area.map_screen_to_world(ev) if world_pos is None: return - if self._move_mode == "create": - last_click = self._last_click - self._last_click = ev.x, ev.y, ev.time_stamp - if np.linalg.norm([last_click[0] - ev.x, last_click[1] - ev.y]) > 5: - self._add_polygon_vertex(world_pos) - elif (ev.time_stamp - last_click[2]) < 2: - self._last_click = (-10, -10, 0) - self._finish_polygon(world_pos) + if self._move_info.mode == "create": + # Add a polygon or finish it + if self._move_info.snap_index is not None: + pass # on release we finish the polygon else: - pass # a too slow double click - - elif self._move_mode is None: - # No move mode, so we can initiate a drag if we clicked on a vertex - - self._move_mode = "drag" - self._move_info = MoveInfo( - start_selection=self.selection, - start_position=world_pos, - delta=np.zeros_like(world_pos), - source=None, - ) - # TODO: initite drag, eirher on an existing vertex, or a new one - # TODO: also delete vertices by double clicking them? - breakpoint() + self._insert_polygon_vertex(999999, world_pos) + + elif self._move_info.mode is None: + # Maybe initiate a drag + if ev.target is self._points: + index = ev.pick_info["vertex_index"] + self._start_move_mode("drag", index) + elif ev.target is self._line: + index = ev.pick_info["vertex_index"] + if ev.pick_info["segment_coord"] > 0: + index += 1 + self._insert_polygon_vertex(index, world_pos) + self._start_move_mode("drag", index) def _on_pointer_move(self, ev): """After mouse pointer move event, moves endpoint of current line segment""" - if self._move_mode is None: + if self._move_info.mode is None: return world_pos = self._plot_area.map_screen_to_world(ev) if world_pos is None: return - if self._move_mode == "create": - # change endpoint + # Are we close to a point that we can snap to? + index = self._move_info.index + snap_index = None + if ev.target is self._points: + snap_index = ev.pick_info["vertex_index"] + if snap_index == index: # dont snap to moving point + snap_index = None + if len(self.selection) < 4: + snap_index = None + if self._move_info.mode == "create" and snap_index != 0: + snap_index = None + if self._move_info.mode == "drag" and snap_index not in (index - 1, index + 1): + snap_index = None + self._move_info.snap_index = snap_index + + # Show state of snap index to user + if snap_index is not None: + world_pos = self.geometry.positions.data[snap_index] + self._indicator.material.size = 30 + else: + self._indicator.material.size = 15 + + # Move the positions being moved a bit down z, so its not preferred in picking + world_pos = (world_pos[0], world_pos[1], -0.01) + + self._indicator.local.position = world_pos + + # Update data + if self._move_info.mode in ("create", "drag"): data = self.selection - data[-1] = world_pos - self._selection.set_value(self, data) + if len(data) > 0: + data[self._move_info.index] = world_pos + self._selection.set_value(self, data) def _on_pointer_up(self, ev): - if self._move_mode == "drag": - self._move_mode = None - self._move_info = None - - def _add_polygon_vertex(self, world_pos): - """After click event, adds a new line segment""" - - # self._move_info = MoveInfo( - # start_selection=None, - # start_position=world_pos, - # delta=np.zeros_like(world_pos), - # source=None, - # ) - print(world_pos) - # line with same position for start and end until mouse moves - if len(self.selection) == 0: - data = np.vstack([self.selection, world_pos, world_pos]) + if self._move_info.mode in ("create", "drag"): + # Update data to set z to zero again + data = self.selection + data[self._move_info.index][2] = 0 + self._selection.set_value(self, data) + # If we snapped, we dissolve (i.e. delete the vertex being moved) + if self._move_info.snap_index is not None: + self._delete_polygon_vertex(self._move_info.index) + + # Moving the mouse up may end the move action + if self._move_info.mode == "create": + if self._move_info.snap_index is not None: + self._end_move_mode() + elif self._move_info.mode == "drag": + self._end_move_mode() + + def _insert_polygon_vertex(self, i, world_pos): + selection = self.selection + if len(selection) == 0: + data = np.vstack([selection, world_pos, world_pos]) else: - data = np.vstack([self.selection, world_pos]) - + data = np.vstack([selection[:i], world_pos, selection[i:]]) self._selection.set_value(self, data) - def _finish_polygon(self, world_pos): - """finishes the polygon, disconnects events""" - self.world_object.children[0].material.loop = True - self._plot_area.controller.enabled = True - self._move_mode = None + def _delete_polygon_vertex(self, i): + selection = self.selection + if i < 0: + data = selection[:i] + else: + data = np.vstack([selection[:i], selection[i + 1 :]]) + self._selection.set_value(self, data) def is_left(p0, p1, p2): From 9c4dcccd7b87d503386cb2344f9c08aca494e6e2 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 4 Jul 2025 14:27:05 +0200 Subject: [PATCH 12/19] Allow restarting and setting the polygon selection --- examples/selection_tools/polygon_selector.py | 1 - .../graphics/features/_selection_features.py | 4 ++-- fastplotlib/graphics/selectors/_polygon.py | 21 ++++++++++++++----- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/examples/selection_tools/polygon_selector.py b/examples/selection_tools/polygon_selector.py index 55110b8d7..9b1d0fbac 100644 --- a/examples/selection_tools/polygon_selector.py +++ b/examples/selection_tools/polygon_selector.py @@ -47,7 +47,6 @@ def make_circle(center, radius: float, n_points: int = 75) -> np.ndarray: def color_indices(ev): line_collection.cmap = "jet" ixs = ev.get_selected_indices() - # iterate through each of the selected indices, if the array size > 0 that mean it's under the selection selected_line_ixs = [i for i in range(len(ixs)) if ixs[i].size > 0] line_collection[selected_line_ixs].colors = "w" diff --git a/fastplotlib/graphics/features/_selection_features.py b/fastplotlib/graphics/features/_selection_features.py index e15ca18f4..d4a37d365 100644 --- a/fastplotlib/graphics/features/_selection_features.py +++ b/fastplotlib/graphics/features/_selection_features.py @@ -428,12 +428,12 @@ def set_value(self, selector, value: Sequence[tuple[float]]): geometry.indices = gfx.Buffer(arr) geometry.positions.data[: len(value)] = value - geometry.positions.data[len(value):] = value[-1] if len(value) else (0, 0, 0) + geometry.positions.data[len(value) :] = value[-1] if len(value) else (0, 0, 0) geometry.positions.draw_range = 0, len(value) geometry.positions.update_full() geometry.indices.data[: len(indices)] = indices - geometry.indices.data[len(indices):] = 0 + geometry.indices.data[len(indices) :] = 0 geometry.indices.draw_range = 0, len(indices) geometry.indices.update_full() diff --git a/fastplotlib/graphics/selectors/_polygon.py b/fastplotlib/graphics/selectors/_polygon.py index 64a1c436e..390a1d60a 100644 --- a/fastplotlib/graphics/selectors/_polygon.py +++ b/fastplotlib/graphics/selectors/_polygon.py @@ -107,16 +107,14 @@ def __init__( group = pygfx.Group().add(self._line, self._points, self._mesh, self._indicator) self._set_world_object(group) - # if selection is None: if selection is None: - self._move_info.mode = "create" selection = [] - self.geometry.positions.draw_range = 0, 1 self._selection = PolygonSelectionFeature(selection, (0, 0, 0, 0)) self.edge_color = edge_color self.edge_width = edge_thickness self.limits = limits + self.selection = self.selection # trigger positions to be created def get_selected_data( self, graphic: Graphic = None, mode: str = "full" @@ -268,8 +266,15 @@ def get_selected_indices( # selector (xmin, xmax, ymin, ymax) values polygon = self.selection[:, :2] + # Empty ... if len(polygon) == 0: - return None + if "Image" in source.__class__.__name__: + return np.zeros((0, 2), np.int32) + if "Line" in source.__class__.__name__: + if isinstance(source, GraphicCollection): + return [np.zeros((0, 1), np.int32) for _ in source.graphics] + else: + return np.zeros((0, 1), np.int32) # Get bounding box to be able to do first selection xmin, xmax = polygon[:, 0].min(), polygon[:, 0].max() @@ -335,14 +340,20 @@ def _fpl_add_plot_area_hook(self, plot_area): self.position_z = len(self._plot_area) + 10 - if self._move_info.mode == "create": + if len(self.selection) == 0: self._start_move_mode("create", -1) + def start_new_polygon(self): + """Remove the current polygon and start drawing a new one.""" + self.selection = np.zeros((0, 3), np.float32) + self._start_move_mode("create", -1) + def _start_move_mode(self, what, index): self._plot_area.controller.enabled = False self._move_info.mode = what self._move_info.index = index self._move_info.snap_index = None + self._indicator.material.size = 15 self._indicator.visible = True def _end_move_mode(self): From 24b77075e1f068483288fd668b95b36d88225283 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 4 Jul 2025 14:31:10 +0200 Subject: [PATCH 13/19] cleanuo --- fastplotlib/graphics/selectors/_polygon.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/fastplotlib/graphics/selectors/_polygon.py b/fastplotlib/graphics/selectors/_polygon.py index 390a1d60a..51db3faa9 100644 --- a/fastplotlib/graphics/selectors/_polygon.py +++ b/fastplotlib/graphics/selectors/_polygon.py @@ -15,6 +15,8 @@ @dataclass class MoveInfo: + """Movement info specific to the polygon selector.""" + mode: str index: int snap_index: int @@ -95,12 +97,12 @@ def __init__( self.geometry, pygfx.PointsMaterial(size=vertex_size, color=vertex_color, pick_write=True), ) + self._points.local.z = 0.01 # move it slightly towards the camera self._indicator = pygfx.Points( pygfx.Geometry(positions=[[0, 0, 0]]), pygfx.PointsMaterial(size=15, color=vertex_color, opacity=0.3), ) self._indicator.visible = False - self._points.local.z = 0.01 # move it slightly towards the camera self._mesh = pygfx.Mesh( self.geometry, pygfx.MeshBasicMaterial(color=fill_color, pick_write=False) ) From cb7ddf80dbfdf18c492b7eb9f8ed22462498c3ab Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 4 Jul 2025 14:40:43 +0200 Subject: [PATCH 14/19] cleanup --- fastplotlib/graphics/selectors/_base_selector.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/fastplotlib/graphics/selectors/_base_selector.py b/fastplotlib/graphics/selectors/_base_selector.py index e53b7ae02..1542d2bad 100644 --- a/fastplotlib/graphics/selectors/_base_selector.py +++ b/fastplotlib/graphics/selectors/_base_selector.py @@ -28,8 +28,6 @@ class MoveInfo: # WorldObject or "key" event source: WorldObject | str - ref: Any = None - # key bindings used to move the selector key_bind_direction = { From 593ce4d53243476a4e3f945d4dca3e4512b753f7 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Wed, 27 Aug 2025 16:26:54 +0200 Subject: [PATCH 15/19] Update fastplotlib/graphics/selectors/_polygon.py Co-authored-by: Kushal Kolar --- fastplotlib/graphics/selectors/_polygon.py | 1 - 1 file changed, 1 deletion(-) diff --git a/fastplotlib/graphics/selectors/_polygon.py b/fastplotlib/graphics/selectors/_polygon.py index 51db3faa9..0e133395f 100644 --- a/fastplotlib/graphics/selectors/_polygon.py +++ b/fastplotlib/graphics/selectors/_polygon.py @@ -39,7 +39,6 @@ def selection(self) -> np.ndarray[float]: @selection.setter def selection(self, selection: np.ndarray[float]): - # set (xmin, xmax, ymin, ymax) of the selector in data space graphic = self._parent if isinstance(graphic, GraphicCollection): From ec51a95af8839eba09dc51b5c7d94bab7f623626 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Thu, 28 Aug 2025 09:32:37 +0200 Subject: [PATCH 16/19] address reviewer comments --- .../graphics/features/_selection_features.py | 3 --- fastplotlib/graphics/image.py | 2 +- fastplotlib/graphics/line.py | 4 ++-- fastplotlib/graphics/line_collection.py | 2 +- fastplotlib/graphics/selectors/_polygon.py | 16 ++++++++++++++-- 5 files changed, 18 insertions(+), 9 deletions(-) diff --git a/fastplotlib/graphics/features/_selection_features.py b/fastplotlib/graphics/features/_selection_features.py index d4a37d365..3052ae3d0 100644 --- a/fastplotlib/graphics/features/_selection_features.py +++ b/fastplotlib/graphics/features/_selection_features.py @@ -414,9 +414,6 @@ def set_value(self, selector, value: Sequence[tuple[float]]): else: indices = np.zeros((0, 3), np.int32) - # TODO: Update the fill mesh - # selector.fill.geometry.positions = ... - geometry = selector.geometry # Need larger buffer? diff --git a/fastplotlib/graphics/image.py b/fastplotlib/graphics/image.py index 7caa0979c..85e34a413 100644 --- a/fastplotlib/graphics/image.py +++ b/fastplotlib/graphics/image.py @@ -457,7 +457,7 @@ def add_polygon_selector( Parameters ---------- selection: List of positions, optional - initial points for the polygon + Initial points for the polygon. If not given or None, you'll start drawing the selection (clicking adds points to the polygon). """ diff --git a/fastplotlib/graphics/line.py b/fastplotlib/graphics/line.py index 94c335413..7e6ecee93 100644 --- a/fastplotlib/graphics/line.py +++ b/fastplotlib/graphics/line.py @@ -306,8 +306,8 @@ def add_polygon_selector( Parameters ---------- - selection: (float, float, float, float), optional - initial (xmin, xmax, ymin, ymax) of the selection + selection: List of positions, optional + Initial points for the polygon. If not given or None, you'll start drawing the selection (clicking adds points to the polygon). """ # remove any nans diff --git a/fastplotlib/graphics/line_collection.py b/fastplotlib/graphics/line_collection.py index e26b4a63b..ad4bf74d5 100644 --- a/fastplotlib/graphics/line_collection.py +++ b/fastplotlib/graphics/line_collection.py @@ -503,7 +503,7 @@ def add_polygon_selector( Parameters ---------- selection: List of positions, optional - initial points for the polygon + Initial points for the polygon. If not given or None, you'll start drawing the selection (clicking adds points to the polygon). """ bbox = self.world_object.get_world_bounding_box() diff --git a/fastplotlib/graphics/selectors/_polygon.py b/fastplotlib/graphics/selectors/_polygon.py index 0e133395f..755001b02 100644 --- a/fastplotlib/graphics/selectors/_polygon.py +++ b/fastplotlib/graphics/selectors/_polygon.py @@ -17,8 +17,13 @@ class MoveInfo: """Movement info specific to the polygon selector.""" + # The interaction mode: None, 'create', or 'drag' mode: str + + # The index of the point in the polygon that is currently being manipulated index: int + + # The index of the point in the polygon to snap to. This is used to merge (i.e. delete) points, and to close the polygon. snap_index: int @@ -33,7 +38,7 @@ def parent(self) -> Graphic | None: @property def selection(self) -> np.ndarray[float]: """ - The polygon as an array of 3D points. + The polygon as an array of 3D points. The shape is [n_points, 3]. """ return self._selection.value.copy() @@ -79,10 +84,14 @@ def __init__( BaseSelector.__init__(self, name=name, parent=parent) self._move_info = MoveInfo("none", -1, -1) + # Initialize geometry with space for 8 points. The buffers are oversized, so we only need to create new buffers when the allocated space is full. + # The points are 3D, even though the z-component is always 0. Indices represent the faces (i.e. the triangles). self.geometry = pygfx.Geometry( positions=np.zeros((8, 3), np.float32), indices=np.zeros((8, 3), np.int32), ) + + # The draw range allows us to draw only part of the buffer, i.e. it allows us to oversize our buffers to avoid creating a new one for every added point. self.geometry.positions.draw_range = 0, 0 self.geometry.indices.draw_range = 0, 0 @@ -96,7 +105,7 @@ def __init__( self.geometry, pygfx.PointsMaterial(size=vertex_size, color=vertex_color, pick_write=True), ) - self._points.local.z = 0.01 # move it slightly towards the camera + self._points.local.z = 0.1 # move it slightly towards the camera self._indicator = pygfx.Points( pygfx.Geometry(positions=[[0, 0, 0]]), pygfx.PointsMaterial(size=15, color=vertex_color, opacity=0.3), @@ -397,6 +406,9 @@ def _on_pointer_move(self, ev): return # Are we close to a point that we can snap to? + # The concept of snapping is to prevent the user from creating points that are very close to each-other, + # allowing the user to merge points by dragging one onto its neighbour, and allowing the user to close the polygon + # by clicking on the first point when in 'create' mode. index = self._move_info.index snap_index = None if ev.target is self._points: From 03b43a92ad6bbf3e6c95c53a8e1a36b88766781e Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Thu, 28 Aug 2025 11:26:34 +0200 Subject: [PATCH 17/19] allow dragging the whole polygon --- fastplotlib/graphics/selectors/_polygon.py | 55 +++++++++++++++++----- 1 file changed, 42 insertions(+), 13 deletions(-) diff --git a/fastplotlib/graphics/selectors/_polygon.py b/fastplotlib/graphics/selectors/_polygon.py index 755001b02..b53ae2c03 100644 --- a/fastplotlib/graphics/selectors/_polygon.py +++ b/fastplotlib/graphics/selectors/_polygon.py @@ -23,9 +23,15 @@ class MoveInfo: # The index of the point in the polygon that is currently being manipulated index: int - # The index of the point in the polygon to snap to. This is used to merge (i.e. delete) points, and to close the polygon. + # The index of the point in the polygon to snap to. This is used to merge (i.e. delete) points, and to finish se the polygon. snap_index: int + # The position of the cursor at the start of a drag + start_pos: np.ndarray | None + + # The position of the vertices at the start of a drag + start_positions: np.ndarray | None + class PolygonSelector(BaseSelector): _features = {"selection": PolygonSelectionFeature} @@ -82,7 +88,7 @@ def __init__( self._resizable = bool(resizable) BaseSelector.__init__(self, name=name, parent=parent) - self._move_info = MoveInfo("none", -1, -1) + self._move_info = MoveInfo("none", -1, -1, None, None) # Initialize geometry with space for 8 points. The buffers are oversized, so we only need to create new buffers when the allocated space is full. # The points are 3D, even though the z-component is always 0. Indices represent the faces (i.e. the triangles). @@ -105,18 +111,21 @@ def __init__( self.geometry, pygfx.PointsMaterial(size=vertex_size, color=vertex_color, pick_write=True), ) - self._points.local.z = 0.1 # move it slightly towards the camera self._indicator = pygfx.Points( pygfx.Geometry(positions=[[0, 0, 0]]), pygfx.PointsMaterial(size=15, color=vertex_color, opacity=0.3), ) self._indicator.visible = False self._mesh = pygfx.Mesh( - self.geometry, pygfx.MeshBasicMaterial(color=fill_color, pick_write=False) + self.geometry, pygfx.MeshBasicMaterial(color=fill_color, pick_write=True) ) group = pygfx.Group().add(self._line, self._points, self._mesh, self._indicator) self._set_world_object(group) + # Order in z, so stuff stays pickable + self._line.local.z = 0.1 + self._points.local.z = 0.2 + if selection is None: selection = [] self._selection = PolygonSelectionFeature(selection, (0, 0, 0, 0)) @@ -358,19 +367,25 @@ def start_new_polygon(self): self.selection = np.zeros((0, 3), np.float32) self._start_move_mode("create", -1) - def _start_move_mode(self, what, index): + def _start_move_mode(self, what, index, start_pos=None): self._plot_area.controller.enabled = False self._move_info.mode = what self._move_info.index = index self._move_info.snap_index = None self._indicator.material.size = 15 self._indicator.visible = True + if start_pos is not None: + self._move_info.start_pos = start_pos + self._move_info.start_positions = self.selection.copy() + self._indicator.visible = False def _end_move_mode(self): if self._move_info.mode == "create": self.world_object.children[0].material.loop = True self._plot_area.controller.enabled = True self._move_info.mode = None + self._move_info.start_pos = None + self._move_info.start_positions = None self._indicator.visible = False def _on_pointer_down(self, ev): @@ -396,6 +411,9 @@ def _on_pointer_down(self, ev): index += 1 self._insert_polygon_vertex(index, world_pos) self._start_move_mode("drag", index) + elif ev.target is self._mesh: + index = None # move whole polygon + self._start_move_mode("drag", index, world_pos) def _on_pointer_move(self, ev): """After mouse pointer move event, moves endpoint of current line segment""" @@ -406,9 +424,10 @@ def _on_pointer_move(self, ev): return # Are we close to a point that we can snap to? - # The concept of snapping is to prevent the user from creating points that are very close to each-other, - # allowing the user to merge points by dragging one onto its neighbour, and allowing the user to close the polygon - # by clicking on the first point when in 'create' mode. + # The concept of snapping does multiple things: + # - preventing the user from creating points that are very close to each-other, + # - allowing the user to finish the polygon by connecting to the start-point when in 'create' mode. + # - allowing the user to merge points by dragging one onto its neighbour. index = self._move_info.index snap_index = None if ev.target is self._points: @@ -419,7 +438,11 @@ def _on_pointer_move(self, ev): snap_index = None if self._move_info.mode == "create" and snap_index != 0: snap_index = None - if self._move_info.mode == "drag" and snap_index not in (index - 1, index + 1): + if ( + self._move_info.mode == "drag" + and index is not None + and snap_index not in (index - 1, index + 1) + ): snap_index = None self._move_info.snap_index = snap_index @@ -439,17 +462,23 @@ def _on_pointer_move(self, ev): if self._move_info.mode in ("create", "drag"): data = self.selection if len(data) > 0: - data[self._move_info.index] = world_pos + if self._move_info.index is None: + delta = world_pos - self._move_info.start_pos + data[:] = self._move_info.start_positions + delta + else: + data[self._move_info.index] = world_pos self._selection.set_value(self, data) def _on_pointer_up(self, ev): if self._move_info.mode in ("create", "drag"): # Update data to set z to zero again - data = self.selection - data[self._move_info.index][2] = 0 - self._selection.set_value(self, data) + if self._move_info.index is not None: + data = self.selection + data[self._move_info.index][2] = 0 + self._selection.set_value(self, data) # If we snapped, we dissolve (i.e. delete the vertex being moved) if self._move_info.snap_index is not None: + assert self._move_info.index is not None self._delete_polygon_vertex(self._move_info.index) # Moving the mouse up may end the move action From a89e143751b1b80fe120368d3f62ec28c3fe2864 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Thu, 28 Aug 2025 11:48:51 +0200 Subject: [PATCH 18/19] fix snapping --- fastplotlib/graphics/selectors/_polygon.py | 27 +++++++++++----------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/fastplotlib/graphics/selectors/_polygon.py b/fastplotlib/graphics/selectors/_polygon.py index b53ae2c03..1bca2aca9 100644 --- a/fastplotlib/graphics/selectors/_polygon.py +++ b/fastplotlib/graphics/selectors/_polygon.py @@ -438,12 +438,14 @@ def _on_pointer_move(self, ev): snap_index = None if self._move_info.mode == "create" and snap_index != 0: snap_index = None - if ( - self._move_info.mode == "drag" - and index is not None - and snap_index not in (index - 1, index + 1) - ): - snap_index = None + if self._move_info.mode == "drag" and index is not None: + last_index = len(self.selection) - 1 + if not ( + (index == 0 and snap_index == last_index) + or (index == last_index and snap_index == 0) + or (snap_index in (index - 1, index + 1)) + ): + snap_index = None self._move_info.snap_index = snap_index # Show state of snap index to user @@ -453,8 +455,8 @@ def _on_pointer_move(self, ev): else: self._indicator.material.size = 15 - # Move the positions being moved a bit down z, so its not preferred in picking - world_pos = (world_pos[0], world_pos[1], -0.01) + # Move the positions being moved a bit down in depth, so its de-preferred in picking + world_pos = (world_pos[0], world_pos[1], -0.05) self._indicator.local.position = world_pos @@ -471,11 +473,10 @@ def _on_pointer_move(self, ev): def _on_pointer_up(self, ev): if self._move_info.mode in ("create", "drag"): - # Update data to set z to zero again - if self._move_info.index is not None: - data = self.selection - data[self._move_info.index][2] = 0 - self._selection.set_value(self, data) + # Update data to set depth (z) to zero again + data = self.selection + data[:, 2] = 0 + self._selection.set_value(self, data) # If we snapped, we dissolve (i.e. delete the vertex being moved) if self._move_info.snap_index is not None: assert self._move_info.index is not None From 9c7e380f5fe5050e3c926cfa70323afb79033a76 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Thu, 28 Aug 2025 11:51:27 +0200 Subject: [PATCH 19/19] Add note to Bermuda lib --- fastplotlib/utils/triangulation.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/fastplotlib/utils/triangulation.py b/fastplotlib/utils/triangulation.py index d84ab57ea..7abe089de 100644 --- a/fastplotlib/utils/triangulation.py +++ b/fastplotlib/utils/triangulation.py @@ -7,6 +7,10 @@ logger = logging.getLogger("fastplotlib") +# Note: the current triangulation is in pure Python. If the results or performance of the current implementation +# proves inadequate, we can have a look at Bermuda: https://github.com/napari/bermuda + + def triangulate(positions, method="earcut"): """Triangulate the given vertex positions.