From f0fcdf3e8911b7611d456599eef2a5636ea427ce Mon Sep 17 00:00:00 2001 From: clewis7 Date: Fri, 19 Sep 2025 12:29:59 -0400 Subject: [PATCH 1/2] start ball marker selector --- .../graphics/features/_selection_features.py | 52 +++++++ fastplotlib/graphics/line.py | 30 ++++ fastplotlib/graphics/selectors/__init__.py | 8 +- fastplotlib/graphics/selectors/_ball.py | 138 ++++++++++++++++++ 4 files changed, 227 insertions(+), 1 deletion(-) create mode 100644 fastplotlib/graphics/selectors/_ball.py diff --git a/fastplotlib/graphics/features/_selection_features.py b/fastplotlib/graphics/features/_selection_features.py index 3052ae3d0..2a7895943 100644 --- a/fastplotlib/graphics/features/_selection_features.py +++ b/fastplotlib/graphics/features/_selection_features.py @@ -8,6 +8,58 @@ from ...utils.triangulation import triangulate +class PointSelectionFeature(GraphicFeature): + event_info_spec = [ + { + "dict key": "value", + "type": "np.ndarray", + "description": "new (x, y, z) value of selection", + }, + ] + + event_extra_attrs = [ + { + "attribute": "get_selected_index", + "type": "callable", + "description": "returns index under the selector", + } + ] + + def __init__(self, value: np.ndarray): + """ + Parameters + ---------- + value : np.ndarray + position of the selector in world space, NOT data space + """ + + super().__init__() + + self._value = value + + @property + def value(self) -> np.ndarray: + """ + selection, data (x, y, z) + """ + return self._value + + @block_reentrance + def set_value(self, selector, value: np.ndarray): + if value.shape != (1, 3): + raise ValueError("Shape of new value must be of a single point: (1, 3)") + for vertex in selector._vertices: + vertex.geometry.positions.data[:] = value + vertex.geometry.positions.update_range() + + self._value = value + + event = GraphicFeatureEvent("selection", {"value": value}) + event.get_selected_index = selector.get_selected_index + + self._call_event_handlers(event) + + class LinearSelectionFeature(GraphicFeature): event_info_spec = [ { diff --git a/fastplotlib/graphics/line.py b/fastplotlib/graphics/line.py index 7e6ecee93..b3945ecdd 100644 --- a/fastplotlib/graphics/line.py +++ b/fastplotlib/graphics/line.py @@ -6,10 +6,12 @@ from ._positions_base import PositionsGraphic from .selectors import ( + BallSelector, LinearRegionSelector, LinearSelector, RectangleSelector, PolygonSelector, + __all__, ) from .features import ( Thickness, @@ -137,6 +139,34 @@ def thickness(self) -> float: def thickness(self, value: float): self._thickness.set_value(self, value) + def add_ball_selector(self, selection: float = None, **kwargs) -> BallSelector: + """ + Adds a :class: `.BallSelector`. + + Parameters + ---------- + selection: float, optional + selected point on the linear selector, by default the first datapoint on the line. + + kwargs + passed to :class:`.BallSelector` + + Returns + ------- + BallSelector + """ + if selection is None: + selection = self.data.value[0].reshape(1, 3) + + selector = BallSelector(selection=selection, parent=self, **kwargs) + + self._plot_area.add_graphic(selector, center=False) + + # place selector above this graphic + selector.offset = selector.offset + (0.0, 0.0, self.offset[-1] + 1) + + return selector + def add_linear_selector( self, selection: float = None, axis: str = "x", **kwargs ) -> LinearSelector: diff --git a/fastplotlib/graphics/selectors/__init__.py b/fastplotlib/graphics/selectors/__init__.py index 9133192e9..85403df14 100644 --- a/fastplotlib/graphics/selectors/__init__.py +++ b/fastplotlib/graphics/selectors/__init__.py @@ -2,6 +2,12 @@ from ._linear_region import LinearRegionSelector from ._polygon import PolygonSelector from ._rectangle import RectangleSelector +from ._ball import BallSelector -__all__ = ["LinearSelector", "LinearRegionSelector", "RectangleSelector"] +__all__ = [ + "LinearSelector", + "LinearRegionSelector", + "RectangleSelector", + "BallSelector", +] diff --git a/fastplotlib/graphics/selectors/_ball.py b/fastplotlib/graphics/selectors/_ball.py new file mode 100644 index 000000000..0c2459070 --- /dev/null +++ b/fastplotlib/graphics/selectors/_ball.py @@ -0,0 +1,138 @@ +from typing import Sequence + +import numpy as np +import pygfx + +from .._base import Graphic +from .._collection_base import GraphicCollection +from ..features._selection_features import PointSelectionFeature +from ._base_selector import BaseSelector, MoveInfo +from ..features import UniformSize + + +class BallSelector(BaseSelector): + _features = {"selection": PointSelectionFeature} + + @property + def parent(self) -> Graphic: + return self._parent + + @property + def selection(self) -> np.ndarray: + """Value of selector's current position (x, y, z)""" + return self._selection.value + + @selection.setter + def selection(self, value: np.ndarray): + self._selection.set_value(self, value) + + @property + def color(self) -> pygfx.Color: + """Returns the color of the ball selector.""" + return self._color + + @color.setter + def color(self, color: str | Sequence[float]): + """ + Set the color of the ball selector. + + Parameters + ---------- + color : str | Sequence[float] + String or sequence of floats that gets converted into a ``pygfx.Color`` object. + """ + color = pygfx.Color(color) + self.world_object.material.color = color + self._original_colors[self._vertices[0]] = color + self._color = color + + @property + def size(self) -> float: + """Returns the size of the ball selector.""" + if isinstance(self._size, UniformSize): + return self._size.value + + @size.setter + def size(self, value: float): + """ + Set the size of the ball selector. + + Parameters + ---------- + value : float + Size of the ball selector + """ + if isinstance(self._size, UniformSize): + self._size.set_value(self, value) + + def __init__( + self, + selection: np.ndarray, + parent: Graphic = None, + color: str | Sequence[float] | np.ndarray = "w", + size: float = 10, + arrow_keys_modifier: str = "Shift", + name: str = None, + ): + """ + Create a ball marker that can be used to select a value along a line + + Parameters + ---------- + selection : np.ndarray + """ + self._color = pygfx.Color(color) + + geo_kwargs = {"positions": selection} + + material_kwargs = {"pick_write": True} + material_kwargs["color_mode"] = "uniform" + material_kwargs["color"] = self._color + + material_kwargs["size_mode"] = "uniform" + self._size = UniformSize(size) + material_kwargs["size"] = self.size + + world_object = pygfx.Points( + pygfx.Geometry(**geo_kwargs), + material=pygfx.PointsMaterial(**material_kwargs), + ) + + # init base selector + BaseSelector.__init__( + self, + vertices=(world_object,), + hover_responsive=(world_object,), + arrow_keys_modifier=arrow_keys_modifier, + parent=parent, + name=name, + ) + + self._set_world_object(world_object) + + self._selection = PointSelectionFeature(value=selection) + + if self._parent is not None: + self.selection = selection + else: + self._selection.set_value(self, selection) + + def get_selected_index(self, graphic: Graphic = None) -> int | list[int]: + return 0 + + def _move_graphic(self, move_info: MoveInfo): + """ + Moves the graphic + + Parameters + ---------- + delta: np.ndarray + delta in world space + + """ + # If this the first move in this drag, store initial selection + if move_info.start_selection is None: + move_info.start_selection = self.selection + + delta = move_info.delta + self.selection = move_info.start_selection + delta From 858bdb7d511cd3409c71aaf1084bb29547fd7c5f Mon Sep 17 00:00:00 2001 From: clewis7 Date: Fri, 19 Sep 2025 14:08:12 -0400 Subject: [PATCH 2/2] 2d case working --- .../graphics/features/_selection_features.py | 9 +--- fastplotlib/graphics/selectors/_ball.py | 46 +++++++++++++++++-- 2 files changed, 43 insertions(+), 12 deletions(-) diff --git a/fastplotlib/graphics/features/_selection_features.py b/fastplotlib/graphics/features/_selection_features.py index 2a7895943..4c07d76d0 100644 --- a/fastplotlib/graphics/features/_selection_features.py +++ b/fastplotlib/graphics/features/_selection_features.py @@ -17,14 +17,6 @@ class PointSelectionFeature(GraphicFeature): }, ] - event_extra_attrs = [ - { - "attribute": "get_selected_index", - "type": "callable", - "description": "returns index under the selector", - } - ] - def __init__(self, value: np.ndarray): """ Parameters @@ -48,6 +40,7 @@ def value(self) -> np.ndarray: def set_value(self, selector, value: np.ndarray): if value.shape != (1, 3): raise ValueError("Shape of new value must be of a single point: (1, 3)") + for vertex in selector._vertices: vertex.geometry.positions.data[:] = value vertex.geometry.positions.update_range() diff --git a/fastplotlib/graphics/selectors/_ball.py b/fastplotlib/graphics/selectors/_ball.py index 0c2459070..f2caf8e2a 100644 --- a/fastplotlib/graphics/selectors/_ball.py +++ b/fastplotlib/graphics/selectors/_ball.py @@ -1,10 +1,10 @@ from typing import Sequence +import math import numpy as np import pygfx from .._base import Graphic -from .._collection_base import GraphicCollection from ..features._selection_features import PointSelectionFeature from ._base_selector import BaseSelector, MoveInfo from ..features import UniformSize @@ -24,6 +24,21 @@ def selection(self) -> np.ndarray: @selection.setter def selection(self, value: np.ndarray): + """ + Set the (x, y, z) position of the selector. If bound to a parent graphic, will set + selection to the nearest point of the parent. + + Parameters + ---------- + value : np.ndarray + New (x, y, z) position of the selector + """ + if value.shape != (1, 3): + raise ValueError("Selection must be a single (x, y, z) point") + # if selector is bound to a parent graphic, find the nearest data point + if self.parent is not None: + closest_ix = self._get_nearest_index(self.parent, value) + value = self.parent.data[closest_ix].reshape(1, 3) self._selection.set_value(self, value) @property @@ -79,7 +94,21 @@ def __init__( Parameters ---------- - selection : np.ndarray + selection: np.ndarray + (x, y, z) position of the selector, in data space + parent: Graphic + parent graphic for the BallSelector + color: str | tuple | np.ndarray, default "w" + color of the selector + size: float + size of the selector + arrow_keys_modifier: str + modifier key that must be pressed to initiate movement using arrow keys, must be one of: + "Control", "Shift", "Alt" or ``None``. Double-click the selector first to enable the + arrow key movements, or set the attribute ``arrow_key_events_enabled = True`` + name: str, optional + name of linear selector + """ self._color = pygfx.Color(color) @@ -117,8 +146,16 @@ def __init__( else: self._selection.set_value(self, selection) - def get_selected_index(self, graphic: Graphic = None) -> int | list[int]: - return 0 + def _get_nearest_index(self, graphic, find_value): + data = graphic.data.value[:] + + # get closest data index to the world space position of the selector + distances = np.sum((data - find_value) ** 2, axis=1) + + # Index of closest point + idx = np.argmin(distances) + + return idx def _move_graphic(self, move_info: MoveInfo): """ @@ -135,4 +172,5 @@ def _move_graphic(self, move_info: MoveInfo): move_info.start_selection = self.selection delta = move_info.delta + self.selection = move_info.start_selection + delta