From 8063e15085d171381cbae53462eb971bebf42cd2 Mon Sep 17 00:00:00 2001 From: Derek Homeier Date: Tue, 19 Sep 2023 23:21:06 +0200 Subject: [PATCH 1/9] Add edge midpoint helper methods Co-authored-by: David Stansby --- lib/matplotlib/patches.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/lib/matplotlib/patches.py b/lib/matplotlib/patches.py index 2cc09a5931f6..613d95ba1287 100644 --- a/lib/matplotlib/patches.py +++ b/lib/matplotlib/patches.py @@ -877,6 +877,14 @@ def get_corners(self): return self.get_patch_transform().transform( [(0, 0), (1, 0), (1, 1), (0, 1)]) + def _get_edge_midpoints(self): + """ + Return the edge midpoints of the rectangle, moving anti-clockwise from + the centre of the left-hand edge. + """ + return self.get_patch_transform().transform( + [(0, 0.5), (0.5, 0), (1, 0.5), (0.5, 1)]) + def get_center(self): """Return the centre of the rectangle.""" return self.get_patch_transform().transform((0.5, 0.5)) @@ -1794,6 +1802,14 @@ def get_co_vertices(self): ret = self.get_patch_transform().transform([(0, 1), (0, -1)]) return [tuple(x) for x in ret] + def _get_edge_midpoints(self): + """ + Return the edge midpoints of the ellipse, moving anti-clockwise from + the centre of the left-hand edge. + """ + return self.get_patch_transform().transform( + [(0, 0.5), (0.5, 0), (1, 0.5), (0.5, 1)]) + class Annulus(Patch): """ From 728a7493b1b721a1ce6a574e3a0e6973b350745e Mon Sep 17 00:00:00 2001 From: Derek Homeier Date: Tue, 19 Sep 2023 23:59:01 +0200 Subject: [PATCH 2/9] Use assert_allclose in RectangleSelector tests Co-authored-by: David Stansby --- lib/matplotlib/tests/test_widgets.py | 106 ++++++++++++++------------- 1 file changed, 57 insertions(+), 49 deletions(-) diff --git a/lib/matplotlib/tests/test_widgets.py b/lib/matplotlib/tests/test_widgets.py index 186c287e10f4..d6dcfdba0dfc 100644 --- a/lib/matplotlib/tests/test_widgets.py +++ b/lib/matplotlib/tests/test_widgets.py @@ -12,7 +12,6 @@ mock_event, noop) import numpy as np -from numpy.testing import assert_allclose import pytest @@ -22,6 +21,13 @@ def ax(): return get_ax() +# Set default tolerances for checking coordinates. These are needed due to +# small innacuracies in floating point conversions between data/display +# coordinates +assert_allclose = functools.partial( + np.testing.assert_allclose, atol=1e-12, rtol=1e-7) + + def test_save_blitted_widget_as_pdf(): from matplotlib.widgets import CheckButtons, RadioButtons from matplotlib.cbook import _get_running_interactive_framework @@ -138,7 +144,7 @@ def test_rectangle_drag(ax, drag_from_anywhere, new_center): drag_from_anywhere=drag_from_anywhere) # Create rectangle click_and_drag(tool, start=(0, 10), end=(100, 120)) - assert tool.center == (50, 65) + assert_allclose(tool.center, (50, 65)) # Drag inside rectangle, but away from centre handle # # If drag_from_anywhere == True, this will move the rectangle by (10, 10), @@ -147,11 +153,11 @@ def test_rectangle_drag(ax, drag_from_anywhere, new_center): # If drag_from_anywhere == False, this will create a new rectangle with # center (30, 20) click_and_drag(tool, start=(25, 15), end=(35, 25)) - assert tool.center == new_center + assert_allclose(tool.center, new_center) # Check that in both cases, dragging outside the rectangle draws a new # rectangle click_and_drag(tool, start=(175, 185), end=(185, 195)) - assert tool.center == (180, 190) + assert_allclose(tool.center, (180, 190)) def test_rectangle_selector_set_props_handle_props(ax): @@ -179,35 +185,39 @@ def test_rectangle_resize(ax): tool = widgets.RectangleSelector(ax, interactive=True) # Create rectangle click_and_drag(tool, start=(0, 10), end=(100, 120)) - assert tool.extents == (0.0, 100.0, 10.0, 120.0) + assert_allclose(tool.extents, (0.0, 100.0, 10.0, 120.0)) # resize NE handle extents = tool.extents xdata, ydata = extents[1], extents[3] xdata_new, ydata_new = xdata + 10, ydata + 5 click_and_drag(tool, start=(xdata, ydata), end=(xdata_new, ydata_new)) - assert tool.extents == (extents[0], xdata_new, extents[2], ydata_new) + assert_allclose( + tool.extents, (extents[0], xdata_new, extents[2], ydata_new)) # resize E handle extents = tool.extents xdata, ydata = extents[1], extents[2] + (extents[3] - extents[2]) / 2 xdata_new, ydata_new = xdata + 10, ydata click_and_drag(tool, start=(xdata, ydata), end=(xdata_new, ydata_new)) - assert tool.extents == (extents[0], xdata_new, extents[2], extents[3]) + np.testing.assert_allclose( + tool.extents, (extents[0], xdata_new, extents[2], extents[3])) # resize W handle extents = tool.extents xdata, ydata = extents[0], extents[2] + (extents[3] - extents[2]) / 2 xdata_new, ydata_new = xdata + 15, ydata click_and_drag(tool, start=(xdata, ydata), end=(xdata_new, ydata_new)) - assert tool.extents == (xdata_new, extents[1], extents[2], extents[3]) + assert_allclose( + tool.extents, (xdata_new, extents[1], extents[2], extents[3])) # resize SW handle extents = tool.extents xdata, ydata = extents[0], extents[2] xdata_new, ydata_new = xdata + 20, ydata + 25 click_and_drag(tool, start=(xdata, ydata), end=(xdata_new, ydata_new)) - assert tool.extents == (xdata_new, extents[1], ydata_new, extents[3]) + assert_allclose( + tool.extents, (xdata_new, extents[1], ydata_new, extents[3])) def test_rectangle_add_state(ax): @@ -245,8 +255,8 @@ def test_rectangle_resize_center(ax, add_state): xdata_new, ydata_new = xdata + xdiff, ydata + ydiff click_and_drag(tool, start=(xdata, ydata), end=(xdata_new, ydata_new), key=use_key) - assert tool.extents == (extents[0] - xdiff, xdata_new, - extents[2] - ydiff, ydata_new) + assert_allclose(tool.extents, (extents[0] - xdiff, xdata_new, + extents[2] - ydiff, ydata_new)) # resize E handle extents = tool.extents @@ -255,8 +265,8 @@ def test_rectangle_resize_center(ax, add_state): xdata_new, ydata_new = xdata + xdiff, ydata click_and_drag(tool, start=(xdata, ydata), end=(xdata_new, ydata_new), key=use_key) - assert tool.extents == (extents[0] - xdiff, xdata_new, - extents[2], extents[3]) + assert_allclose(tool.extents, (extents[0] - xdiff, xdata_new, + extents[2], extents[3])) # resize E handle negative diff extents = tool.extents @@ -265,8 +275,8 @@ def test_rectangle_resize_center(ax, add_state): xdata_new, ydata_new = xdata + xdiff, ydata click_and_drag(tool, start=(xdata, ydata), end=(xdata_new, ydata_new), key=use_key) - assert tool.extents == (extents[0] - xdiff, xdata_new, - extents[2], extents[3]) + assert_allclose(tool.extents, (extents[0] - xdiff, xdata_new, + extents[2], extents[3])) # resize W handle extents = tool.extents @@ -275,8 +285,8 @@ def test_rectangle_resize_center(ax, add_state): xdata_new, ydata_new = xdata + xdiff, ydata click_and_drag(tool, start=(xdata, ydata), end=(xdata_new, ydata_new), key=use_key) - assert tool.extents == (xdata_new, extents[1] - xdiff, - extents[2], extents[3]) + assert_allclose(tool.extents, (xdata_new, extents[1] - xdiff, + extents[2], extents[3])) # resize W handle negative diff extents = tool.extents @@ -285,8 +295,8 @@ def test_rectangle_resize_center(ax, add_state): xdata_new, ydata_new = xdata + xdiff, ydata click_and_drag(tool, start=(xdata, ydata), end=(xdata_new, ydata_new), key=use_key) - assert tool.extents == (xdata_new, extents[1] - xdiff, - extents[2], extents[3]) + assert_allclose(tool.extents, (xdata_new, extents[1] - xdiff, + extents[2], extents[3])) # resize SW handle extents = tool.extents @@ -329,8 +339,8 @@ def test_rectangle_resize_square(ax, add_state): xdata_new, ydata_new = xdata + xdiff, ydata click_and_drag(tool, start=(xdata, ydata), end=(xdata_new, ydata_new), key=use_key) - assert tool.extents == (extents[0], xdata_new, - extents[2], extents[3] + xdiff) + assert_allclose(tool.extents, (extents[0], xdata_new, + extents[2], extents[3] + xdiff)) # resize E handle negative diff extents = tool.extents @@ -339,8 +349,8 @@ def test_rectangle_resize_square(ax, add_state): xdata_new, ydata_new = xdata + xdiff, ydata click_and_drag(tool, start=(xdata, ydata), end=(xdata_new, ydata_new), key=use_key) - assert tool.extents == (extents[0], xdata_new, - extents[2], extents[3] + xdiff) + assert_allclose(tool.extents, (extents[0], xdata_new, + extents[2], extents[3] + xdiff)) # resize W handle extents = tool.extents @@ -349,8 +359,8 @@ def test_rectangle_resize_square(ax, add_state): xdata_new, ydata_new = xdata + xdiff, ydata click_and_drag(tool, start=(xdata, ydata), end=(xdata_new, ydata_new), key=use_key) - assert tool.extents == (xdata_new, extents[1], - extents[2], extents[3] - xdiff) + assert_allclose(tool.extents, (xdata_new, extents[1], + extents[2], extents[3] - xdiff)) # resize W handle negative diff extents = tool.extents @@ -359,8 +369,8 @@ def test_rectangle_resize_square(ax, add_state): xdata_new, ydata_new = xdata + xdiff, ydata click_and_drag(tool, start=(xdata, ydata), end=(xdata_new, ydata_new), key=use_key) - assert tool.extents == (xdata_new, extents[1], - extents[2], extents[3] - xdiff) + assert_allclose(tool.extents, (xdata_new, extents[1], + extents[2], extents[3] - xdiff)) # resize SW handle extents = tool.extents @@ -369,8 +379,8 @@ def test_rectangle_resize_square(ax, add_state): xdata_new, ydata_new = xdata + xdiff, ydata + ydiff click_and_drag(tool, start=(xdata, ydata), end=(xdata_new, ydata_new), key=use_key) - assert tool.extents == (extents[0] + ydiff, extents[1], - ydata_new, extents[3]) + assert_allclose(tool.extents, (extents[0] + ydiff, extents[1], + ydata_new, extents[3])) def test_rectangle_resize_square_center(ax): @@ -442,7 +452,7 @@ def test_rectangle_rotate(ax, selector_class): tool = selector_class(ax, interactive=True) # Draw rectangle click_and_drag(tool, start=(100, 100), end=(130, 140)) - assert tool.extents == (100, 130, 100, 140) + assert_allclose(tool.extents, (100, 130, 100, 140)) assert len(tool._state) == 0 # Rotate anticlockwise using top-right corner @@ -475,7 +485,7 @@ def test_rectangle_add_remove_set(ax): tool = widgets.RectangleSelector(ax, interactive=True) # Draw rectangle click_and_drag(tool, start=(100, 100), end=(130, 140)) - assert tool.extents == (100, 130, 100, 140) + assert_allclose(tool.extents, (100, 130, 100, 140)) assert len(tool._state) == 0 for state in ['rotate', 'square', 'center']: tool.add_state(state) @@ -492,7 +502,7 @@ def test_rectangle_resize_square_center_aspect(ax, use_data_coordinates): use_data_coordinates=use_data_coordinates) # Create rectangle click_and_drag(tool, start=(70, 65), end=(120, 115)) - assert tool.extents == (70.0, 120.0, 65.0, 115.0) + assert_allclose(tool.extents, (70.0, 120.0, 65.0, 115.0)) tool.add_state('square') tool.add_state('center') @@ -500,7 +510,7 @@ def test_rectangle_resize_square_center_aspect(ax, use_data_coordinates): # resize E handle extents = tool.extents xdata, ydata, width = extents[1], extents[3], extents[1] - extents[0] - xdiff, ycenter = 10, extents[2] + (extents[3] - extents[2]) / 2 + xdiff, ycenter = 10, extents[2] + (extents[3] - extents[2]) / 2 xdata_new, ydata_new = xdata + xdiff, ydata ychange = width / 2 + xdiff click_and_drag(tool, start=(xdata, ydata), end=(xdata_new, ydata_new)) @@ -525,24 +535,22 @@ def test_ellipse(ax): # drag the rectangle click_and_drag(tool, start=(125, 125), end=(145, 145)) - assert tool.extents == (120, 170, 120, 170) + assert_allclose(tool.extents, (120, 170, 120, 170)) # create from center click_and_drag(tool, start=(100, 100), end=(125, 125), key='control') - assert tool.extents == (75, 125, 75, 125) + assert_allclose(tool.extents, (75, 125, 75, 125)) # create a square click_and_drag(tool, start=(10, 10), end=(35, 30), key='shift') - extents = [int(e) for e in tool.extents] - assert extents == [10, 35, 10, 35] + assert_allclose(tool.extents, (10, 35, 10, 35)) # create a square from center click_and_drag(tool, start=(100, 100), end=(125, 130), key='ctrl+shift') - extents = [int(e) for e in tool.extents] - assert extents == [70, 130, 70, 130] + assert_allclose(tool.extents, (70, 130, 70, 130)) assert tool.geometry.shape == (2, 73) - assert_allclose(tool.geometry[:, 0], [70., 100]) + assert_allclose(tool.geometry[:, 0], (70, 100)) def test_rectangle_handles(ax): @@ -552,22 +560,22 @@ def test_rectangle_handles(ax): tool.extents = (100, 150, 100, 150) assert_allclose(tool.corners, ((100, 150, 150, 100), (100, 100, 150, 150))) - assert tool.extents == (100, 150, 100, 150) + assert_allclose(tool.extents, (100, 150, 100, 150)) assert_allclose(tool.edge_centers, ((100, 125.0, 150, 125.0), (125.0, 100, 125.0, 150))) - assert tool.extents == (100, 150, 100, 150) + assert_allclose(tool.extents, (100, 150, 100, 150)) # grab a corner and move it click_and_drag(tool, start=(100, 100), end=(120, 120)) - assert tool.extents == (120, 150, 120, 150) + assert_allclose(tool.extents, (120, 150, 120, 150)) # grab the center and move it click_and_drag(tool, start=(132, 132), end=(120, 120)) - assert tool.extents == (108, 138, 108, 138) + assert_allclose(tool.extents, (108, 138, 108, 138)) # create a new rectangle click_and_drag(tool, start=(10, 10), end=(100, 100)) - assert tool.extents == (10, 100, 10, 100) + assert_allclose(tool.extents, (10, 100, 10, 100)) # Check that marker_props worked. assert mcolors.same_color( @@ -586,7 +594,7 @@ def test_rectangle_selector_onselect(ax, interactive): click_and_drag(tool, start=(100, 110), end=(150, 120)) onselect.assert_called_once() - assert tool.extents == (100.0, 150.0, 110.0, 120.0) + assert_allclose(tool.extents, (100.0, 150.0, 110.0, 120.0)) onselect.reset_mock() click_and_drag(tool, start=(10, 100), end=(10, 100)) @@ -601,7 +609,7 @@ def test_rectangle_selector_ignore_outside(ax, ignore_event_outside): ignore_event_outside=ignore_event_outside) click_and_drag(tool, start=(100, 110), end=(150, 120)) onselect.assert_called_once() - assert tool.extents == (100.0, 150.0, 110.0, 120.0) + assert_allclose(tool.extents, (100.0, 150.0, 110.0, 120.0)) onselect.reset_mock() # Trigger event outside of span @@ -609,11 +617,11 @@ def test_rectangle_selector_ignore_outside(ax, ignore_event_outside): if ignore_event_outside: # event have been ignored and span haven't changed. onselect.assert_not_called() - assert tool.extents == (100.0, 150.0, 110.0, 120.0) + assert_allclose(tool.extents, (100.0, 150.0, 110.0, 120.0)) else: # A new shape is created onselect.assert_called_once() - assert tool.extents == (150.0, 160.0, 150.0, 160.0) + assert_allclose(tool.extents, (150.0, 160.0, 150.0, 160.0)) @pytest.mark.parametrize('orientation, onmove_callback, kwargs', [ From 23e3bc0a192e62d05b52f8461cbf0d402f3be2a0 Mon Sep 17 00:00:00 2001 From: Derek Homeier Date: Wed, 20 Sep 2023 00:47:31 +0200 Subject: [PATCH 3/9] Use display coordinates for RectangleSelector, update handles on resize Co-authored-by: David Stansby --- lib/matplotlib/patches.py | 28 +-- lib/matplotlib/widgets.py | 504 ++++++++++++++++++++++++-------------- 2 files changed, 327 insertions(+), 205 deletions(-) diff --git a/lib/matplotlib/patches.py b/lib/matplotlib/patches.py index 613d95ba1287..89132ca03901 100644 --- a/lib/matplotlib/patches.py +++ b/lib/matplotlib/patches.py @@ -799,13 +799,6 @@ def __init__(self, xy, width, height, *, self._height = height self.angle = float(angle) self.rotation_point = rotation_point - # Required for RectangleSelector with axes aspect ratio != 1 - # The patch is defined in data coordinates and when changing the - # selector with square modifier and not in data coordinates, we need - # to correct for the aspect ratio difference between the data and - # display coordinate systems. Its value is typically provide by - # Axes._get_aspect_ratio() - self._aspect_ratio_correction = 1.0 self._convert_units() # Validate the inputs. def get_path(self): @@ -833,13 +826,11 @@ def get_patch_transform(self): rotation_point = bbox.x0, bbox.y0 else: rotation_point = self.rotation_point - return transforms.BboxTransformTo(bbox) \ - + transforms.Affine2D() \ - .translate(-rotation_point[0], -rotation_point[1]) \ - .scale(1, self._aspect_ratio_correction) \ - .rotate_deg(self.angle) \ - .scale(1, 1 / self._aspect_ratio_correction) \ - .translate(*rotation_point) + return (transforms.BboxTransformTo(bbox) + + transforms.Affine2D() + .translate(-rotation_point[0], -rotation_point[1]) + .rotate_deg(self.angle) + .translate(*rotation_point)) @property def rotation_point(self): @@ -1658,12 +1649,6 @@ def __init__(self, xy, width, height, *, angle=0, **kwargs): self._width, self._height = width, height self._angle = angle self._path = Path.unit_circle() - # Required for EllipseSelector with axes aspect ratio != 1 - # The patch is defined in data coordinates and when changing the - # selector with square modifier and not in data coordinates, we need - # to correct for the aspect ratio difference between the data and - # display coordinate systems. - self._aspect_ratio_correction = 1.0 # Note: This cannot be calculated until this is added to an Axes self._patch_transform = transforms.IdentityTransform() @@ -1681,9 +1666,8 @@ def _recompute_transform(self): width = self.convert_xunits(self._width) height = self.convert_yunits(self._height) self._patch_transform = transforms.Affine2D() \ - .scale(width * 0.5, height * 0.5 * self._aspect_ratio_correction) \ + .scale(width * 0.5, height * 0.5) \ .rotate_deg(self.angle) \ - .scale(1, 1 / self._aspect_ratio_correction) \ .translate(*center) def get_path(self): diff --git a/lib/matplotlib/widgets.py b/lib/matplotlib/widgets.py index 6b196571814d..7604c0505e20 100644 --- a/lib/matplotlib/widgets.py +++ b/lib/matplotlib/widgets.py @@ -12,6 +12,7 @@ from contextlib import ExitStack import copy import itertools +from dataclasses import dataclass from numbers import Integral, Number from cycler import cycler @@ -1533,6 +1534,14 @@ def disconnect(self, cid): """Remove the observer with connection id *cid*.""" self._observers.disconnect(cid) + def _get_edge_midpoints(self): + """ + Return the corners of the ellipse, moving anti-clockwise from + the center of the left-hand edge before rotation. + """ + return self.get_patch_transform().transform( + [(-1, 0), (0, -1), (1, 0), (0, 1)]) + class RadioButtons(AxesWidget): """ @@ -2898,6 +2907,24 @@ def __init__(self, ax, positions, direction, *, line_props=None, def artists(self): return tuple(self._artists) + @property + def _geometry_state(self): + """ + Return a named tuple containing all geometry state attributes + (position, size, orientation) in display coordinates. + """ + return _RectState( + self._x0, self._y0, self._width, self._height, self._rotation) + + @_geometry_state.setter + def _geometry_state(self, state): + self._x0 = state.x0 + self._y0 = state.y0 + self._width = state.width + self._height = state.height + self._rotation = state.rotation + self._update_selection_artist() + @property def positions(self): """Positions of the handle in data coordinates.""" @@ -3120,6 +3147,23 @@ def onselect(eclick: MouseEvent, erelease: MouseEvent) """ +@dataclass +class _RectState: + x0: float + y0: float + width: float + height: float + rotation: float + + @property + def xy(self): + return (self.x0, self.y0) + + @xy.setter + def xy(self, xy): + self.x0, self.y0 = xy + + @_docstring.Substitution(_RECTANGLESELECTOR_PARAMETERS_DOCSTRING.replace( '__ARTIST_NAME__', 'rectangle')) class RectangleSelector(_SelectorWidget): @@ -3165,13 +3209,20 @@ def __init__(self, ax, onselect=None, *, minspanx=0, self._interactive = interactive self.drag_from_anywhere = drag_from_anywhere self.ignore_event_outside = ignore_event_outside - self._rotation = 0.0 - self._aspect_ratio_correction = 1.0 + + # State that determines the position of the selector + # All of this state is defined in display coordinates + self._x0 = 0 + self._y0 = 0 + self._width = 0 + self._height = 0 + self._rotation = 0 # State to allow the option of an interactive selector that can't be # interactively drawn. This is used in PolygonSelector as an - # interactive bounding box to allow the polygon to be easily resized + # interactive bounding box to allow the polygon to be easily resized. self._allow_creation = True + self._drawing_new = False if props is None: props = dict(facecolor='red', edgecolor='black', @@ -3180,9 +3231,15 @@ def __init__(self, ax, onselect=None, *, minspanx=0, self._visible = props.pop('visible', self._visible) to_draw = self._init_shape(**props) self.ax.add_patch(to_draw) + # ax.add_patch sets the transform to ax.transData. Override to None + # so the selector is defined in display coordinates, which makes + # it much easier to handle rotation and scaling. + to_draw.set_transform(None) + # Because the transform is performed in display coords, we need to + # manually add a resize callback for when the axes are resized. + self.ax.figure.canvas.mpl_connect('resize_event', self._on_resize) self._selection_artist = to_draw - self._set_aspect_ratio_correction() self.minspanx = minspanx self.minspany = minspany @@ -3218,6 +3275,28 @@ def __init__(self, ax, onselect=None, *, minspanx=0, self._extents_on_press = None + def _on_resize(self, event): + # Callback for an Axes resize + self._update_handles() + + @property + def _geometry_state(self): + """ + Return a named tuple containing all geometry state attributes + (position, size, orientation) in display coordinates. + """ + return _RectState( + self._x0, self._y0, self._width, self._height, self._rotation) + + @_geometry_state.setter + def _geometry_state(self, state): + self._x0 = state.x0 + self._y0 = state.y0 + self._width = state.width + self._height = state.height + self._rotation = state.rotation + self._update_selection_artist() + @property def _handles_artists(self): return (*self._center_handle.artists, *self._corner_handles.artists, @@ -3225,7 +3304,7 @@ def _handles_artists(self): def _init_shape(self, **props): return Rectangle((0, 0), 0, 1, visible=False, - rotation_point='center', **props) + rotation_point='xy', **props) def _press(self, event): """Button press event handler.""" @@ -3242,16 +3321,17 @@ def _press(self, event): if (self._active_handle is None and not self.ignore_event_outside and self._allow_creation): - x, y = self._get_data_coords(event) - self._visible = False - self.extents = x, x, y, y - self._visible = True - else: - self.set_visible(True) + # Start drawing a new rectangle + self._x0, self._y0 = self._get_data_coords(event) + self._width = 0 + self._height = 0 + self._rotation = 0 + self._drawing_new = True - self._extents_on_press = self.extents - self._rotation_on_press = self._rotation - self._set_aspect_ratio_correction() + self.set_visible(True) + + self._geom_state_on_press = self._geometry_state + self._center_on_press = self._selection_artist.get_center() return False @@ -3264,31 +3344,28 @@ def _release(self, event): self.ignore_event_outside): return - # update the eventpress and eventrelease with the resulting extents - x0, x1, y0, y1 = self.extents - self._eventpress.xdata = x0 - self._eventpress.ydata = y0 - xy0 = self.ax.transData.transform([x0, y0]) - self._eventpress.x, self._eventpress.y = xy0 - - self._eventrelease.xdata = x1 - self._eventrelease.ydata = y1 - xy1 = self.ax.transData.transform([x1, y1]) - self._eventrelease.x, self._eventrelease.y = xy1 + self._eventrelease.xdata = event.xdata + self._eventrelease.ydata = event.ydata + self._eventrelease.x = event.x + self._eventrelease.y = event.y # calculate dimensions of box or line if self.spancoords == 'data': - spanx = abs(self._eventpress.xdata - self._eventrelease.xdata) - spany = abs(self._eventpress.ydata - self._eventrelease.ydata) + # Can't use self.extents, as these are the tool handle locations + # that will be in old locations if a selector pre-exists + inv_tr = self.ax.transData.inverted() + x1, y1 = (self._x0 + self._width, + self._y0 + self._height) + spanx, spany = (inv_tr.transform((x1, y1)) - + inv_tr.transform((self._x0, self._y0))) elif self.spancoords == 'pixels': - spanx = abs(self._eventpress.x - self._eventrelease.x) - spany = abs(self._eventpress.y - self._eventrelease.y) + spanx = self._width + spany = self._height else: - _api.check_in_list(['data', 'pixels'], - spancoords=self.spancoords) + _api.check_in_list(['data', 'pixels'], spancoords=self.spancoords) # check if drawn distance (if it exists) is not too small in # either x or y-direction - if spanx <= self.minspanx or spany <= self.minspany: + if abs(spanx) <= self.minspanx or abs(spany) <= self.minspany: if self._selection_completed: # Call onselect, only when the selection is already existing self.onselect(self._eventpress, self._eventrelease) @@ -3296,10 +3373,23 @@ def _release(self, event): else: self.onselect(self._eventpress, self._eventrelease) self._selection_completed = True + if self._drawing_new: + # When finished drawing, make sure width, height are positive, + # and put reference corner in lower left. This ensures that the + # orientation of the corners and edges is always anti-clockwise + c = self._selection_artist.get_corners() + cx, cy = c[:, 0], c[:, 1] + new_geom_state = self._geometry_state + new_geom_state.x0 = np.min(cx) + new_geom_state.y0 = np.min(cy) + new_geom_state.width = np.max(cx) - np.min(cx) + new_geom_state.height = np.max(cy) - np.min(cy) + self._geometry_state = new_geom_state self.update() self._active_handle = None self._extents_on_press = None + self._drawing_new = False return False @@ -3314,122 +3404,160 @@ def _onmove(self, event): - Continue the creation of a new shape """ eventpress = self._eventpress - # The calculations are done for rotation at zero: we apply inverse - # transformation to events except when we rotate and move + event.x, event.y = self._clip_to_axes(event.x, event.y) + + # Decide which action to carry out state = self._state rotate = 'rotate' in state and self._active_handle in self._corner_order move = self._active_handle == 'C' resize = self._active_handle and not move + # Create a variable for the new position after this move + new_geom_state = copy.copy(self._geom_state_on_press) - xdata, ydata = self._get_data_coords(event) - if resize: - inv_tr = self._get_rotation_transform().inverted() - xdata, ydata = inv_tr.transform([xdata, ydata]) - eventpress.xdata, eventpress.ydata = inv_tr.transform( - (eventpress.xdata, eventpress.ydata)) - - dx = xdata - eventpress.xdata - dy = ydata - eventpress.ydata - # refmax is used when moving the corner handle with the square state - # and is the maximum between refx and refy - refmax = None - if self._use_data_coordinates: - refx, refy = dx, dy - else: - # Get dx/dy in display coordinates - refx = event.x - eventpress.x - refy = event.y - eventpress.y - - x0, x1, y0, y1 = self._extents_on_press # rotate an existing shape if rotate: # calculate angle abc - a = (eventpress.xdata, eventpress.ydata) - b = self.center - c = (xdata, ydata) + a = [eventpress.x, eventpress.y] + b = self._center_on_press + c = [event.x, event.y] angle = (np.arctan2(c[1]-b[1], c[0]-b[0]) - np.arctan2(a[1]-b[1], a[0]-b[0])) - self.rotation = np.rad2deg(self._rotation_on_press + angle) + new_geom_state.rotation = self._geom_state_on_press.rotation + angle + # Transform the rectangle corner so we are rotating about the + # center of the rectangle + new_geom_state.xy = Affine2D().rotate_around(*b, angle).transform( + self._geom_state_on_press.xy) elif resize: - size_on_press = [x1 - x0, y1 - y0] - center = (x0 + size_on_press[0] / 2, y0 + size_on_press[1] / 2) + # Do resizing in a de-rotated frame + t = Affine2D().rotate(-self._rotation) + press_x, press_y = t.transform((eventpress.x, eventpress.y)) + event.x, event.y = t.transform((event.x, event.y)) + x0, y0 = t.transform((self._geom_state_on_press.x0, + self._geom_state_on_press.y0)) + dx = event.x - press_x + dy = event.y - press_y # Keeping the center fixed if 'center' in state: + size_on_press = [self._geom_state_on_press.width, + self._geom_state_on_press.height] + center = [x0 + size_on_press[0] / 2, + y0 + size_on_press[1] / 2] # hh, hw are half-height and half-width if 'square' in state: # when using a corner, find which reference to use + refmax = None if self._active_handle in self._corner_order: - refmax = max(refx, refy, key=abs) - if self._active_handle in ['E', 'W'] or refmax == refx: - hw = xdata - center[0] - hh = hw / self._aspect_ratio_correction + # When using a corner, use the maximum change in x/y + refmax = max(dx, dy, key=abs) + if self._active_handle in ['E', 'W'] or refmax == dx: + hw = hh = abs(event.x - center[0]) + if self._use_data_coordinates: + hh *= self.ax._get_aspect_ratio() else: - hh = ydata - center[1] - hw = hh * self._aspect_ratio_correction + hw = hh = abs(event.y - center[1]) + if self._use_data_coordinates: + hw /= self.ax._get_aspect_ratio() + else: hw = size_on_press[0] / 2 hh = size_on_press[1] / 2 # cancel changes in perpendicular direction if self._active_handle in ['E', 'W'] + self._corner_order: - hw = abs(xdata - center[0]) + hw = abs(event.x - center[0]) if self._active_handle in ['N', 'S'] + self._corner_order: - hh = abs(ydata - center[1]) + hh = abs(event.y - center[1]) - x0, x1, y0, y1 = (center[0] - hw, center[0] + hw, - center[1] - hh, center[1] + hh) + x0 = center[0] - hw + y0 = center[1] - hh + width = 2 * hw + height = 2 * hh else: - # change sign of relative changes to simplify calculation - # Switch variables so that x1 and/or y1 are updated on move - if 'W' in self._active_handle: - x0 = x1 - if 'S' in self._active_handle: - y0 = y1 - if self._active_handle in ['E', 'W'] + self._corner_order: - x1 = xdata - if self._active_handle in ['N', 'S'] + self._corner_order: - y1 = ydata + # center not in state + width = self._geom_state_on_press.width + height = self._geom_state_on_press.height + + if 'N' in self._active_handle: + height += dy + elif 'S' in self._active_handle: + height -= dy + y0 += dy + + if 'E' in self._active_handle: + width += dx + elif 'W' in self._active_handle: + width -= dx + x0 += dx + if 'square' in state: - # when using a corner, find which reference to use - if self._active_handle in self._corner_order: - refmax = max(refx, refy, key=abs) - if self._active_handle in ['E', 'W'] or refmax == refx: - sign = np.sign(ydata - y0) - y1 = y0 + sign * abs(x1 - x0) / self._aspect_ratio_correction - else: - sign = np.sign(xdata - x0) - x1 = x0 + sign * abs(y1 - y0) * self._aspect_ratio_correction + if self._active_handle in ['E', 'W']: + height = width + elif self._active_handle in ['N', 'S']: + width = height + elif self._active_handle == 'NE': + width = height = max(event.x - x0, event.y - y0) + elif self._active_handle == 'SW': + # Keep x0 + width, y0 + height a fixed point + new_wh = max(x0 + width - event.x, + y0 + height - event.y) + x0 += width - new_wh + y0 += height - new_wh + width = height = new_wh + elif self._active_handle == 'SE': + # Keep x0, y0 + height a fixed point + new_wh = max(event.x - x0, y0 + height - event.y) + y0 += height - new_wh + width = height = new_wh + elif self._active_handle == 'NW': + # Keep x0 + width, y0 a fixed point + new_wh = max(x0 + width - event.x, event.y - y0) + x0 += width - new_wh + width = height = new_wh + + # Transform back into de-rotated display coordiantes + new_geom_state.x0, new_geom_state.y0 = t.inverted().transform( + (x0, y0)) + # Width and height are invariant under the rotation, so no need + # to transform + new_geom_state.width = width + new_geom_state.height = height elif move: - x0, x1, y0, y1 = self._extents_on_press - dx = xdata - eventpress.xdata - dy = ydata - eventpress.ydata - x0 += dx - x1 += dx - y0 += dy - y1 += dy + dx = event.x - eventpress.x + dy = event.y - eventpress.y + new_geom_state.x0 += dx + new_geom_state.y0 += dy else: # Create a new shape - self._rotation = 0 + # Don't create a new rectangle if there is already one when # ignore_event_outside=True if ((self.ignore_event_outside and self._selection_completed) or not self._allow_creation): return - center = [eventpress.xdata, eventpress.ydata] - dx = (xdata - center[0]) / 2 - dy = (ydata - center[1]) / 2 + center = [eventpress.x, eventpress.y] + dx = (event.x - center[0]) / 2 + dy = (event.y - center[1]) / 2 # square shape if 'square' in state: + refx = event.x - eventpress.x + refy = event.y - eventpress.y refmax = max(refx, refy, key=abs) + if refmax == refx: - dy = np.sign(dy) * abs(dx) / self._aspect_ratio_correction + sign = np.sign(dy) or 1 + dy = sign * abs(dx) + if self._use_data_coordinates: + dy *= self.ax._get_aspect_ratio() else: - dx = np.sign(dx) * abs(dy) * self._aspect_ratio_correction + sign = np.sign(dx) or 1 + dx = sign * abs(dy) + if self._use_data_coordinates: + dx /= self.ax._get_aspect_ratio() # from center if 'center' in state: @@ -3441,22 +3569,13 @@ def _onmove(self, event): center[0] += dx center[1] += dy - x0, x1, y0, y1 = (center[0] - dx, center[0] + dx, - center[1] - dy, center[1] + dy) + new_geom_state.x0 = center[0] - dx + new_geom_state.y0 = center[1] - dy + new_geom_state.width = 2 * dx + new_geom_state.height = 2 * dy + new_geom_state.rotation = 0 - self.extents = x0, x1, y0, y1 - - @property - def _rect_bbox(self): - return self._selection_artist.get_bbox().bounds - - def _set_aspect_ratio_correction(self): - aspect_ratio = self.ax._get_aspect_ratio() - self._selection_artist._aspect_ratio_correction = aspect_ratio - if self._use_data_coordinates: - self._aspect_ratio_correction = 1 - else: - self._aspect_ratio_correction = aspect_ratio + self._geometry_state = new_geom_state def _get_rotation_transform(self): aspect_ratio = self.ax._get_aspect_ratio() @@ -3472,12 +3591,10 @@ def corners(self): Corners of rectangle in data coordinates from lower left, moving clockwise. """ - x0, y0, width, height = self._rect_bbox - xc = x0, x0 + width, x0 + width, x0 - yc = y0, y0, y0 + height, y0 + height - transform = self._get_rotation_transform() - coords = transform.transform(np.array([xc, yc]).T).T - return coords[0], coords[1] + c = self._selection_artist.get_corners() + # Convert from display to data coordinates + c = self.ax.transData.inverted().transform(c) + return c[:, 0], c[:, 1] @property def edge_centers(self): @@ -3485,20 +3602,16 @@ def edge_centers(self): Midpoint of rectangle edges in data coordinates from left, moving anti-clockwise. """ - x0, y0, width, height = self._rect_bbox - w = width / 2. - h = height / 2. - xe = x0, x0 + w, x0 + width, x0 + w - ye = y0 + h, y0, y0 + h, y0 + height - transform = self._get_rotation_transform() - coords = transform.transform(np.array([xe, ye]).T).T - return coords[0], coords[1] + c = self._selection_artist._get_edge_midpoints() + c = self.ax.transData.inverted().transform(c) + return c[:, 0], c[:, 1] @property def center(self): """Center of rectangle in data coordinates.""" - x0, y0, width, height = self._rect_bbox - return x0 + width / 2., y0 + height / 2. + c = self._selection_artist.get_center() + # Convert from display to data coordinates + return self.ax.transData.inverted().transform(c) @property def extents(self): @@ -3506,59 +3619,82 @@ def extents(self): Return (xmin, xmax, ymin, ymax) in data coordinates as defined by the bounding box before rotation. """ - x0, y0, width, height = self._rect_bbox - xmin, xmax = sorted([x0, x0 + width]) - ymin, ymax = sorted([y0, y0 + height]) - return xmin, xmax, ymin, ymax + cx, cy = self.corners + return cx[0], cx[2], cy[0], cy[2] @extents.setter def extents(self, extents): + # Convert from data to figure coordinates + corner_min = self.ax.transData.transform((extents[0], extents[2])) + corner_max = self.ax.transData.transform((extents[1], extents[3])) # Update displayed shape - self._draw_shape(extents) - if self._interactive: - # Update displayed handles - self._corner_handles.set_data(*self.corners) - self._edge_handles.set_data(*self.edge_centers) - x, y = self.center - self._center_handle.set_data([x], [y]) - self.set_visible(self._visible) + self._draw_shape((corner_min[0], corner_max[0], + corner_min[1], corner_max[1])) + self.set_visible(self.visible) self.update() @property def rotation(self): """ - Rotation in degree in interval [-45°, 45°]. The rotation is limited in - range to keep the implementation simple. + Rotation in degrees. """ return np.rad2deg(self._rotation) @rotation.setter def rotation(self, value): - # Restrict to a limited range of rotation [-45°, 45°] to avoid changing - # order of handles - if -45 <= value and value <= 45: - self._rotation = np.deg2rad(value) - # call extents setter to draw shape and update handles positions - self.extents = self.extents + self._rotation = np.deg2rad(value) + self._update_selection_artist() - def _draw_shape(self, extents): - x0, x1, y0, y1 = extents - xmin, xmax = sorted([x0, x1]) - ymin, ymax = sorted([y0, y1]) + def _clip_to_axes(self, x, y): + """ + Clip x and y values in diplay coordinates to the limits of the current + Axes. + """ xlim = sorted(self.ax.get_xlim()) ylim = sorted(self.ax.get_ylim()) - xmin = max(xlim[0], xmin) - ymin = max(ylim[0], ymin) - xmax = min(xmax, xlim[1]) - ymax = min(ymax, ylim[1]) + min_lim = (xlim[0], ylim[0]) + max_lim = (xlim[1], ylim[1]) + # Axes limits in display coordinates + min_lim = self.ax.transData.transform(min_lim) + max_lim = self.ax.transData.transform(max_lim) + + x = np.clip(x, min_lim[0], max_lim[0]) + y = np.clip(y, min_lim[1], max_lim[1]) + return x, y + + # TODO: _draw_shape can be removed in 3.7 + # draw_shape = _api.deprecate_privatize_attribute('3.5') + + def _draw_shape(self, extents): + x0, x1, y0, y1 = extents + self._x0 = x0 + self._y0 = y0 + self._width = x1 - x0 + self._height = y1 - y0 + self._update_selection_artist() - self._selection_artist.set_x(xmin) - self._selection_artist.set_y(ymin) - self._selection_artist.set_width(xmax - xmin) - self._selection_artist.set_height(ymax - ymin) + def _update_selection_artist(self): + """ + Update the selection artists from the current position state. + """ + self._selection_artist.set_x(self._x0) + self._selection_artist.set_y(self._y0) + self._selection_artist.set_width(self._width) + self._selection_artist.set_height(self._height) self._selection_artist.set_angle(self.rotation) + if self._interactive: + # Update displayed handles + self._update_handles() + + self.update() + + def _update_handles(self): + self._corner_handles.set_data(*self.corners) + self._edge_handles.set_data(*self.edge_centers) + self._center_handle.set_data(*self.center) + def _set_active_handle(self, event): """Set active handle based on the location of the mouse event.""" # Note: event.xdata/ydata in data coordinates, event.x/y in pixels @@ -3628,25 +3764,27 @@ class EllipseSelector(RectangleSelector): def _init_shape(self, **props): return Ellipse((0, 0), 0, 1, visible=False, **props) - def _draw_shape(self, extents): - x0, x1, y0, y1 = extents - xmin, xmax = sorted([x0, x1]) - ymin, ymax = sorted([y0, y1]) - center = [x0 + (x1 - x0) / 2., y0 + (y1 - y0) / 2.] - a = (xmax - xmin) / 2. - b = (ymax - ymin) / 2. + def _update_selection_artist(self): + """ + Update the selection artists from the current position state. + """ + center = (self._x0 + self._width / 2, + self._y0 + self._height / 2) + center = Affine2D().rotate_around( + self._x0, self._y0, self._rotation).transform(center) self._selection_artist.center = center - self._selection_artist.width = 2 * a - self._selection_artist.height = 2 * b + self._selection_artist.width = self._width + self._selection_artist.height = self._height self._selection_artist.angle = self.rotation - @property - def _rect_bbox(self): - x, y = self._selection_artist.center - width = self._selection_artist.width - height = self._selection_artist.height - return x - width / 2., y - height / 2., width, height + if self._interactive: + # Update displayed handles + self._corner_handles.set_data(*self.corners) + self._edge_handles.set_data(*self.edge_centers) + self._center_handle.set_data(*self.center) + + self.update() class LassoSelector(_SelectorWidget): @@ -3891,13 +4029,13 @@ def _scale_polygon(self, event): return # Create transform from old box to new box - x1, y1, w1, h1 = self._box._rect_bbox + xmin, xmax, ymin, ymax = self._box.extents old_bbox = self._get_bbox() t = (transforms.Affine2D() .translate(-old_bbox.x0, -old_bbox.y0) .scale(1 / old_bbox.width, 1 / old_bbox.height) - .scale(w1, h1) - .translate(x1, y1)) + .scale(xmax - xmin, ymax - ymin) + .translate(xmin, ymin)) # Update polygon verts. Must be a list of tuples for consistency. new_verts = [(x, y) for x, y in t.transform(np.array(self.verts))] From 78d0f0231871c88270a975363902d246ff60d77d Mon Sep 17 00:00:00 2001 From: Derek Homeier Date: Wed, 20 Sep 2023 13:13:48 +0200 Subject: [PATCH 4/9] Rectangle resize tests with multiple start/end corners Co-authored-by: David Stansby --- lib/matplotlib/tests/test_widgets.py | 109 ++++++++++++++++----------- 1 file changed, 65 insertions(+), 44 deletions(-) diff --git a/lib/matplotlib/tests/test_widgets.py b/lib/matplotlib/tests/test_widgets.py index d6dcfdba0dfc..2b4e1ed4344d 100644 --- a/lib/matplotlib/tests/test_widgets.py +++ b/lib/matplotlib/tests/test_widgets.py @@ -82,10 +82,11 @@ def test_rectangle_selector(ax, kwargs): # purposely drag outside of axis for release do_event(tool, 'release', xdata=250, ydata=250, button=1) + do_event(tool, 'onmove', xdata=250, ydata=250, button=1) if kwargs.get('drawtype', None) not in ['line', 'none']: assert_allclose(tool.geometry, - [[100., 100, 199, 199, 100], + [[100, 100, 199, 199, 100], [100, 199, 199, 100, 100]], err_msg=tool.geometry) @@ -93,8 +94,8 @@ def test_rectangle_selector(ax, kwargs): (epress, erelease), kwargs = onselect.call_args assert epress.xdata == 100 assert epress.ydata == 100 - assert erelease.xdata == 199 - assert erelease.ydata == 199 + assert erelease.xdata == 200 + assert erelease.ydata == 200 assert kwargs == {} @@ -181,10 +182,16 @@ def test_rectangle_selector_set_props_handle_props(ax): assert artist.get_alpha() == 0.3 -def test_rectangle_resize(ax): +# Should give same results if rectangle is created from any two +# opposite corners +@pytest.mark.parametrize('start, end', [[(0, 10), (100, 120)], + [(100, 120), (0, 10)], + [(0, 120), (100, 10)], + [(100, 10), (0, 120)]]) +def test_rectangle_resize(ax, start, end): tool = widgets.RectangleSelector(ax, interactive=True) # Create rectangle - click_and_drag(tool, start=(0, 10), end=(100, 120)) + click_and_drag(tool, start=start, end=end) assert_allclose(tool.extents, (0.0, 100.0, 10.0, 120.0)) # resize NE handle @@ -305,8 +312,8 @@ def test_rectangle_resize_center(ax, add_state): xdata_new, ydata_new = xdata + xdiff, ydata + ydiff click_and_drag(tool, start=(xdata, ydata), end=(xdata_new, ydata_new), key=use_key) - assert tool.extents == (xdata_new, extents[1] - xdiff, - ydata_new, extents[3] - ydiff) + assert_allclose(tool.extents, (xdata_new, extents[1] - xdiff, + ydata_new, extents[3] - ydiff)) @pytest.mark.parametrize('add_state', [True, False]) @@ -314,7 +321,7 @@ def test_rectangle_resize_square(ax, add_state): tool = widgets.RectangleSelector(ax, interactive=True) # Create rectangle click_and_drag(tool, start=(70, 65), end=(120, 115)) - assert tool.extents == (70.0, 120.0, 65.0, 115.0) + assert_allclose(tool.extents, (70.0, 120.0, 65.0, 115.0)) if add_state: tool.add_state('square') @@ -329,8 +336,8 @@ def test_rectangle_resize_square(ax, add_state): xdata_new, ydata_new = xdata + xdiff, ydata + ydiff click_and_drag(tool, start=(xdata, ydata), end=(xdata_new, ydata_new), key=use_key) - assert tool.extents == (extents[0], xdata_new, - extents[2], extents[3] + xdiff) + assert_allclose(tool.extents, (extents[0], xdata_new, + extents[2], extents[3] + xdiff)) # resize E handle extents = tool.extents @@ -379,11 +386,12 @@ def test_rectangle_resize_square(ax, add_state): xdata_new, ydata_new = xdata + xdiff, ydata + ydiff click_and_drag(tool, start=(xdata, ydata), end=(xdata_new, ydata_new), key=use_key) - assert_allclose(tool.extents, (extents[0] + ydiff, extents[1], - ydata_new, extents[3])) + assert_allclose(tool.extents, (xdata_new, extents[1], + extents[2] + xdiff, extents[3])) def test_rectangle_resize_square_center(ax): + ax.set_aspect(1) tool = widgets.RectangleSelector(ax, interactive=True) # Create rectangle click_and_drag(tool, start=(70, 65), end=(120, 115)) @@ -449,6 +457,7 @@ def test_rectangle_resize_square_center(ax): @pytest.mark.parametrize('selector_class', [widgets.RectangleSelector, widgets.EllipseSelector]) def test_rectangle_rotate(ax, selector_class): + ax.set_aspect(1) tool = selector_class(ax, interactive=True) # Draw rectangle click_and_drag(tool, start=(100, 100), end=(130, 140)) @@ -462,19 +471,19 @@ def test_rectangle_rotate(ax, selector_class): click_and_drag(tool, start=(130, 140), end=(120, 145)) do_event(tool, 'on_key_press', key='r') assert len(tool._state) == 0 - # Extents shouldn't change (as shape of rectangle hasn't changed) - assert tool.extents == (100, 130, 100, 140) + # Extents change as the selector remains rigid in display coordinates + assert_allclose(tool.extents, (110.10, 119.90, 95.49, 144.51), atol=0.01) assert_allclose(tool.rotation, 25.56, atol=0.01) tool.rotation = 45 assert tool.rotation == 45 # Corners should move assert_allclose(tool.corners, - np.array([[118.53, 139.75, 111.46, 90.25], - [95.25, 116.46, 144.75, 123.54]]), atol=0.01) + np.array([[110.10, 131.31, 103.03, 81.81], + [95.49, 116.70, 144.98, 123.77]]), atol=0.01) # Scale using top-right corner click_and_drag(tool, start=(110, 145), end=(110, 160)) - assert_allclose(tool.extents, (100, 139.75, 100, 151.82), atol=0.01) + assert_allclose(tool.extents, (110, 110, 145, 160), atol=0.01) if selector_class == widgets.RectangleSelector: with pytest.raises(ValueError): @@ -496,36 +505,38 @@ def test_rectangle_add_remove_set(ax): @pytest.mark.parametrize('use_data_coordinates', [False, True]) def test_rectangle_resize_square_center_aspect(ax, use_data_coordinates): - ax.set_aspect(0.8) + ax = get_ax() + ax.set_aspect(0.5) + # Need to call a draw to update ax.transData + plt.gcf().canvas.draw() tool = widgets.RectangleSelector(ax, interactive=True, use_data_coordinates=use_data_coordinates) - # Create rectangle - click_and_drag(tool, start=(70, 65), end=(120, 115)) - assert_allclose(tool.extents, (70.0, 120.0, 65.0, 115.0)) tool.add_state('square') tool.add_state('center') + # Create rectangle, width 50 in data coordinates + click_and_drag(tool, start=(70, 65), end=(120, 75)) if use_data_coordinates: - # resize E handle - extents = tool.extents - xdata, ydata, width = extents[1], extents[3], extents[1] - extents[0] - xdiff, ycenter = 10, extents[2] + (extents[3] - extents[2]) / 2 - xdata_new, ydata_new = xdata + xdiff, ydata - ychange = width / 2 + xdiff - click_and_drag(tool, start=(xdata, ydata), end=(xdata_new, ydata_new)) - assert_allclose(tool.extents, [extents[0] - xdiff, xdata_new, - ycenter - ychange, ycenter + ychange]) + assert_allclose(tool.extents, (20, 120, 15, 115)) else: - # resize E handle - extents = tool.extents - xdata, ydata = extents[1], extents[3] - xdiff = 10 - xdata_new, ydata_new = xdata + xdiff, ydata - ychange = xdiff * 1 / tool._aspect_ratio_correction - click_and_drag(tool, start=(xdata, ydata), end=(xdata_new, ydata_new)) - assert_allclose(tool.extents, [extents[0] - xdiff, xdata_new, - 46.25, 133.75]) + assert_allclose(tool.extents, (20, 120, -35, 165)) + + # resize E handle + extents = tool.extents + xdata, ydata = extents[1], extents[3] + xdiff = 10 + xdata_new, ydata_new = xdata + xdiff, ydata + if use_data_coordinates: + # In data coordinates the difference should be equal in both directions + ydiff = xdiff + else: + # In display coordinates, the change in data coordinates should be + # different in each direction + ydiff = xdiff / tool.ax._get_aspect_ratio() + click_and_drag(tool, start=(xdata, ydata), end=(xdata_new, ydata_new)) + assert_allclose(tool.extents, [extents[0] - xdiff, xdata_new, + extents[2] - ydiff, extents[3] + ydiff]) def test_ellipse(ax): @@ -1631,6 +1642,7 @@ def test_polygon_selector_verts_setter(fig_test, fig_ref, draw_bounding_box): ] for (etype, event_args) in event_sequence: do_event(tool_ref, etype, **event_args) + np.testing.assert_allclose(tool_ref.verts, verts) def test_polygon_selector_box(ax): @@ -1645,10 +1657,19 @@ def test_polygon_selector_box(ax): *polygon_place_vertex(*verts[0]), ] + # Set smaller axes limits to reduce errors in converting from data to + # display coords. The canvas size is 640 x 640, so we need a tolerance of + # (data width / canvas width) = 50 / 640 ~ 0.08 when comparing points in + # data space + ax.set_xlim(-5, 45) + ax.set_ylim(-5, 45) + atol = 0.08 + # Create selector tool = widgets.PolygonSelector(ax, draw_bounding_box=True) for (etype, event_args) in event_sequence: do_event(tool, etype, **event_args) + np.testing.assert_allclose(tool.verts, verts, atol=atol) # In order to trigger the correct callbacks, trigger events on the canvas # instead of the individual tools @@ -1663,7 +1684,7 @@ def test_polygon_selector_box(ax): MouseEvent( "button_release_event", canvas, *t.transform((20, 20)), 1)._process() np.testing.assert_allclose( - tool.verts, [(10, 0), (0, 10), (10, 20), (20, 10)]) + tool.verts, [(10, 0), (0, 10), (10, 20), (20, 10)], atol=atol) # Move using the center of the bounding box MouseEvent( @@ -1673,20 +1694,20 @@ def test_polygon_selector_box(ax): MouseEvent( "button_release_event", canvas, *t.transform((30, 30)), 1)._process() np.testing.assert_allclose( - tool.verts, [(30, 20), (20, 30), (30, 40), (40, 30)]) + tool.verts, [(30, 20), (20, 30), (30, 40), (40, 30)], atol=atol) # Remove a point from the polygon and check that the box extents update np.testing.assert_allclose( - tool._box.extents, (20.0, 40.0, 20.0, 40.0)) + tool._box.extents, (20.0, 40.0, 20.0, 40.0), atol=atol) MouseEvent( "button_press_event", canvas, *t.transform((30, 20)), 3)._process() MouseEvent( "button_release_event", canvas, *t.transform((30, 20)), 3)._process() np.testing.assert_allclose( - tool.verts, [(20, 30), (30, 40), (40, 30)]) + tool.verts, [(20, 30), (30, 40), (40, 30)], atol=atol) np.testing.assert_allclose( - tool._box.extents, (20.0, 40.0, 30.0, 40.0)) + tool._box.extents, (20.0, 40.0, 30.0, 40.0), atol=atol) def test_polygon_selector_clear_method(ax): From 5ba3b5fe8bb048d104932fdbdba497aba4ff963a Mon Sep 17 00:00:00 2001 From: Derek Homeier Date: Wed, 20 Sep 2023 13:15:16 +0200 Subject: [PATCH 5/9] Fix deprecation warnings from `self.visible` and `set_data(x, y)` --- lib/matplotlib/widgets.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/matplotlib/widgets.py b/lib/matplotlib/widgets.py index 7604c0505e20..31d030132106 100644 --- a/lib/matplotlib/widgets.py +++ b/lib/matplotlib/widgets.py @@ -3630,7 +3630,7 @@ def extents(self, extents): # Update displayed shape self._draw_shape((corner_min[0], corner_max[0], corner_min[1], corner_max[1])) - self.set_visible(self.visible) + self.set_visible(self.get_visible()) self.update() @property @@ -3693,7 +3693,7 @@ def _update_selection_artist(self): def _update_handles(self): self._corner_handles.set_data(*self.corners) self._edge_handles.set_data(*self.edge_centers) - self._center_handle.set_data(*self.center) + self._center_handle.set_data(*self.center.reshape(-1, 1)) def _set_active_handle(self, event): """Set active handle based on the location of the mouse event.""" @@ -3782,7 +3782,7 @@ def _update_selection_artist(self): # Update displayed handles self._corner_handles.set_data(*self.corners) self._edge_handles.set_data(*self.edge_centers) - self._center_handle.set_data(*self.center) + self._center_handle.set_data(*self.center.reshape(-1, 1)) self.update() From b7f12abd350d24aa1328f4aebcc5bf13c7a10be6 Mon Sep 17 00:00:00 2001 From: Thomas Robitaille Date: Fri, 19 Jul 2024 10:30:08 +0100 Subject: [PATCH 6/9] Fix case where axis limits are reversed --- lib/matplotlib/widgets.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/lib/matplotlib/widgets.py b/lib/matplotlib/widgets.py index 31d030132106..519b4c3ce087 100644 --- a/lib/matplotlib/widgets.py +++ b/lib/matplotlib/widgets.py @@ -3655,12 +3655,23 @@ def _clip_to_axes(self, x, y): min_lim = (xlim[0], ylim[0]) max_lim = (xlim[1], ylim[1]) + # Axes limits in display coordinates min_lim = self.ax.transData.transform(min_lim) max_lim = self.ax.transData.transform(max_lim) + # For axes where the limits are reversed, need to make sure the + # display min/max are in the correct order. + + if min_lim[0] > max_lim[0]: + min_lim[0], max_lim[0] = max_lim[0], min_lim[0] + + if min_lim[1] > max_lim[1]: + min_lim[1], max_lim[1] = max_lim[1], min_lim[1] + x = np.clip(x, min_lim[0], max_lim[0]) y = np.clip(y, min_lim[1], max_lim[1]) + return x, y # TODO: _draw_shape can be removed in 3.7 From c3b7e4bc307565849bffcde89840a398a09fbd91 Mon Sep 17 00:00:00 2001 From: Derek Homeier Date: Wed, 11 Dec 2024 17:12:02 +0100 Subject: [PATCH 7/9] Corrections to comments Co-authored-by: Kyle Sunden --- lib/matplotlib/widgets.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/matplotlib/widgets.py b/lib/matplotlib/widgets.py index 519b4c3ce087..dffcdbf25667 100644 --- a/lib/matplotlib/widgets.py +++ b/lib/matplotlib/widgets.py @@ -3516,7 +3516,7 @@ def _onmove(self, event): x0 += width - new_wh width = height = new_wh - # Transform back into de-rotated display coordiantes + # Transform back into de-rotated display coordinates new_geom_state.x0, new_geom_state.y0 = t.inverted().transform( (x0, y0)) # Width and height are invariant under the rotation, so no need @@ -3647,7 +3647,7 @@ def rotation(self, value): def _clip_to_axes(self, x, y): """ - Clip x and y values in diplay coordinates to the limits of the current + Clip x and y values in display coordinates to the limits of the current Axes. """ xlim = sorted(self.ax.get_xlim()) @@ -3674,7 +3674,7 @@ def _clip_to_axes(self, x, y): return x, y - # TODO: _draw_shape can be removed in 3.7 + # TODO: _draw_shape can be removed in 3.7(??) - still used in @extents.setter # draw_shape = _api.deprecate_privatize_attribute('3.5') def _draw_shape(self, extents): From fc74f9521ff01baf0a33f2990a5d8cd2b2650912 Mon Sep 17 00:00:00 2001 From: Derek Homeier Date: Wed, 11 Dec 2024 21:12:02 +0100 Subject: [PATCH 8/9] Fix ellipse patch bbox edges and add selector tests --- lib/matplotlib/patches.py | 2 +- lib/matplotlib/tests/test_widgets.py | 29 +++++++++++++++++----------- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/lib/matplotlib/patches.py b/lib/matplotlib/patches.py index 89132ca03901..105b85c4dbe1 100644 --- a/lib/matplotlib/patches.py +++ b/lib/matplotlib/patches.py @@ -1792,7 +1792,7 @@ def _get_edge_midpoints(self): the centre of the left-hand edge. """ return self.get_patch_transform().transform( - [(0, 0.5), (0.5, 0), (1, 0.5), (0.5, 1)]) + [(-1, 0), (0, -1), (1, 0), (0, 1)]) class Annulus(Patch): diff --git a/lib/matplotlib/tests/test_widgets.py b/lib/matplotlib/tests/test_widgets.py index 2b4e1ed4344d..0f8bbb9a31d9 100644 --- a/lib/matplotlib/tests/test_widgets.py +++ b/lib/matplotlib/tests/test_widgets.py @@ -456,7 +456,7 @@ def test_rectangle_resize_square_center(ax): @pytest.mark.parametrize('selector_class', [widgets.RectangleSelector, widgets.EllipseSelector]) -def test_rectangle_rotate(ax, selector_class): +def test_selector_rotate(ax, selector_class): ax.set_aspect(1) tool = selector_class(ax, interactive=True) # Draw rectangle @@ -564,10 +564,13 @@ def test_ellipse(ax): assert_allclose(tool.geometry[:, 0], (70, 100)) -def test_rectangle_handles(ax): - tool = widgets.RectangleSelector(ax, grab_range=10, interactive=True, - handle_props={'markerfacecolor': 'r', - 'markeredgecolor': 'b'}) +@pytest.mark.parametrize('selector_class', + [widgets.RectangleSelector, widgets.EllipseSelector]) +def test_selector_handles(ax, selector_class): + """Test geometry of Rectangle/Ellipse Selector [bounding box]""" + tool = selector_class(ax, grab_range=10, interactive=True, + handle_props={'markerfacecolor': 'r', + 'markeredgecolor': 'b'}) tool.extents = (100, 150, 100, 150) assert_allclose(tool.corners, ((100, 150, 150, 100), (100, 100, 150, 150))) @@ -584,7 +587,7 @@ def test_rectangle_handles(ax): click_and_drag(tool, start=(132, 132), end=(120, 120)) assert_allclose(tool.extents, (108, 138, 108, 138)) - # create a new rectangle + # create a new rectangle/ellipse click_and_drag(tool, start=(10, 10), end=(100, 100)) assert_allclose(tool.extents, (10, 100, 10, 100)) @@ -596,11 +599,13 @@ def test_rectangle_handles(ax): @pytest.mark.parametrize('interactive', [True, False]) -def test_rectangle_selector_onselect(ax, interactive): +@pytest.mark.parametrize('selector_class', + [widgets.RectangleSelector, widgets.EllipseSelector]) +def test_selector_onselect(ax, interactive, selector_class): # check when press and release events take place at the same position onselect = mock.Mock(spec=noop, return_value=None) - tool = widgets.RectangleSelector(ax, onselect=onselect, interactive=interactive) + tool = selector_class(ax, onselect=onselect, interactive=interactive) # move outside of axis click_and_drag(tool, start=(100, 110), end=(150, 120)) @@ -613,11 +618,13 @@ def test_rectangle_selector_onselect(ax, interactive): @pytest.mark.parametrize('ignore_event_outside', [True, False]) -def test_rectangle_selector_ignore_outside(ax, ignore_event_outside): +@pytest.mark.parametrize('selector_class', + [widgets.RectangleSelector, widgets.EllipseSelector]) +def test_selector_ignore_outside(ax, ignore_event_outside, selector_class): onselect = mock.Mock(spec=noop, return_value=None) - tool = widgets.RectangleSelector(ax, onselect=onselect, - ignore_event_outside=ignore_event_outside) + tool = selector_class(ax, onselect=onselect, + ignore_event_outside=ignore_event_outside) click_and_drag(tool, start=(100, 110), end=(150, 120)) onselect.assert_called_once() assert_allclose(tool.extents, (100.0, 150.0, 110.0, 120.0)) From 27c1c11f2e9743295ac469f461e7d88420ed9f99 Mon Sep 17 00:00:00 2001 From: Derek Homeier Date: Mon, 30 Dec 2024 14:52:44 +0100 Subject: [PATCH 9/9] Let @rotation.setter rotate around center, too; update selector tests --- lib/matplotlib/tests/test_widgets.py | 35 +++++++++++++++++++++------- lib/matplotlib/widgets.py | 12 ++++++++-- 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/lib/matplotlib/tests/test_widgets.py b/lib/matplotlib/tests/test_widgets.py index 0f8bbb9a31d9..855ad11a570f 100644 --- a/lib/matplotlib/tests/test_widgets.py +++ b/lib/matplotlib/tests/test_widgets.py @@ -459,9 +459,11 @@ def test_rectangle_resize_square_center(ax): def test_selector_rotate(ax, selector_class): ax.set_aspect(1) tool = selector_class(ax, interactive=True) + corners = np.array([[100, 130, 130, 100], [100, 100, 140, 140]]) # Draw rectangle click_and_drag(tool, start=(100, 100), end=(130, 140)) assert_allclose(tool.extents, (100, 130, 100, 140)) + assert_allclose(tool.corners, corners, atol=1e-6) assert len(tool._state) == 0 # Rotate anticlockwise using top-right corner @@ -474,20 +476,37 @@ def test_selector_rotate(ax, selector_class): # Extents change as the selector remains rigid in display coordinates assert_allclose(tool.extents, (110.10, 119.90, 95.49, 144.51), atol=0.01) assert_allclose(tool.rotation, 25.56, atol=0.01) + angle = tool.rotation + corners_dragged = tool.corners + + # Check corners on returning to unrotated position, then rotate back with @setter + tool.rotation = 0 + assert tool.rotation == 0 + assert_allclose(tool.corners, corners, atol=1e-6) + + tool.rotation = angle + assert_allclose(tool.rotation, 25.56, atol=0.01) + assert_allclose(tool.corners, corners_dragged, atol=1e-6) + assert_allclose(tool.extents, (110.10, 119.90, 95.49, 144.51), atol=0.01) + tool.rotation = 45 assert tool.rotation == 45 - # Corners should move + # Corners should move again assert_allclose(tool.corners, - np.array([[110.10, 131.31, 103.03, 81.81], - [95.49, 116.70, 144.98, 123.77]]), atol=0.01) + np.array([[118.54, 139.75, 111.46, 90.25], + [95.25, 116.46, 144.75, 123.54]]), atol=0.01) - # Scale using top-right corner - click_and_drag(tool, start=(110, 145), end=(110, 160)) - assert_allclose(tool.extents, (110, 110, 145, 160), atol=0.01) + # Rescale using top-right corner + click_and_drag(tool, start=(111, 145), end=(111, 160)) + assert_allclose(tool.corners, + np.array([[118.54, 147.25, 111.46, 82.75], + [95.25, 123.96, 159.75, 131.04]]), atol=0.01) + assert_allclose(tool.extents, (118.54, 111.46, 95.25, 159.75), atol=0.01) if selector_class == widgets.RectangleSelector: + assert tool._selection_artist.rotation_point == 'xy' with pytest.raises(ValueError): - tool._selection_artist.rotation_point = 'unvalid_value' + tool._selection_artist.rotation_point = 'invalid_value' def test_rectangle_add_remove_set(ax): @@ -570,7 +589,7 @@ def test_selector_handles(ax, selector_class): """Test geometry of Rectangle/Ellipse Selector [bounding box]""" tool = selector_class(ax, grab_range=10, interactive=True, handle_props={'markerfacecolor': 'r', - 'markeredgecolor': 'b'}) + 'markeredgecolor': 'b'}) tool.extents = (100, 150, 100, 150) assert_allclose(tool.corners, ((100, 150, 150, 100), (100, 100, 150, 150))) diff --git a/lib/matplotlib/widgets.py b/lib/matplotlib/widgets.py index dffcdbf25667..cd7f4d822390 100644 --- a/lib/matplotlib/widgets.py +++ b/lib/matplotlib/widgets.py @@ -3636,13 +3636,21 @@ def extents(self, extents): @property def rotation(self): """ - Rotation in degrees. + Rotation in degrees (around center). """ return np.rad2deg(self._rotation) @rotation.setter def rotation(self, value): - self._rotation = np.deg2rad(value) + new_angle = np.deg2rad(value) + rot = new_angle - self._rotation + xc, yc = self._selection_artist.get_center() + new_geom_state = copy.copy(self._geometry_state) + new_geom_state.rotation = new_angle + # Transform the rectangle corner xy so we are rotating about the center. + new_geom_state.xy = Affine2D().rotate_around(xc, yc, rot).transform( + self._geometry_state.xy) + self._geometry_state = new_geom_state self._update_selection_artist() def _clip_to_axes(self, x, y):