diff --git a/doc/users/next_whats_new/selector_improvement.rst b/doc/users/next_whats_new/selector_improvement.rst index f19282335705..6691c2b23a38 100644 --- a/doc/users/next_whats_new/selector_improvement.rst +++ b/doc/users/next_whats_new/selector_improvement.rst @@ -2,8 +2,7 @@ Selectors improvement: rotation, aspect ratio correction and add/remove state ----------------------------------------------------------------------------- The `~matplotlib.widgets.RectangleSelector` and -`~matplotlib.widgets.EllipseSelector` can now be rotated interactively between --45° and 45°. The range limits are currently dictated by the implementation. +`~matplotlib.widgets.EllipseSelector` can now be rotated. The rotation is enabled or disabled by striking the *r* key ('r' is the default key mapped to 'rotate' in *state_modifier_keys*) or by calling ``selector.add_state('rotate')``. diff --git a/lib/matplotlib/patches.py b/lib/matplotlib/patches.py index 894717dcdd3e..5c4226134d3f 100644 --- a/lib/matplotlib/patches.py +++ b/lib/matplotlib/patches.py @@ -738,13 +738,6 @@ def __init__(self, xy, width, height, angle=0.0, *, 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): @@ -772,13 +765,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): @@ -816,6 +807,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 center 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)) @@ -1565,12 +1564,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() @@ -1588,9 +1581,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): @@ -1681,6 +1673,14 @@ def get_corners(self): return self.get_patch_transform().transform( [(-1, -1), (1, -1), (1, 1), (-1, 1)]) + 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 Annulus(Patch): """ diff --git a/lib/matplotlib/tests/test_widgets.py b/lib/matplotlib/tests/test_widgets.py index bb91684f305e..5a8808dad5fd 100644 --- a/lib/matplotlib/tests/test_widgets.py +++ b/lib/matplotlib/tests/test_widgets.py @@ -9,7 +9,6 @@ mock_event, noop) import numpy as np -from numpy.testing import assert_allclose import pytest @@ -19,6 +18,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 check_rectangle(**kwargs): ax = get_ax() @@ -26,20 +32,19 @@ def onselect(epress, erelease): ax._got_onselect = True assert epress.xdata == 100 assert epress.ydata == 100 - assert erelease.xdata == 199 - assert erelease.ydata == 199 + assert erelease.xdata == 200 + assert erelease.ydata == 200 tool = widgets.RectangleSelector(ax, onselect, **kwargs) do_event(tool, 'press', xdata=100, ydata=100, button=1) - do_event(tool, 'onmove', xdata=199, ydata=199, button=1) - + do_event(tool, 'onmove', xdata=250, ydata=250, button=1) # purposely drag outside of axis for release do_event(tool, 'release', 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, 199, 199, 100, 100]], + [[100., 100, 200, 200, 100], + [100, 200, 200, 100, 100]], err_msg=tool.geometry) assert ax._got_onselect @@ -113,7 +118,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), @@ -122,11 +127,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): @@ -150,39 +155,49 @@ 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, onselect=noop, 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) + click_and_drag(tool, start=start, end=end) + 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): @@ -220,8 +235,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 @@ -230,8 +245,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 @@ -240,8 +255,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 @@ -250,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 == (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 @@ -260,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 == (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 @@ -270,8 +285,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]) @@ -279,7 +294,7 @@ def test_rectangle_resize_square(ax, add_state): tool = widgets.RectangleSelector(ax, onselect=noop, 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') @@ -294,8 +309,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 @@ -304,8 +319,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 @@ -314,8 +329,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 @@ -324,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 == (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 @@ -334,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 == (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 @@ -344,11 +359,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 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, onselect=noop, interactive=True) # Create rectangle click_and_drag(tool, start=(70, 65), end=(120, 115)) @@ -414,10 +430,11 @@ 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, onselect=noop, 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 @@ -427,19 +444,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): @@ -450,7 +467,7 @@ def test_rectange_add_remove_set(ax): tool = widgets.RectangleSelector(ax, onselect=noop, 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) @@ -461,36 +478,38 @@ def test_rectange_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, onselect=noop, interactive=True, 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) tool.add_state('square') tool.add_state('center') + # Create rectangle, width 50 in data coordinates + click_and_drag(tool, start=(70, 65), end=(120, 65)) 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 coordiantes, 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): @@ -501,24 +520,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): @@ -530,22 +547,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( @@ -565,7 +582,7 @@ def onselect(vmin, vmax): click_and_drag(tool, start=(100, 110), end=(150, 120)) assert tool.ax._got_onselect - assert tool.extents == (100.0, 150.0, 110.0, 120.0) + assert_allclose(tool.extents, (100.0, 150.0, 110.0, 120.0)) # Reset tool.ax._got_onselect tool.ax._got_onselect = False @@ -583,7 +600,7 @@ def onselect(vmin, vmax): ignore_event_outside=ignore_event_outside) click_and_drag(tool, start=(100, 110), end=(150, 120)) assert tool.ax._got_onselect - assert tool.extents == (100.0, 150.0, 110.0, 120.0) + assert_allclose(tool.extents, (100.0, 150.0, 110.0, 120.0)) # Reset ax._got_onselect = False @@ -592,11 +609,11 @@ def onselect(vmin, vmax): if ignore_event_outside: # event have been ignored and span haven't changed. assert not ax._got_onselect - 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 assert ax._got_onselect - assert tool.extents == (150.0, 160.0, 150.0, 160.0) + assert_allclose(tool.extents, (150.0, 160.0, 150.0, 160.0)) def check_span(*args, **kwargs): @@ -1401,6 +1418,14 @@ def test_polygon_selector_box(ax): polygon_place_vertex(*verts[3]) + 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(0, 50) + ax.set_ylim(0, 50) + atol = 0.08 + # Create selector tool = widgets.PolygonSelector(ax, onselect=noop, draw_bounding_box=True) for (etype, event_args) in event_sequence: @@ -1416,25 +1441,25 @@ def test_polygon_selector_box(ax): canvas.motion_notify_event(*t.transform((20, 20))) canvas.button_release_event(*t.transform((20, 20)), 1) 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 canvas.button_press_event(*t.transform((10, 10)), 1) canvas.motion_notify_event(*t.transform((30, 30))) canvas.button_release_event(*t.transform((30, 30)), 1) 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) canvas.button_press_event(*t.transform((30, 20)), 3) canvas.button_release_event(*t.transform((30, 20)), 3) 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) @pytest.mark.parametrize( diff --git a/lib/matplotlib/widgets.py b/lib/matplotlib/widgets.py index f9eb30f9cf8e..ef1affbc3651 100644 --- a/lib/matplotlib/widgets.py +++ b/lib/matplotlib/widgets.py @@ -11,6 +11,7 @@ from contextlib import ExitStack import copy +from dataclasses import dataclass from numbers import Integral, Number import numpy as np @@ -2844,6 +2845,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): @@ -2897,13 +2915,20 @@ def __init__(self, ax, onselect, drawtype='box', 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 self._allow_creation = True + self._drawing_new = False if drawtype == 'none': # draw a line but make it invisible _api.warn_deprecated( @@ -2923,6 +2948,14 @@ def __init__(self, ax, onselect, drawtype='box', self._props = props to_draw = self._init_shape(**self._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) + # Becasue the transform in display coords, need to manually + # add a resize callback for when the axes are reszied + self.ax.figure.canvas.mpl_connect('resize_event', self._on_resize) + if drawtype == 'line': _api.warn_deprecated( "3.5", message="Support for drawtype='line' is deprecated " @@ -2937,7 +2970,6 @@ def __init__(self, ax, onselect, drawtype='box', self.ax.add_line(to_draw) self._selection_artist = to_draw - self._set_aspect_ratio_correction() self.minspanx = minspanx self.minspany = minspany @@ -2989,6 +3021,25 @@ def __init__(self, ax, onselect, drawtype='box', property(lambda self: self.grab_range, lambda self, value: setattr(self, "grab_range", value))) + def _on_resize(self, event): + # Callback for an Axes resize + self._update_handles() + + @property + def _position_state(self): + """Return a named tuple containing all position state attributes.""" + return _RectState( + self._x0, self._y0, self._width, self._height, self._rotation) + + @_position_state.setter + def _position_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, @@ -2996,7 +3047,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.""" @@ -3014,18 +3065,19 @@ def _press(self, event): if (self._active_handle is None and not self.ignore_event_outside and self._allow_creation): - x = event.xdata - y = event.ydata - self.visible = False - self.extents = x, x, y, y - self.visible = True - else: - self.set_visible(True) + # Start drawing a new rectangle + self._x0 = event.x + self._y0 = event.y + 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._pos_state_on_press = self._position_state + if self._drawtype == 'box': + self._center_on_press = self._selection_artist.get_center() return False def _release(self, event): @@ -3037,31 +3089,29 @@ 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) # check if drawn distance (if it exists) is not too small in # either x or y-direction - minspanxy = (spanx <= self.minspanx or spany <= self.minspany) + minspanxy = abs(spanx) <= self.minspanx or abs(spany) <= self.minspany if (self._drawtype != 'none' and minspanxy): for artist in self.artists: artist.set_visible(False) @@ -3072,10 +3122,23 @@ def _release(self, event): else: self.onselect(self._eventpress, self._eventrelease) self._selection_completed = True + if self._drawing_new and self._drawtype == 'box': + # 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_pos_state = self._position_state + new_pos_state.x0 = np.min(cx) + new_pos_state.y0 = np.min(cy) + new_pos_state.width = np.max(cx) - np.min(cx) + new_pos_state.height = np.max(cy) - np.min(cy) + self._position_state = new_pos_state self.update() self._active_handle = None self._extents_on_press = None + self._drawing_new = False return False @@ -3090,126 +3153,161 @@ 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 - if resize: - inv_tr = self._get_rotation_transform().inverted() - event.xdata, event.ydata = inv_tr.transform( - [event.xdata, event.ydata]) - eventpress.xdata, eventpress.ydata = inv_tr.transform( - [eventpress.xdata, eventpress.ydata] - ) - - dx = event.xdata - eventpress.xdata - dy = event.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 + # Create a variable for the new position after this move + new_pos_state = copy.copy(self._pos_state_on_press) - x0, x1, y0, y1 = self._extents_on_press # rotate an existing shape if rotate: # calculate angle abc - a = np.array([eventpress.xdata, eventpress.ydata]) - b = np.array(self.center) - c = np.array([event.xdata, event.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_pos_state.rotation = self._pos_state_on_press.rotation + angle + # Transform the rectangle corner so we are rotating about the + # center of the rectangle + new_pos_state.xy = Affine2D().rotate_around(*b, angle).transform( + self._pos_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._pos_state_on_press.x0, + self._pos_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._pos_state_on_press.width, + self._pos_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 = event.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 = event.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(event.xdata - center[0]) + hw = abs(event.x - center[0]) if self._active_handle in ['N', 'S'] + self._corner_order: - hh = abs(event.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 = event.xdata - if self._active_handle in ['N', 'S'] + self._corner_order: - y1 = event.ydata + # center not in state + width = self._pos_state_on_press.width + height = self._pos_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(event.ydata - y0) - y1 = y0 + sign * abs(x1 - x0) / \ - self._aspect_ratio_correction - else: - sign = np.sign(event.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_pos_state.x0, new_pos_state.y0 = t.inverted().transform( + (x0, y0)) + # Width and height are invariant under the rotation, so no need + # to transform + new_pos_state.width = width + new_pos_state.height = height elif move: - x0, x1, y0, y1 = self._extents_on_press - dx = event.xdata - eventpress.xdata - dy = event.ydata - eventpress.ydata - x0 += dx - x1 += dx - y0 += dy - y1 += dy + dx = event.x - eventpress.x + dy = event.y - eventpress.y + new_pos_state.x0 += dx + new_pos_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 = (event.xdata - center[0]) / 2. - dy = (event.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: @@ -3221,41 +3319,17 @@ 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_pos_state.x0 = center[0] - dx + new_pos_state.y0 = center[1] - dy + new_pos_state.width = 2 * dx + new_pos_state.height = 2 * dy + new_pos_state.rotation = 0 - self.extents = x0, x1, y0, y1 - - @property - def _rect_bbox(self): - if self._drawtype == 'box': - return self._selection_artist.get_bbox().bounds - else: - x, y = self._selection_artist.get_data() - x0, x1 = min(x), max(x) - y0, y1 = min(y), max(y) - return x0, y0, x1 - x0, y1 - y0 - - def _set_aspect_ratio_correction(self): - aspect_ratio = self.ax._get_aspect_ratio() - if not hasattr(self._selection_artist, '_aspect_ratio_correction'): - # Aspect ratio correction is not supported with deprecated - # drawtype='line'. Remove this block in matplotlib 3.7 - self._aspect_ratio_correction = 1 - return - - 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._position_state = new_pos_state def _get_rotation_transform(self): - aspect_ratio = self.ax._get_aspect_ratio() return Affine2D().translate(-self.center[0], -self.center[1]) \ - .scale(1, aspect_ratio) \ .rotate(self._rotation) \ - .scale(1, 1 / aspect_ratio) \ .translate(*self.center) @property @@ -3264,12 +3338,15 @@ 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] + if self._drawtype == 'box': + c = self._selection_artist.get_corners() + # Convert from display to data coordinates + c = self.ax.transData.inverted().transform(c) + return c[:, 0], c[:, 1] + elif self._drawtype == 'line': + x, y = self._selection_artist.get_data() + return (np.array([x[0], x[0], x[1], x[1]]), + np.array([y[0], y[1], y[1], y[0]])) @property def edge_centers(self): @@ -3277,20 +3354,16 @@ def edge_centers(self): Midpoint of rectangle edges in data coordiantes 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): @@ -3298,63 +3371,87 @@ 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) - self._center_handle.set_data(*self.center) + 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 _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()) + + 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 - xmin, xmax = sorted([x0, x1]) - ymin, ymax = sorted([y0, y1]) - 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]) + self._x0 = x0 + self._y0 = y0 + self._width = x1 - x0 + self._height = y1 - y0 + self._update_selection_artist() + def _update_selection_artist(self): + """ + Update the selection artists from the current position state. + """ if self._drawtype == 'box': - 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) + 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) elif self._drawtype == 'line': - self._selection_artist.set_data([xmin, xmax], [ymin, ymax]) + xy0 = self._x0, self._y0 + xy1 = self._x0 + self._width, self._y0 + self._height + xy1 = Affine2D().rotate_around(*xy0, self._rotation).transform(xy1) + self._selection_artist.set_data([xy0[0], xy1[0]], [xy0[1], xy1[1]]) + + if self._interactive: + 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.""" @@ -3428,37 +3525,32 @@ 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) if self._drawtype == 'box': 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 else: rad = np.deg2rad(np.arange(31) * 12) - x = a * np.cos(rad) + center[0] - y = b * np.sin(rad) + center[1] + x = self._width / 2 * np.cos(rad) + center[0] + y = self._height / 2 * np.sin(rad) + center[1] self._selection_artist.set_data(x, y) - @property - def _rect_bbox(self): - if self._drawtype == 'box': - 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 - else: - x, y = self._selection_artist.get_data() - x0, x1 = min(x), max(x) - y0, y1 = min(y), max(y) - return x0, y0, x1 - x0, y1 - y0 + 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): @@ -3716,13 +3808,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))] pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy