Description
Hi I was interesed in making a circle selector. I've been playing around with it a little bit but was interested if you had guidance on a couple of points. Primarily when looking at the Rectangle Selector you are using a 3D object rather than a 2D one? Is there a good reason for this?
I think the code that I have (mostly) works although I need to work on the resize and the selection code. Would this be of interest? I can also abstract this to an ellipse if we just make our own custom geometry.
One other question is how is the _move_graphic
function called? It would be good to include not only the deltas but the position of the click. Currently, it is hard to know if the user is dragging the circle out/in without the event position. You can kind of see the effect of this in the video where the resize is reversed depending on which part of the circle you click on.
Screen.Recording.2025-03-03.at.8.47.10.AM.mov
class CircleSelectionFeature(GraphicFeature):
"""
**additional event attributes:**
+----------------------+----------+------------------------------------+
| attribute | type | description |
+======================+==========+====================================+
| get_selected_indices | callable | returns indices under the selector |
+----------------------+----------+------------------------------------+
| get_selected_data | callable | returns data under the selector |
+----------------------+----------+------------------------------------+
**info dict:**
+-------------0--+------------+-------------------------------------------+
| dict key | value type | value description |
+===============+============+===========================================+
| value | np.ndarray | new [centerx, centery, radius, inner_radius] of selection |
+-------------+------------+-------------------------------------------+
| limits | np.ndarray | new [xmin, xmax, ymin, ymax] for the ind |
+------------+------------+-------------------------------------------+
| size_limits| np.ndarray | new [xmin, xmax, ymin, ymax] for the ind |
+------------+------------+-------------------------------------------+
"""
def __init__(
self,
value: tuple[float, float, float, float],
limits: tuple[float, float, float, float],
size_limits: tuple[float, float, float, float]=None,
):
super().__init__()
self._limits = limits
self._value = tuple(int(v) for v in value)
self._size_limits = size_limits
@property
def value(self) -> np.ndarray[float]:
"""
(centerx, centery, radius, inner_radius) of the selection, in data space
"""
return self._value
def set_value(self, selector, value: Sequence[float]):
"""
Set the selection of the rectangle selector.
Parameters
----------
selector: RectangleSelector
value: (float, float, float, float)
new values (centerx, centery, radius, inner_radius) of the selection
"""
if not len(value) == 4:
raise TypeError(
"Selection must be an array, tuple, list, or sequence in the form of `(xmin, xmax, ymin, ymax)`, "
"where `xmin`, `xmax`, `ymin`, `ymax` are numeric values."
)
# convert to array
value = np.asarray(value, dtype=np.float32)
# clip the center values if they are outside of the selection.
# the radius should be allowed to move 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])
x, y, radius, inner_radius = value
# make sure that the radius is greater than 0 and the inner radius is less than the radius
if radius <= 0 or inner_radius> radius:
return
radial_segments = 360
cap = generate_cap(radius=radius, height=1, radial_segments=radial_segments, theta_start=0,
theta_length=np.pi * 2)
cap_outline = generate_cap(radius=radius, height=1.01, radial_segments=radial_segments, theta_start=0,
theta_length=np.pi * 2)
positions, normals, texcoords, indices = cap
selector.fill.geometry.positions.data[:] = positions + np.array([x, y, 0])
positions, normals, texcoords, indices = cap_outline
# shift the circle to the correct position
selector.edges[0].geometry.positions.data[:] = positions[1:] + np.array([x, y, 0])
# change the edge lines
self._value = value
# send changes to GPU
selector.fill.geometry.positions.update_range()
for edge in selector.edges:
edge.geometry.positions.update_range()
# send event
if len(self._event_handlers) < 1:
return
event = FeatureEvent("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)
class CircleSelector(BaseSelector):
def __init__(self,
center: Sequence[float],
radius: float,
limits: Sequence[float],
inner_radius: float = 0,
parent: Graphic = None,
resizable: bool = True,
fill_color=(0, 0, 0.35),
edge_color=(0.8, 0.6, 0),
vertex_color=(0.7, 0.4, 0),
edge_thickness: float = 4,
vertex_thickness: float = 8,
arrow_keys_modifier: str = "Shift",
size_limits: Sequence[float] = None,
name: str = None,):
if not len(center) == 2:
raise ValueError()
# lots of very close to zero values etc. so round them
center = tuple(map(round, center))
radius = round(radius)
inner_radius = round(inner_radius)
self._parent = parent
self._center = np.asarray(center)
self._resizable = resizable
self._limits = np.asarray(limits)
self.size_limits = size_limits
center = np.asarray(center)
# world object for this will be a group
# basic mesh for the fill area of the selector
# line for each edge of the selector
group = pygfx.Group()
self._fill_color = pygfx.Color(fill_color)
self._edge_color = pygfx.Color(edge_color)
self._vertex_color = pygfx.Color(vertex_color)
if radius < 0:
raise ValueError()
radial_segments = 360
cap = generate_cap(radius=radius, height=1, radial_segments=radial_segments, theta_start=0,
theta_length=np.pi * 2)
cap_outline = generate_cap(radius=radius, height=1.01, radial_segments=radial_segments, theta_start=0,
theta_length=np.pi * 2)
positions, normals, texcoords, indices = cap
circle_geo = pygfx.Geometry(
indices=indices.reshape((-1, 3)),
positions=positions,
)
self.fill = pygfx.Mesh(circle_geo,
pygfx.MeshBasicMaterial(
color=pygfx.Color(self.fill_color), pick_write=True
),
)
self.fill.world.position = (0, 0, -2)
group.add(self.fill)
positions, normals, texcoords, indices = cap_outline
positions_outline = positions[1:]
circle_outline_geo = pygfx.Geometry(
indices=indices.reshape((-1, 3))[1:],
positions=positions_outline,
)
outline = pygfx.Line(
geometry= circle_outline_geo,
material= pygfx.LineMaterial(thickness=edge_thickness, color=self.edge_color)
)
self.edges = (outline,)
outline.world.z = -0.5
group.add(outline)
selection = np.asarray(tuple(center) + (radius, inner_radius))
self._selection = CircleSelectionFeature(selection, limits=self._limits,
size_limits=size_limits)
# include parent offset
if parent is not None:
offset = (parent.offset[0], parent.offset[1], 0)
else:
offset = (0, 0, 0)
BaseSelector.__init__(
self,
edges=self.edges,
fill=(self.fill,),
hover_responsive=(*self.edges,),
arrow_keys_modifier=arrow_keys_modifier,
parent=parent,
name=name,
offset=offset,
)
self._set_world_object(group)
self.selection = selection
def _move_graphic(self, delta: np.ndarray):
# new selection positions
centerx_new = self.selection[0] + delta[0]
centery_new = self.selection[1] + delta[1]
# move entire selector if source is fill
if self._move_info.source == self.fill:
# set thew new bounds
self._selection.set_value(self, (centerx_new, centery_new, self.selection[2], self.selection[3]))
self.selection = (centerx_new, centery_new, self.selection[2], self.selection[3])
return
# if selector not resizable return
if not self._resizable:
return
elif self._move_info.source == self.edges[0]:
if delta[0] < 0 or delta[1] < 0:
radius_change = -np.sqrt(delta[0]**2 + delta[1]**2)
else:
radius_change = np.sqrt(delta[0]**2 + delta[1]**2)
new_radius = self.selection[2] + radius_change
if new_radius < 0:
return
if self.size_limits is not None:
if new_radius < self.size_limits[0] or new_radius > self.size_limits[1]:
return
values = (self.selection[0], self.selection[1], self.selection[2]+radius_change, self.selection[3])
self._selection.set_value(self, values)
self.selection = values
return