# Copyright 2026 European Union
# Author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu)
# SPDX-License-Identifier: EUPL-1.2
"""
Interactive angle selector for matplotlib plots.
This module provides a selector that captures three points to define an
angle. The selector supports interactive editing of the three vertices
and can snap movements to horizontal/vertical directions when the user
holds the control key.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal, Optional, cast
from matplotlib.axes import Axes
from matplotlib.backend_bases import KeyEvent, MouseEvent, PickEvent
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg
from matplotlib.lines import Line2D
from PySide6.QtCore import QObject, Qt, Signal
from PySide6.QtWidgets import QApplication
from radioviz.geometry.line_selector import snap_to_angle
[docs]
@dataclass(frozen=True)
class AngleGeometry:
"""
Represents the geometric properties of an angle.
The angle is defined by three points where *p2* is the vertex.
"""
p1: tuple[float, float]
"""First point of the angle as (x, y) coordinates."""
p2: tuple[float, float]
"""Vertex point of the angle as (x, y) coordinates."""
p3: tuple[float, float]
"""Third point of the angle as (x, y) coordinates."""
[docs]
def as_points(self) -> tuple[tuple[float, float], tuple[float, float], tuple[float, float]]:
"""
Return the points in a tuple.
:return: Tuple of (p1, p2, p3)
:rtype: tuple[tuple[float, float], tuple[float, float], tuple[float, float]]
"""
return self.p1, self.p2, self.p3
[docs]
class AngleSelector(QObject):
"""
Interactive selector for defining an angle with three points.
The selector workflow is:
1) Click three points to define the angle.
2) Edit points by dragging handles.
3) Press Enter to accept or Escape to cancel.
"""
selector_created = Signal()
"""Signal emitted when a new selector is created."""
selector_accepted = Signal()
"""Signal emitted when the selector is accepted."""
selector_canceled = Signal()
"""Signal emitted when the selector is canceled."""
def __init__(
self,
ax: Axes,
/,
*,
color: str = 'orange',
linewidth: float = 2.0,
handle_size: float = 8.0,
picker_tol: float = 10.0,
snap_deg: float = 90.0,
):
"""
Initialize the AngleSelector.
:param ax: Matplotlib axes object where the selector will be displayed
:type ax: matplotlib.axes.Axes
:param color: Color for the selector lines and handles
:type color: str
:param linewidth: Line width for selector segments
:type linewidth: float
:param handle_size: Size of the handle markers in points
:type handle_size: float
:param picker_tol: Tolerance for handle picking in pixels
:type picker_tol: float
:param snap_deg: Angle snapping step in degrees
:type snap_deg: float
"""
super().__init__()
self.ax = ax
self.canvas = cast(FigureCanvasQTAgg, ax.figure.canvas)
self._previously_focused_widget = QApplication.focusWidget()
self.canvas.setFocus()
self.canvas.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.color = color
self.linewidth = linewidth
self.handle_size = handle_size
self.picker_tol = picker_tol
self.snap_deg = snap_deg
self._state: Literal['idle', 'creating', 'editing'] = 'idle'
self._points: list[tuple[float, float]] = []
self._geometry: Optional[AngleGeometry] = None
self._active_role: Optional[Literal['p1', 'p2', 'p3']] = None
self._last_xy: Optional[tuple[float, float]] = None
self._ctrl_pressed: bool = False
self._line1: Optional[Line2D] = None
self._line2: Optional[Line2D] = None
self._temp_line: Optional[Line2D] = None
self._h1: Optional[Line2D] = None
self._h2: Optional[Line2D] = None
self._h3: Optional[Line2D] = None
self._cid_press = self.canvas.mpl_connect('button_press_event', self._on_press)
self._cid_motion = self.canvas.mpl_connect('motion_notify_event', self._on_motion)
self._cid_release = self.canvas.mpl_connect('button_release_event', self._on_release)
self._cid_pick = self.canvas.mpl_connect('pick_event', self._on_pick)
self._cid_key_press = self.canvas.mpl_connect('key_press_event', self._on_key_press)
self._cid_key_release = self.canvas.mpl_connect('key_release_event', self._on_key_release)
[docs]
def _restore_focus(self) -> None:
"""
Restore focus to the previously focused widget.
"""
if self._previously_focused_widget is not None:
self._previously_focused_widget.setFocus()
self._previously_focused_widget = None
# ------------------------------------------------------------------
# Event handlers
# ------------------------------------------------------------------
[docs]
def _on_press(self, event: object) -> None:
"""
Handle mouse press events.
:param event: Mouse event data
:type event: matplotlib.backend_bases.MouseEvent
"""
if not isinstance(event, MouseEvent):
return
if event.inaxes != self.ax or event.xdata is None or event.ydata is None:
return
if self._state in ('idle', 'creating'):
self._state = 'creating'
self._points.append((event.xdata, event.ydata))
if len(self._points) == 2:
self._promote_first_segment()
if len(self._points) == 3:
self._finalize_creation()
elif self._state == 'editing':
self._last_xy = (event.xdata, event.ydata)
[docs]
def _on_motion(self, event: object) -> None:
"""
Handle mouse motion events.
:param event: Mouse motion event data
:type event: matplotlib.backend_bases.MouseEvent
"""
if not isinstance(event, MouseEvent):
return
if event.inaxes != self.ax or event.xdata is None or event.ydata is None:
return
if self._state == 'creating':
self._update_temp_line((event.xdata, event.ydata))
elif self._state == 'editing' and self._active_role:
self._drag_edit(event)
[docs]
def _on_release(self, event: object) -> None:
"""
Handle mouse release events.
:param event: Mouse release event data
:type event: matplotlib.backend_bases.MouseEvent
"""
if not isinstance(event, MouseEvent):
return
if event.inaxes != self.ax or event.xdata is None or event.ydata is None:
return
self._active_role = None
self._last_xy = None
[docs]
def _on_pick(self, event: object) -> None:
"""
Handle pick events (clicking on handles).
:param event: Pick event data
:type event: matplotlib.backend_bases.PickEvent
"""
if not isinstance(event, PickEvent):
return
if self._state != 'editing':
return
artist = event.artist
role = getattr(artist, '_as_role', None)
if role:
self._active_role = role
[docs]
def _on_key_press(self, event: object) -> None:
"""
Handle key press events.
:param event: Key event data
:type event: matplotlib.backend_bases.KeyEvent
"""
if not isinstance(event, KeyEvent):
return
if event.key is None:
return
key = event.key.lower()
if key in ('control', 'ctrl'):
self._ctrl_pressed = True
return
if key == 'escape':
self.clear()
return
if key in ('enter', 'return'):
self._on_accept()
return
[docs]
def _on_key_release(self, event: object) -> None:
"""
Handle key release events.
:param event: Key event data
:type event: matplotlib.backend_bases.KeyEvent
"""
if not isinstance(event, KeyEvent):
return
if event.key is None:
return
if event.key.lower() in ('control', 'ctrl'):
self._ctrl_pressed = False
[docs]
def _on_accept(self) -> None:
"""
Accept the current selection and emit the corresponding signal.
"""
self.selector_accepted.emit()
self._restore_focus()
# ------------------------------------------------------------------
# Creation and editing logic
# ------------------------------------------------------------------
def _update_temp_line(self, target: tuple[float, float]) -> None:
if not self._points:
return
start = self._points[-1]
if self._ctrl_pressed and len(self._points) == 2:
start = self._points[1]
target = snap_to_angle(start, target, self.snap_deg)
elif self._ctrl_pressed and len(self._points) == 1:
target = snap_to_angle(start, target, self.snap_deg)
if self._temp_line is None:
self._temp_line = Line2D(
[start[0], target[0]],
[start[1], target[1]],
linewidth=self.linewidth,
color=self.color,
linestyle='--',
zorder=10,
)
self.ax.add_line(self._temp_line)
else:
self._temp_line.set_data([start[0], target[0]], [start[1], target[1]])
self.canvas.draw_idle() # type: ignore[no-untyped-call]
def _finalize_creation(self) -> None:
if len(self._points) != 3:
return
if self._temp_line is not None:
try:
self._temp_line.remove()
except ValueError:
pass
self._temp_line = None
if self._line1 is not None:
try:
self._line1.remove()
except ValueError:
pass
self._line1 = None
p1, p2, p3 = self._points
self._geometry = AngleGeometry(p1, p2, p3)
self._create_artists(self._geometry)
self._state = 'editing'
self.selector_created.emit()
self.canvas.draw_idle() # type: ignore[no-untyped-call]
def _drag_edit(self, event: MouseEvent) -> None:
if self._geometry is None or self._last_xy is None:
return
if event.xdata is None or event.ydata is None:
return
x, y = event.xdata, event.ydata
p1, p2, p3 = self._geometry.as_points()
if self._active_role == 'p1':
if self._ctrl_pressed:
x, y = snap_to_angle(p2, (x, y), self.snap_deg)
p1 = (x, y)
elif self._active_role == 'p3':
if self._ctrl_pressed:
x, y = snap_to_angle(p2, (x, y), self.snap_deg)
p3 = (x, y)
elif self._active_role == 'p2':
dx = x - self._last_xy[0]
dy = y - self._last_xy[1]
p1 = (p1[0] + dx, p1[1] + dy)
p2 = (p2[0] + dx, p2[1] + dy)
p3 = (p3[0] + dx, p3[1] + dy)
else:
return
self._geometry = AngleGeometry(p1, p2, p3)
self._update_artists(self._geometry)
self._last_xy = (x, y)
self.canvas.draw_idle() # type: ignore[no-untyped-call]
# ------------------------------------------------------------------
# Artist management
# ------------------------------------------------------------------
def _create_artists(self, geom: AngleGeometry) -> None:
p1, p2, p3 = geom.as_points()
self._line1 = Line2D([p2[0], p1[0]], [p2[1], p1[1]], linewidth=self.linewidth, color=self.color, zorder=10)
self._line2 = Line2D([p2[0], p3[0]], [p2[1], p3[1]], linewidth=self.linewidth, color=self.color, zorder=10)
self.ax.add_line(self._line1)
self.ax.add_line(self._line2)
self._h1 = self._make_handle(p1, 'p1')
self._h2 = self._make_handle(p2, 'p2')
self._h3 = self._make_handle(p3, 'p3')
def _update_artists(self, geom: AngleGeometry) -> None:
p1, p2, p3 = geom.as_points()
if self._line1:
self._line1.set_data([p2[0], p1[0]], [p2[1], p1[1]])
if self._line2:
self._line2.set_data([p2[0], p3[0]], [p2[1], p3[1]])
if self._h1:
self._h1.set_data([p1[0]], [p1[1]])
if self._h2:
self._h2.set_data([p2[0]], [p2[1]])
if self._h3:
self._h3.set_data([p3[0]], [p3[1]])
def _make_handle(self, xy: tuple[float, float], role: str) -> Line2D:
h = Line2D(
[xy[0]],
[xy[1]],
marker='o',
markersize=self.handle_size,
markerfacecolor='white',
markeredgecolor='black',
markeredgewidth=1.5,
linestyle='None',
picker=self.picker_tol,
zorder=10,
)
setattr(h, '_as_role', role)
self.ax.add_line(h)
return h
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
@property
def geometry(self) -> Optional[AngleGeometry]:
"""
Get the current angle geometry.
:return: Current angle geometry or None if not set
:rtype: Optional[AngleGeometry]
"""
return self._geometry
[docs]
def clear(self) -> None:
"""
Clear the selector and reset to idle state.
"""
for art in (self._line1, self._line2, self._h1, self._h2, self._h3, self._temp_line):
if art:
try:
art.remove()
except ValueError:
pass
self._geometry = None
self._points = []
self._state = 'idle'
self.canvas.draw_idle() # type: ignore[no-untyped-call]
self.selector_canceled.emit()
[docs]
def disconnect_signals(self) -> None:
"""
Disconnect all event signals and restore focus.
"""
self.canvas.mpl_disconnect(self._cid_press)
self.canvas.mpl_disconnect(self._cid_motion)
self.canvas.mpl_disconnect(self._cid_release)
self.canvas.mpl_disconnect(self._cid_pick)
self.canvas.mpl_disconnect(self._cid_key_press)
self.canvas.mpl_disconnect(self._cid_key_release)
self._restore_focus()