From 46ccae36c620fa0b84d68f5953544f13801f6cfc Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Mon, 26 May 2025 22:58:40 +0200 Subject: [PATCH 1/8] 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 2/8] 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 3/8] 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 4/8] 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 5/8] 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 6/8] 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 7/8] 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 8/8] 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() pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy