Skip to content

Commit 870d2e6

Browse files
lucaznchmaf2310
andcommitted
ENH: Support open polygonal chains in PolygonSelector
Extend PolygonSelector with open polygonal chain support by adding a new parameter to explicitly control whether the chain is open or closed. The selection of an open polygonal chain is completed by pressing the Enter key after inserting a vertex. The existing interactive editing functionality also applies to open polygonal chains. Extend existing tests to cover the new mode. Closes #28421 Co-authored-by: Mafalda Botelho <[email protected]>
1 parent f4cc437 commit 870d2e6

3 files changed

Lines changed: 82 additions & 38 deletions

File tree

lib/matplotlib/tests/test_widgets.py

Lines changed: 38 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1441,22 +1441,36 @@ def polygon_remove_vertex(ax, xy):
14411441
]
14421442

14431443

1444+
def polygon_complete_chain(ax, closed, xy):
1445+
if closed:
1446+
return [
1447+
*polygon_place_vertex(ax, xy)
1448+
]
1449+
else:
1450+
return [
1451+
KeyEvent("key_press_event", ax.figure.canvas, "enter"),
1452+
KeyEvent("key_release_event", ax.figure.canvas, "enter"),
1453+
]
1454+
1455+
14441456
@pytest.mark.parametrize('draw_bounding_box', [False, True])
1445-
def test_polygon_selector(ax, draw_bounding_box):
1446-
check_selector = functools.partial(
1447-
check_polygon_selector, draw_bounding_box=draw_bounding_box)
1457+
@pytest.mark.parametrize('closed', [False, True])
1458+
def test_polygon_selector(ax, draw_bounding_box, closed):
1459+
check_selector = functools.partial(check_polygon_selector,
1460+
draw_bounding_box=draw_bounding_box,
1461+
closed=closed)
14481462

1449-
# Simple polygon
1463+
# Simple polygonal chain.
14501464
expected_result = [(50, 50), (150, 50), (50, 150)]
14511465
event_sequence = [
14521466
*polygon_place_vertex(ax, (50, 50)),
14531467
*polygon_place_vertex(ax, (150, 50)),
14541468
*polygon_place_vertex(ax, (50, 150)),
1455-
*polygon_place_vertex(ax, (50, 50)),
1469+
*polygon_complete_chain(ax, closed, (50, 50)),
14561470
]
14571471
check_selector(event_sequence, expected_result, 1)
14581472

1459-
# Move first vertex before completing the polygon.
1473+
# Move first vertex before completing the polygonal chain.
14601474
expected_result = [(75, 50), (150, 50), (50, 150)]
14611475
event_sequence = [
14621476
*polygon_place_vertex(ax, (50, 50)),
@@ -1468,11 +1482,11 @@ def test_polygon_selector(ax, draw_bounding_box):
14681482
MouseEvent._from_ax_coords("button_release_event", ax, (75, 50), 1),
14691483
KeyEvent("key_release_event", ax.figure.canvas, "control"),
14701484
*polygon_place_vertex(ax, (50, 150)),
1471-
*polygon_place_vertex(ax, (75, 50)),
1485+
*polygon_complete_chain(ax, closed, (75, 50)),
14721486
]
14731487
check_selector(event_sequence, expected_result, 1)
14741488

1475-
# Move first two vertices at once before completing the polygon.
1489+
# Move first two vertices at once before completing the polygonal chain.
14761490
expected_result = [(50, 75), (150, 75), (50, 150)]
14771491
event_sequence = [
14781492
*polygon_place_vertex(ax, (50, 50)),
@@ -1484,31 +1498,31 @@ def test_polygon_selector(ax, draw_bounding_box):
14841498
MouseEvent._from_ax_coords("button_release_event", ax, (100, 125), 1),
14851499
KeyEvent("key_release_event", ax.figure.canvas, "shift"),
14861500
*polygon_place_vertex(ax, (50, 150)),
1487-
*polygon_place_vertex(ax, (50, 75)),
1501+
*polygon_complete_chain(ax, closed, (50, 75)),
14881502
]
14891503
check_selector(event_sequence, expected_result, 1)
14901504

1491-
# Move first vertex after completing the polygon.
1505+
# Move first vertex after completing the polygonal chain.
14921506
expected_result = [(85, 50), (150, 50), (50, 150)]
14931507
event_sequence = [
14941508
*polygon_place_vertex(ax, (60, 50)),
14951509
*polygon_place_vertex(ax, (150, 50)),
14961510
*polygon_place_vertex(ax, (50, 150)),
1497-
*polygon_place_vertex(ax, (60, 50)),
1511+
*polygon_complete_chain(ax, closed, (60, 50)),
14981512
MouseEvent._from_ax_coords("motion_notify_event", ax, (60, 50)),
14991513
MouseEvent._from_ax_coords("button_press_event", ax, (60, 50), 1),
15001514
MouseEvent._from_ax_coords("motion_notify_event", ax, (85, 50)),
15011515
MouseEvent._from_ax_coords("button_release_event", ax, (85, 50), 1),
15021516
]
15031517
check_selector(event_sequence, expected_result, 2)
15041518

1505-
# Move all vertices after completing the polygon.
1519+
# Move all vertices after completing the polygonal chain.
15061520
expected_result = [(75, 75), (175, 75), (75, 175)]
15071521
event_sequence = [
15081522
*polygon_place_vertex(ax, (50, 50)),
15091523
*polygon_place_vertex(ax, (150, 50)),
15101524
*polygon_place_vertex(ax, (50, 150)),
1511-
*polygon_place_vertex(ax, (50, 50)),
1525+
*polygon_complete_chain(ax, closed, (50, 50)),
15121526
KeyEvent("key_press_event", ax.figure.canvas, "shift"),
15131527
MouseEvent._from_ax_coords("motion_notify_event", ax, (100, 100)),
15141528
MouseEvent._from_ax_coords("button_press_event", ax, (100, 100), 1),
@@ -1536,11 +1550,11 @@ def test_polygon_selector(ax, draw_bounding_box):
15361550
*polygon_place_vertex(ax, (50, 50)),
15371551
*polygon_place_vertex(ax, (150, 50)),
15381552
*polygon_place_vertex(ax, (50, 150)),
1539-
*polygon_place_vertex(ax, (50, 50)),
1553+
*polygon_complete_chain(ax, closed, (50, 50)),
15401554
]
15411555
check_selector(event_sequence, expected_result, 1)
15421556

1543-
# Try to place vertex out-of-bounds, then reset, and start a new polygon.
1557+
# Try to place vertex out-of-bounds, then reset, and start a new polygonal chain.
15441558
expected_result = [(50, 50), (150, 50), (50, 150)]
15451559
event_sequence = [
15461560
*polygon_place_vertex(ax, (50, 50)),
@@ -1550,7 +1564,7 @@ def test_polygon_selector(ax, draw_bounding_box):
15501564
*polygon_place_vertex(ax, (50, 50)),
15511565
*polygon_place_vertex(ax, (150, 50)),
15521566
*polygon_place_vertex(ax, (50, 150)),
1553-
*polygon_place_vertex(ax, (50, 50)),
1567+
*polygon_complete_chain(ax, closed, (50, 50)),
15541568
]
15551569
check_selector(event_sequence, expected_result, 1)
15561570

@@ -1599,35 +1613,37 @@ def test_rect_visibility(fig_test, fig_ref):
15991613
# Change the order that the extra point is inserted in
16001614
@pytest.mark.parametrize('idx', [1, 2, 3])
16011615
@pytest.mark.parametrize('draw_bounding_box', [False, True])
1602-
def test_polygon_selector_remove(ax, idx, draw_bounding_box):
1616+
@pytest.mark.parametrize('closed', [False, True])
1617+
def test_polygon_selector_remove(ax, idx, draw_bounding_box, closed):
16031618
verts = [(50, 50), (150, 50), (50, 150)]
16041619
event_sequence = [polygon_place_vertex(ax, verts[0]),
16051620
polygon_place_vertex(ax, verts[1]),
16061621
polygon_place_vertex(ax, verts[2]),
16071622
# Finish the polygon
1608-
polygon_place_vertex(ax, verts[0])]
1623+
polygon_complete_chain(ax, closed, verts[0])]
16091624
# Add an extra point
16101625
event_sequence.insert(idx, polygon_place_vertex(ax, (200, 200)))
16111626
# Remove the extra point
16121627
event_sequence.append(polygon_remove_vertex(ax, (200, 200)))
16131628
# Flatten list of lists
16141629
event_sequence = functools.reduce(operator.iadd, event_sequence, [])
16151630
check_polygon_selector(event_sequence, verts, 2,
1616-
draw_bounding_box=draw_bounding_box)
1631+
draw_bounding_box=draw_bounding_box, closed=closed)
16171632

16181633

16191634
@pytest.mark.parametrize('draw_bounding_box', [False, True])
1620-
def test_polygon_selector_remove_first_point(ax, draw_bounding_box):
1635+
@pytest.mark.parametrize('closed', [False, True])
1636+
def test_polygon_selector_remove_first_point(ax, draw_bounding_box, closed):
16211637
verts = [(50, 50), (150, 50), (50, 150)]
16221638
event_sequence = [
16231639
*polygon_place_vertex(ax, verts[0]),
16241640
*polygon_place_vertex(ax, verts[1]),
16251641
*polygon_place_vertex(ax, verts[2]),
1626-
*polygon_place_vertex(ax, verts[0]),
1642+
*polygon_complete_chain(ax, closed, verts[0]),
16271643
*polygon_remove_vertex(ax, verts[0]),
16281644
]
16291645
check_polygon_selector(event_sequence, verts[1:], 2,
1630-
draw_bounding_box=draw_bounding_box)
1646+
draw_bounding_box=draw_bounding_box, closed=closed)
16311647

16321648

16331649
@pytest.mark.parametrize('draw_bounding_box', [False, True])

lib/matplotlib/widgets.py

Lines changed: 42 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4002,7 +4002,7 @@ def _onmove(self, event):
40024002

40034003
class PolygonSelector(_SelectorWidget):
40044004
"""
4005-
Select a polygon region of an Axes.
4005+
Select a polygonal region or chain within an Axes.
40064006
40074007
Place vertices with each mouse click, and make the selection by completing
40084008
the polygon (clicking on the first vertex). Once drawn individual vertices
@@ -4015,6 +4015,7 @@ class PolygonSelector(_SelectorWidget):
40154015
polygon has been completed.
40164016
- Hold the *shift* key and click and drag anywhere in the Axes to move
40174017
all vertices.
4018+
- Press *enter* to finalize the selection of an open polygonal chain.
40184019
- Press the *esc* key to start a new polygon.
40194020
40204021
For the selector to remain responsive you must keep a reference to it.
@@ -4064,6 +4065,9 @@ class PolygonSelector(_SelectorWidget):
40644065
Properties to set for the box. See the documentation for the *props*
40654066
argument to `RectangleSelector` for more info.
40664067
4068+
closed : bool, default: True
4069+
Whether to select a closed polygonal region or an open polygonal chain.
4070+
40674071
Examples
40684072
--------
40694073
:doc:`/gallery/widgets/polygon_selector_simple`
@@ -4079,7 +4083,7 @@ class PolygonSelector(_SelectorWidget):
40794083
def __init__(self, ax, onselect=None, *, useblit=False,
40804084
props=None, handle_props=None, grab_range=10,
40814085
draw_bounding_box=False, box_handle_props=None,
4082-
box_props=None):
4086+
box_props=None, closed=True):
40834087
# The state modifiers 'move', 'square', and 'center' are expected by
40844088
# _SelectorWidget but are not supported by PolygonSelector
40854089
# Note: could not use the existing 'move' state modifier in-place of
@@ -4113,14 +4117,16 @@ def __init__(self, ax, onselect=None, *, useblit=False,
41134117
self.grab_range = grab_range
41144118

41154119
self.set_visible(True)
4116-
self._draw_box = draw_bounding_box
4120+
self._draw_box = draw_bounding_box and closed
41174121
self._box = None
41184122

41194123
if box_handle_props is None:
41204124
box_handle_props = {}
41214125
self._box_handle_props = self._handle_props.update(box_handle_props)
41224126
self._box_props = box_props
41234127

4128+
self.closed = closed
4129+
41244130
def _get_bbox(self):
41254131
return self._selection_artist.get_bbox()
41264132

@@ -4188,7 +4194,7 @@ def _handles_artists(self):
41884194

41894195
def _remove_vertex(self, i):
41904196
"""Remove vertex with index i."""
4191-
if (len(self._xys) > 2 and
4197+
if (self.closed and len(self._xys) > 2 and
41924198
self._selection_completed and
41934199
i in (0, len(self._xys) - 1)):
41944200
# If selecting the first or final vertex, remove both first and
@@ -4200,9 +4206,11 @@ def _remove_vertex(self, i):
42004206
self._xys.append(self._xys[0])
42014207
else:
42024208
self._xys.pop(i)
4203-
if len(self._xys) <= 2:
4204-
# If only one point left, return to incomplete state to let user
4205-
# start drawing again
4209+
if ((self.closed and len(self._xys) <= 2)
4210+
or (not self.closed and len(self._xys) == 0)):
4211+
# If only one point left for polygon or
4212+
# or no points left for open polygonal chain,
4213+
# return to incomplete state to let user start drawing again.
42064214
self._selection_completed = False
42074215
self._remove_box()
42084216

@@ -4229,7 +4237,7 @@ def _release(self, event):
42294237
self._active_handle_idx = -1
42304238

42314239
# Complete the polygon.
4232-
elif len(self._xys) > 3 and self._xys[-1] == self._xys[0]:
4240+
elif self.closed and len(self._xys) > 3 and self._xys[-1] == self._xys[0]:
42334241
self._selection_completed = True
42344242
if self._draw_box and self._box is None:
42354243
self._add_box()
@@ -4238,7 +4246,10 @@ def _release(self, event):
42384246
elif (not self._selection_completed
42394247
and 'move_all' not in self._state
42404248
and 'move_vertex' not in self._state):
4241-
self._xys.insert(-1, (event.xdata, event.ydata))
4249+
if self.closed:
4250+
self._xys.insert(-1, (event.xdata, event.ydata))
4251+
else:
4252+
self._xys.append((event.xdata, event.ydata))
42424253

42434254
if self._selection_completed:
42444255
self.onselect(self.verts)
@@ -4270,7 +4281,7 @@ def _onmove(self, event):
42704281
self._xys[idx] = (event.xdata, event.ydata)
42714282
# Also update the end of the polygon line if the first vertex is
42724283
# the active handle and the polygon is completed.
4273-
if idx == 0 and self._selection_completed:
4284+
if idx == 0 and self.closed and self._selection_completed:
42744285
self._xys[-1] = (event.xdata, event.ydata)
42754286

42764287
# Move all vertices.
@@ -4286,8 +4297,8 @@ def _onmove(self, event):
42864297
or 'move_vertex' in self._state or 'move_all' in self._state):
42874298
return
42884299

4289-
# Position pending vertex.
4290-
else:
4300+
# Position pending polygon vertex.
4301+
elif self.closed:
42914302
# Calculate distance to the start vertex.
42924303
x0, y0 = \
42934304
self._selection_artist.get_transform().transform(self._xys[0])
@@ -4298,6 +4309,15 @@ def _onmove(self, event):
42984309
else:
42994310
self._xys[-1] = (event.xdata, event.ydata)
43004311

4312+
# Position pending open polygonal chain vertex.
4313+
else:
4314+
if len(self._xys) == 0:
4315+
# After removal of the only vertex in open polygonal chain,
4316+
# add a new vertex at the current cursor position.
4317+
self._xys.append((event.xdata, event.ydata))
4318+
else:
4319+
self._xys[-1] = (event.xdata, event.ydata)
4320+
43014321
self._draw_polygon()
43024322

43034323
def _on_key_press(self, event):
@@ -4320,6 +4340,13 @@ def _on_key_release(self, event):
43204340
or event.key == self._state_modifier_keys.get('move_all'))):
43214341
self._xys.append((event.xdata, event.ydata))
43224342
self._draw_polygon()
4343+
elif (not self.closed and not self._selection_completed
4344+
and event.key == 'enter' and len(self._xys) > 1):
4345+
# Complete the open polygonal chain.
4346+
self._xys.pop()
4347+
self._selection_completed = True
4348+
self._draw_polygon()
4349+
self.onselect(self.verts)
43234350
# Reset the polygon if the released key is the 'clear' key.
43244351
elif event.key == self._state_modifier_keys.get('clear'):
43254352
event = self._clean_event(event)
@@ -4336,7 +4363,7 @@ def _draw_polygon_without_update(self):
43364363
# Only show one tool handle at the start and end vertex of the polygon
43374364
# if the polygon is completed or the user is locked on to the start
43384365
# vertex.
4339-
if (self._selection_completed
4366+
if (self.closed and self._selection_completed
43404367
or (len(self._xys) > 3
43414368
and self._xys[-1] == self._xys[0])):
43424369
self._polygon_handles.set_data(xs[:-1], ys[:-1])
@@ -4351,7 +4378,7 @@ def _draw_polygon(self):
43514378
@property
43524379
def verts(self):
43534380
"""The polygon vertices, as a list of ``(x, y)`` pairs."""
4354-
return self._xys[:-1]
4381+
return self._xys[:-1] if self.closed else self._xys
43554382

43564383
@verts.setter
43574384
def verts(self, xys):
@@ -4361,7 +4388,7 @@ def verts(self, xys):
43614388
This will remove any preexisting vertices, creating a complete polygon
43624389
with the new vertices.
43634390
"""
4364-
self._xys = [*xys, xys[0]]
4391+
self._xys = [*xys, xys[0]] if self.closed else [*xys]
43654392
self._selection_completed = True
43664393
self.set_visible(True)
43674394
if self._draw_box and self._box is None:

lib/matplotlib/widgets.pyi

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -470,7 +470,8 @@ class PolygonSelector(_SelectorWidget):
470470
grab_range: float = ...,
471471
draw_bounding_box: bool = ...,
472472
box_handle_props: dict[str, Any] | None = ...,
473-
box_props: dict[str, Any] | None = ...
473+
box_props: dict[str, Any] | None = ...,
474+
closed: bool = ...,
474475
) -> None: ...
475476
def onmove(self, event: Event) -> bool: ...
476477
@property

0 commit comments

Comments
 (0)