diff --git a/napari/_vispy/layers/shapes.py b/napari/_vispy/layers/shapes.py index e063da38b4b..84e6a59ed3a 100644 --- a/napari/_vispy/layers/shapes.py +++ b/napari/_vispy/layers/shapes.py @@ -81,10 +81,11 @@ def _on_highlight_change(self): face_color, edge_color, pos, - width, + _, ) = self.layer._compute_vertices_and_box() - width = settings.appearance.highlight_thickness + # use last dimension of scale like (thickness cannot be anisotropic) + width = settings.appearance.highlight_thickness / self.layer.scale[-1] if vertices is None or len(vertices) == 0: vertices = np.zeros((1, self.layer._slice_input.ndisplay)) diff --git a/napari/layers/shapes/_shapes_mouse_bindings.py b/napari/layers/shapes/_shapes_mouse_bindings.py index 07582367459..4bf458f7fd2 100644 --- a/napari/layers/shapes/_shapes_mouse_bindings.py +++ b/napari/layers/shapes/_shapes_mouse_bindings.py @@ -163,7 +163,7 @@ def add_line(layer: Shapes, event: MouseEvent) -> None: A proxy read only wrapper around a vispy mouse event. """ # full size is the initial offset of the second point compared to the first point of the line. - size = layer._vertex_size * layer.scale_factor / 4 + size = layer._normalized_vertex_radius / 2 full_size = np.zeros(layer.ndim, dtype=float) for i in layer._slice_input.displayed: full_size[i] = size @@ -192,7 +192,7 @@ def add_ellipse(layer: Shapes, event: MouseEvent): event: MouseEvent A proxy read only wrapper around a vispy mouse event. """ - size = layer._vertex_size * layer.scale_factor / 4 + size = layer._normalized_vertex_radius / 2 size_h = np.zeros(layer.ndim, dtype=float) size_h[layer._slice_input.displayed[0]] = size size_v = np.zeros(layer.ndim, dtype=float) @@ -218,7 +218,7 @@ def add_rectangle(layer: Shapes, event: MouseEvent) -> None: event: MouseEvent A proxy read only wrapper around a vispy mouse event. """ - size = layer._vertex_size * layer.scale_factor / 4 + size = layer._normalized_vertex_radius / 2 size_h = np.zeros(layer.ndim, dtype=float) size_h[layer._slice_input.displayed[0]] = size size_v = np.zeros(layer.ndim, dtype=float) @@ -736,10 +736,10 @@ def _move_active_element_under_cursor( # prevent box from shrinking below a threshold size size = (np.linalg.norm(box[Box.TOP_LEFT] - box_center),) - threshold = ( - layer._vertex_size * layer.scale_factor / layer.scale[-1] / 2 - ) - if np.linalg.norm(size * drag_scale) < threshold: + if ( + np.linalg.norm(size * drag_scale) + < layer._normalized_vertex_radius + ): drag_scale[:] = 1 # on vertical/horizontal drags we get scale of 0 # when we actually simply don't want to scale diff --git a/napari/layers/shapes/_tests/test_shapes_mouse_bindings.py b/napari/layers/shapes/_tests/test_shapes_mouse_bindings.py index c38e6b12d03..53107795496 100644 --- a/napari/layers/shapes/_tests/test_shapes_mouse_bindings.py +++ b/napari/layers/shapes/_tests/test_shapes_mouse_bindings.py @@ -52,6 +52,8 @@ def create_known_shapes_layer(): n_shapes = len(data) layer = Shapes(data) + # very zoomed in, guaranteed no overlap between vertices + layer.scale_factor = 0.001 assert layer.ndim == 2 assert len(layer.data) == n_shapes assert len(layer.selected_data) == 0 @@ -408,26 +410,14 @@ def test_vertex_remove(create_known_shapes_layer, Event): ) mouse_press_callbacks(layer, event) - # Simulate drag end - event = ReadOnlyWrapper( - Event( - type='mouse_move', - is_dragging=True, - modifiers=[], - position=position, - pos=position, - ) - ) - mouse_move_callbacks(layer, event) assert layer.events.data.call_args[1] == { "value": layer.data, "action": ActionType.CHANGE.value, "data_indices": tuple( select, ), - "vertex_indices": ((3,),), + "vertex_indices": ((0,),), } - # Check new shape added at coordinates assert len(layer.data) == n_shapes assert len(layer.data[0]) == n_coord - 1 @@ -644,7 +634,7 @@ def test_drag_vertex(create_known_shapes_layer, Event): layer.events.data = Mock() layer.mode = 'direct' layer.selected_data = {0} - position = tuple(layer.data[0][0]) + old_position = tuple(layer.data[0][0]) # Simulate click event = ReadOnlyWrapper( @@ -652,21 +642,23 @@ def test_drag_vertex(create_known_shapes_layer, Event): type='mouse_press', is_dragging=False, modifiers=[], - position=position, - pos=position, + position=old_position, + pos=old_position, ) ) mouse_press_callbacks(layer, event) - position = [0, 0] + new_position = [0, 0] + assert np.all(new_position != old_position) + # Simulate move, click, and release event = ReadOnlyWrapper( Event( type='mouse_move', is_dragging=True, modifiers=[], - position=position, - pos=position, + position=new_position, + pos=new_position, ) ) mouse_move_callbacks(layer, event) @@ -677,8 +669,8 @@ def test_drag_vertex(create_known_shapes_layer, Event): type='mouse_release', is_dragging=True, modifiers=[], - position=position, - pos=position, + position=new_position, + pos=new_position, ) ) mouse_release_callbacks(layer, event) @@ -693,7 +685,7 @@ def test_drag_vertex(create_known_shapes_layer, Event): "data_indices": (0,), "vertex_indices": vertex_indices, } - np.testing.assert_allclose(layer.data[0][-1], [0, 0]) + np.testing.assert_allclose(layer.data[0][0], [0, 0]) @pytest.mark.parametrize( diff --git a/napari/layers/shapes/shapes.py b/napari/layers/shapes/shapes.py index db256407328..cf47762d23f 100644 --- a/napari/layers/shapes/shapes.py +++ b/napari/layers/shapes/shapes.py @@ -2269,6 +2269,20 @@ def refresh_text(self): """ self.text.refresh(self.features) + @property + def _normalized_scale_factor(self): + """Scale factor accounting for layer scale. + + This is often needed when calculating screen-space sizes and distances + of vertices for interactivity (rescaling, adding vertices, etc). + """ + return self.scale_factor / self.scale[-1] + + @property + def _normalized_vertex_radius(self): + """Vertex radius normalized to screen space.""" + return self._vertex_size * self._normalized_scale_factor / 2 + def _set_view_slice(self): """Set the view given the slicing indices.""" ndisplay = self._slice_input.ndisplay @@ -2330,7 +2344,10 @@ def interaction_box(self, index): box[Box.BOTTOM_LEFT] - box[Box.TOP_LEFT] ) if length_box > 0: - r = self._rotation_handle_length * self.scale_factor + r = ( + self._rotation_handle_length + * self._normalized_scale_factor + ) rot = ( rot - r @@ -2368,7 +2385,7 @@ def _outline_shapes(self): centers, offsets, triangles = self._data_view.outline(index) vertices = centers + ( - self.scale_factor * self._highlight_width * offsets + self._normalized_scale_factor * self._highlight_width * offsets ) vertices = vertices[:, ::-1] else: @@ -2635,7 +2652,7 @@ def _scale_box(self, scale, center=(0, 0)): box = self._selected_box - center box = np.array(box * scale) if not np.all(box[Box.TOP_CENTER] == box[Box.HANDLE]): - r = self._rotation_handle_length * self.scale_factor + r = self._rotation_handle_length * self._normalized_scale_factor handle_vec = box[Box.HANDLE] - box[Box.TOP_CENTER] cur_len = np.linalg.norm(handle_vec) box[Box.HANDLE] = box[Box.TOP_CENTER] + r * handle_vec / cur_len @@ -2654,12 +2671,20 @@ def _transform_box(self, transform, center=(0, 0)): box = self._selected_box - center box = box @ transform.T if not np.all(box[Box.TOP_CENTER] == box[Box.HANDLE]): - r = self._rotation_handle_length * self.scale_factor + r = self._rotation_handle_length * self._normalized_scale_factor handle_vec = box[Box.HANDLE] - box[Box.TOP_CENTER] cur_len = np.linalg.norm(handle_vec) box[Box.HANDLE] = box[Box.TOP_CENTER] + r * handle_vec / cur_len self._selected_box = box + center + def _update_draw( + self, scale_factor, corner_pixels_displayed, shape_threshold + ): + super()._update_draw( + scale_factor, corner_pixels_displayed, shape_threshold + ) + self._set_highlight(force=True) + def _get_value(self, position): """Value of the data at a position in data coordinates. @@ -2688,17 +2713,23 @@ def _get_value(self, position): # Check selected shapes value = None selected_index = list(self.selected_data) + if len(selected_index) > 0: + self.scale[self._slice_input.displayed] + # Get the vertex sizes. They need to be rescaled by a few parameters: + # - scale_factor, because vertex sizes are zoom-invariant + # - scale, because vertex sizes are not affected by scale (unlike in Points) + # - 2, because the radius is what we need + if self._mode == Mode.SELECT: # Check if inside vertex of interaction box or rotation handle box = self._selected_box[Box.WITH_HANDLE] distances = abs(box - coord) - # Get the vertex sizes - sizes = self._vertex_size * self.scale_factor / 2 - # Check if any matching vertices - matches = np.all(distances <= sizes, axis=1).nonzero() + matches = np.all( + distances <= self._normalized_vertex_radius, axis=1 + ).nonzero() if len(matches[0]) > 0: value = (selected_index[0], matches[0][-1]) elif self._mode in ( @@ -2709,11 +2740,10 @@ def _get_value(self, position): vertices = self._data_view.displayed_vertices[inds] distances = abs(vertices - coord) - # Get the vertex sizes - sizes = self._vertex_size * self.scale_factor / 2 - # Check if any matching vertices - matches = np.all(distances <= sizes, axis=1).nonzero()[0] + matches = np.all( + distances <= self._normalized_vertex_radius, axis=1 + ).nonzero()[0] if len(matches) > 0: index = inds.nonzero()[0][matches[-1]] shape = self._data_view.displayed_index[index]