Source code for radioviz.geometry.angle_selector

#  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]
[docs] def _promote_first_segment(self) -> None: """ Keep the first segment visible after the second click. """ if len(self._points) != 2: return if self._temp_line is not None: try: self._temp_line.remove() except ValueError: pass self._temp_line = None p1, p2 = self._points if self._line1 is None: self._line1 = Line2D( [p1[0], p2[0]], [p1[1], p2[1]], linewidth=self.linewidth, color=self.color, zorder=10, ) self.ax.add_line(self._line1) else: self._line1.set_data([p1[0], p2[0]], [p1[1], p2[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()