Skip to content

Commit c748401

Browse files
committed
Implement rotation selector
1 parent a6de72c commit c748401

File tree

3 files changed

+84
-35
lines changed

3 files changed

+84
-35
lines changed

lib/matplotlib/patches.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -706,7 +706,8 @@ def __str__(self):
706706
return fmt % pars
707707

708708
@docstring.dedent_interpd
709-
def __init__(self, xy, width, height, angle=0.0, **kwargs):
709+
def __init__(self, xy, width, height, angle=0.0,
710+
rotate_around_center=False, **kwargs):
710711
"""
711712
Parameters
712713
----------
@@ -717,7 +718,12 @@ def __init__(self, xy, width, height, angle=0.0, **kwargs):
717718
height : float
718719
Rectangle height.
719720
angle : float, default: 0
720-
Rotation in degrees anti-clockwise about *xy*.
721+
Rotation in degrees anti-clockwise about *xy* if
722+
*rotate_around_center* if False, otherwise rotate around the
723+
center of the rectangle
724+
rotate_around_center : bool, default: False
725+
If True, the rotation is performed around the center of the
726+
rectangle.
721727
722728
Other Parameters
723729
----------------
@@ -730,6 +736,7 @@ def __init__(self, xy, width, height, angle=0.0, **kwargs):
730736
self._width = width
731737
self._height = height
732738
self.angle = float(angle)
739+
self.rotate_around_center = rotate_around_center
733740
self._convert_units() # Validate the inputs.
734741

735742
def get_path(self):
@@ -750,9 +757,14 @@ def get_patch_transform(self):
750757
# important to call the accessor method and not directly access the
751758
# transformation member variable.
752759
bbox = self.get_bbox()
760+
if self.rotate_around_center:
761+
width, height = bbox.x1 - bbox.x0, bbox.y1 - bbox.y0
762+
rotation_point = bbox.x0 + width / 2., bbox.y0 + height / 2.
763+
else:
764+
rotation_point = bbox.x0, bbox.y0
753765
return (transforms.BboxTransformTo(bbox)
754766
+ transforms.Affine2D().rotate_deg_around(
755-
bbox.x0, bbox.y0, self.angle))
767+
*rotation_point, self.angle))
756768

757769
def get_x(self):
758770
"""Return the left coordinate of the rectangle."""

lib/matplotlib/tests/test_widgets.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -510,11 +510,10 @@ def onselect(epress, erelease):
510510
'markeredgecolor': 'b'})
511511
tool.extents = (100, 150, 100, 150)
512512

513-
assert tool.corners == (
514-
(100, 150, 150, 100), (100, 100, 150, 150))
513+
assert_allclose(tool.corners, ((100, 150, 150, 100), (100, 100, 150, 150)))
515514
assert tool.extents == (100, 150, 100, 150)
516-
assert tool.edge_centers == (
517-
(100, 125.0, 150, 125.0), (125.0, 100, 125.0, 150))
515+
assert_allclose(tool.edge_centers,
516+
((100, 125.0, 150, 125.0), (125.0, 100, 125.0, 150)))
518517
assert tool.extents == (100, 150, 100, 150)
519518

520519
# grab a corner and move it

lib/matplotlib/widgets.py

Lines changed: 66 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from . import _api, backend_tools, cbook, colors, ticker
2121
from .lines import Line2D
2222
from .patches import Circle, Rectangle, Ellipse
23-
from .transforms import TransformedPatchPath
23+
from .transforms import TransformedPatchPath, Affine2D
2424

2525

2626
class LockDraw:
@@ -1811,7 +1811,8 @@ def __init__(self, ax, onselect, useblit=False, button=None,
18111811

18121812
self._state_modifier_keys = dict(move=' ', clear='escape',
18131813
square='shift', center='control',
1814-
data_coordinates='d')
1814+
data_coordinates='d',
1815+
rotate='r')
18151816
self._state_modifier_keys.update(state_modifier_keys or {})
18161817

18171818
self.background = None
@@ -1946,8 +1947,9 @@ def press(self, event):
19461947
key = event.key or ''
19471948
key = key.replace('ctrl', 'control')
19481949
# move state is locked in on a button press
1949-
if key == self._state_modifier_keys['move']:
1950-
self._state.add('move')
1950+
for action in ['move']:
1951+
if key == self._state_modifier_keys[action]:
1952+
self._state.add(action)
19511953
self._press(event)
19521954
return True
19531955
return False
@@ -1997,14 +1999,15 @@ def on_key_press(self, event):
19971999
if key == self._state_modifier_keys['clear']:
19982000
self.clear()
19992001
return
2000-
if key == 'd' and key in self._state_modifier_keys.values():
2001-
modifier = 'data_coordinates'
2002-
if modifier in self._default_state:
2003-
self._default_state.remove(modifier)
2004-
else:
2005-
self.add_default_state(modifier)
2002+
for state in ['rotate', 'data_coordinates']:
2003+
if key == self._state_modifier_keys[state]:
2004+
if state in self._default_state:
2005+
self._default_state.remove(state)
2006+
else:
2007+
self.add_default_state(state)
20062008
for (state, modifier) in self._state_modifier_keys.items():
2007-
if modifier in key:
2009+
# Multiple keys are string concatenated using '+'
2010+
if modifier in key.split('+'):
20082011
self._state.add(state)
20092012
self._on_key_press(event)
20102013

@@ -2212,7 +2215,8 @@ def __init__(self, ax, onselect, direction, minspan=0, useblit=False,
22122215
state_modifier_keys = dict(clear='escape',
22132216
square='not-applicable',
22142217
center='not-applicable',
2215-
data_coordinates='not-applicable')
2218+
data_coordinates='not-applicable',
2219+
rotate='not-applicable')
22162220
super().__init__(ax, onselect, useblit=useblit, button=button,
22172221
state_modifier_keys=state_modifier_keys)
22182222

@@ -2788,6 +2792,7 @@ def onselect(eclick: MouseEvent, erelease: MouseEvent)
27882792
- "center": change the shape around its center, default: "ctrl".
27892793
- "data_coordinates": define if data or figure coordinates should be
27902794
used to define the square shape, default: "d"
2795+
- "rotate": Rotate the shape around its center, default: "r".
27912796
27922797
"square" and "center" can be combined. The square shape can be defined
27932798
in data or figure coordinates as determined by the ``data_coordinates``
@@ -2835,8 +2840,6 @@ class RectangleSelector(_SelectorWidget):
28352840
See also: :doc:`/gallery/widgets/rectangle_selector`
28362841
"""
28372842

2838-
_shape_klass = Rectangle
2839-
28402843
@_api.rename_parameter("3.5", "maxdist", "grab_range")
28412844
@_api.rename_parameter("3.5", "marker_props", "handle_props")
28422845
@_api.rename_parameter("3.5", "rectprops", "props")
@@ -2855,6 +2858,7 @@ def __init__(self, ax, onselect, drawtype='box',
28552858
self._interactive = interactive
28562859
self.drag_from_anywhere = drag_from_anywhere
28572860
self.ignore_event_outside = ignore_event_outside
2861+
self._rotation = 0
28582862

28592863
if drawtype == 'none': # draw a line but make it invisible
28602864
_api.warn_deprecated(
@@ -2872,8 +2876,7 @@ def __init__(self, ax, onselect, drawtype='box',
28722876
props['animated'] = self.useblit
28732877
self.visible = props.pop('visible', self.visible)
28742878
self._props = props
2875-
to_draw = self._shape_klass((0, 0), 0, 1, visible=False,
2876-
**self._props)
2879+
to_draw = self._init_shape(**self._props)
28772880
self.ax.add_patch(to_draw)
28782881
if drawtype == 'line':
28792882
_api.warn_deprecated(
@@ -2945,6 +2948,10 @@ def _handles_artists(self):
29452948
return (*self._center_handle.artists, *self._corner_handles.artists,
29462949
*self._edge_handles.artists)
29472950

2951+
def _init_shape(self, **props):
2952+
return Rectangle((0, 0), 0, 1, visible=False,
2953+
rotate_around_center=True, **props)
2954+
29482955
def _press(self, event):
29492956
"""Button press event handler."""
29502957
# make the drawn box/line visible get the click-coordinates,
@@ -3041,9 +3048,17 @@ def _onmove(self, event):
30413048
refx = event.xdata / (self._eventpress.xdata + 1e-6)
30423049
refy = event.ydata / (self._eventpress.ydata + 1e-6)
30433050

3051+
3052+
x0, x1, y0, y1 = self._extents_on_press
30443053
# resize an existing shape
3045-
if self._active_handle and self._active_handle != 'C':
3046-
x0, x1, y0, y1 = self._extents_on_press
3054+
if 'rotate' in state and self._active_handle in self._corner_order:
3055+
# calculate angle abc
3056+
a = np.array([self._eventpress.xdata, self._eventpress.ydata])
3057+
b = np.array(self.center)
3058+
c = np.array([event.xdata, event.ydata])
3059+
self._rotation = (np.arctan2(c[1]-b[1], c[0]-b[0]) -
3060+
np.arctan2(a[1]-b[1], a[0]-b[0]))
3061+
elif self._active_handle and self._active_handle != 'C':
30473062
size_on_press = [x1 - x0, y1 - y0]
30483063
center = [x0 + size_on_press[0] / 2, y0 + size_on_press[1] / 2]
30493064

@@ -3108,6 +3123,7 @@ def _onmove(self, event):
31083123

31093124
# new shape
31103125
else:
3126+
self._rotation = 0
31113127
# Don't create a new rectangle if there is already one when
31123128
# ignore_event_outside=True
31133129
if self.ignore_event_outside and self._selection_completed:
@@ -3142,24 +3158,25 @@ def _onmove(self, event):
31423158
@property
31433159
def _rect_bbox(self):
31443160
if self._drawtype == 'box':
3145-
x0 = self._selection_artist.get_x()
3146-
y0 = self._selection_artist.get_y()
3147-
width = self._selection_artist.get_width()
3148-
height = self._selection_artist.get_height()
3149-
return x0, y0, width, height
3161+
return self._selection_artist.get_bbox().bounds
31503162
else:
31513163
x, y = self._selection_artist.get_data()
31523164
x0, x1 = min(x), max(x)
31533165
y0, y1 = min(y), max(y)
31543166
return x0, y0, x1 - x0, y1 - y0
31553167

3168+
def _get_rotation_transform(self):
3169+
return Affine2D().rotate_around(*self.center, self._rotation)
3170+
31563171
@property
31573172
def corners(self):
31583173
"""Corners of rectangle from lower left, moving clockwise."""
31593174
x0, y0, width, height = self._rect_bbox
31603175
xc = x0, x0 + width, x0 + width, x0
31613176
yc = y0, y0, y0 + height, y0 + height
3162-
return xc, yc
3177+
transform = self._get_rotation_transform()
3178+
coords = transform.transform(np.array([xc, yc]).T).T
3179+
return coords[0], coords[1]
31633180

31643181
@property
31653182
def edge_centers(self):
@@ -3169,7 +3186,9 @@ def edge_centers(self):
31693186
h = height / 2.
31703187
xe = x0, x0 + w, x0 + width, x0 + w
31713188
ye = y0 + h, y0, y0 + h, y0 + height
3172-
return xe, ye
3189+
transform = self._get_rotation_transform()
3190+
coords = transform.transform(np.array([xe, ye]).T).T
3191+
return coords[0], coords[1]
31733192

31743193
@property
31753194
def center(self):
@@ -3179,7 +3198,10 @@ def center(self):
31793198

31803199
@property
31813200
def extents(self):
3182-
"""Return (xmin, xmax, ymin, ymax)."""
3201+
"""
3202+
Return (xmin, xmax, ymin, ymax) as defined by the bounding box before
3203+
rotation.
3204+
"""
31833205
x0, y0, width, height = self._rect_bbox
31843206
xmin, xmax = sorted([x0, x0 + width])
31853207
ymin, ymax = sorted([y0, y0 + height])
@@ -3197,6 +3219,17 @@ def extents(self, extents):
31973219
self.set_visible(self.visible)
31983220
self.update()
31993221

3222+
@property
3223+
def rotation(self):
3224+
"""Rotation in degree."""
3225+
return np.rad2deg(self._rotation)
3226+
3227+
@rotation.setter
3228+
def rotation(self, value):
3229+
self._rotation = np.deg2rad(value)
3230+
# call extents setter to draw shape and update handles positions
3231+
self.extents = self.extents
3232+
32003233
draw_shape = _api.deprecate_privatize_attribute('3.5')
32013234

32023235
def _draw_shape(self, extents):
@@ -3216,6 +3249,7 @@ def _draw_shape(self, extents):
32163249
self._selection_artist.set_y(ymin)
32173250
self._selection_artist.set_width(xmax - xmin)
32183251
self._selection_artist.set_height(ymax - ymin)
3252+
self._selection_artist.set_angle(self.rotation)
32193253

32203254
elif self._drawtype == 'line':
32213255
self._selection_artist.set_data([xmin, xmax], [ymin, ymax])
@@ -3288,9 +3322,11 @@ class EllipseSelector(RectangleSelector):
32883322
:doc:`/gallery/widgets/rectangle_selector`
32893323
"""
32903324

3291-
_shape_klass = Ellipse
32923325
draw_shape = _api.deprecate_privatize_attribute('3.5')
32933326

3327+
def _init_shape(self, **props):
3328+
return Ellipse((0, 0), 0, 1, visible=False, **props)
3329+
32943330
def _draw_shape(self, extents):
32953331
x0, x1, y0, y1 = extents
32963332
xmin, xmax = sorted([x0, x1])
@@ -3303,6 +3339,7 @@ def _draw_shape(self, extents):
33033339
self._selection_artist.center = center
33043340
self._selection_artist.width = 2 * a
33053341
self._selection_artist.height = 2 * b
3342+
self._selection_artist.set_angle(self.rotation)
33063343
else:
33073344
rad = np.deg2rad(np.arange(31) * 12)
33083345
x = a * np.cos(rad) + center[0]
@@ -3483,7 +3520,8 @@ def __init__(self, ax, onselect, useblit=False,
34833520
move_all='shift', move='not-applicable',
34843521
square='not-applicable',
34853522
center='not-applicable',
3486-
data_coordinates='not-applicable')
3523+
data_coordinates='not-applicable',
3524+
rotate='not-applicable')
34873525
super().__init__(ax, onselect, useblit=useblit,
34883526
state_modifier_keys=state_modifier_keys)
34893527

0 commit comments

Comments
 (0)