diff --git a/fastplotlib/graphics/features/_selection_features.py b/fastplotlib/graphics/features/_selection_features.py index 3052ae3d0..4c07d76d0 100644 --- a/fastplotlib/graphics/features/_selection_features.py +++ b/fastplotlib/graphics/features/_selection_features.py @@ -8,6 +8,51 @@ 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", + }, + ] + + 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..f2caf8e2a --- /dev/null +++ b/fastplotlib/graphics/selectors/_ball.py @@ -0,0 +1,176 @@ +from typing import Sequence +import math + +import numpy as np +import pygfx + +from .._base import Graphic +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): + """ + 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 + 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 + (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) + + 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_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): + """ + 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