Source code for wolfhece._companion_factory

"""Pre-built companion classes for common interactive-selection patterns.

All four classes are ready to use as-is or to subclass.  Override the
``COLOR_*`` / ``*_FRACTION`` class attributes to change the visual style
without writing any method code.

Quick-start
-----------
::

    from wolfhece._companion_factory import point_picker, polyline, multi_polyline, polygon

    # One-liner: create + register + activate
    comp = point_picker(viewer)
    # … right-click on the map …
    print(comp.points)
    comp.destroy()

Interaction summary
-------------------

+-------------------------+------------------+-------------------+------------------+
| Companion               | Add vertex       | Finish / accept   | Cancel / stop    |
+=========================+==================+===================+==================+
| PointPickerCompanion    | Right-click      | (already done)    | Esc or stop()    |
+-------------------------+------------------+-------------------+------------------+
| PolylineCompanion       | Right-click      | Enter (≥ 2 pts)   | Esc (discard)    |
+-------------------------+------------------+-------------------+------------------+
| MultiPolylineCompanion  | Right-click      | Enter (≥ 2 pts)   | Esc stops action |
+-------------------------+------------------+-------------------+------------------+
| PolygonCompanion        | Right-click      | Enter (≥ 3 pts)   | Esc (discard)    |
+-------------------------+------------------+-------------------+------------------+

All companions:

* ``left-click`` selects the nearest already-placed vertex (PointPickerCompanion only)
* ``Ctrl+Z`` undoes the last vertex (PointPickerCompanion only)
* ``comp.stop()``  deactivates the action; collected data is preserved
* ``comp.destroy()``  deactivates + unregisters all handlers
"""
from __future__ import annotations

import logging
from typing import TYPE_CHECKING

_logger = logging.getLogger(__name__)

import numpy as np

from wolfhece._menu_companion_abc import AbstractCompanion
from wolfhece._viewer_plugin_handlers import MouseContext, KeyboardSnapshot

if TYPE_CHECKING:
    from wolfhece.PyDraw import WolfMapViewer

__all__ = [
    'PointPickerCompanion',
    'PolylineCompanion',
    'MultiPolylineCompanion',
    'MultiPolylineZonesCompanion',
    'PolygonCompanion',
    'point_picker',
    'polyline',
    'multi_polyline',
    'multi_polyline_zones',
    'polygon',
]

# wx key codes — numeric values avoid importing wx at module load time.
_WXK_RETURN = 13
_WXK_ESCAPE = 27


# ---------------------------------------------------------------------------
# PointPickerCompanion
# ---------------------------------------------------------------------------

[docs] class PointPickerCompanion(AbstractCompanion): """Collect isolated (x, y) points via right-click. Interaction ----------- * **Right-click** — add a point at the snapped cursor position * **Left-click** — select the nearest point (highlighted in gold) * **Ctrl+Z** — remove the last point * **Esc** — deactivate the action; collected points are preserved Attributes ---------- points : list[tuple[float, float]] Collected world-coordinate pairs (snapped). selected : int Index of the highlighted point (-1 = none). Customisation ------------- Override the class attributes to change colours/size without subclassing:: class MyPicker(PointPickerCompanion): COLOR_NORMAL = (0.0, 0.6, 1.0, 1.0) # blue COLOR_SELECTED = (1.0, 1.0, 0.0, 1.0) # yellow CROSS_FRACTION = 0.015 """ #: RGBA colour for unselected points.
[docs] COLOR_NORMAL: tuple = (1.0, 0.0, 0.0, 1.0)
#: RGBA colour for the selected point.
[docs] COLOR_SELECTED: tuple = (1.0, 0.8, 0.0, 1.0)
#: Cross arm-length as a fraction of viewport width.
[docs] CROSS_FRACTION: float = 0.01
def __init__(self, viewer: 'WolfMapViewer') -> None: super().__init__(viewer) #: Collected world-coordinate pairs.
[docs] self.points: list[tuple[float, float]] = []
#: Index of the currently highlighted point (-1 = none).
[docs] self.selected: int = -1
[docs] def start(self) -> None: """Activate the point-picker action.""" if self._action_id('pick') not in self._registered_action_ids: self._register_action( self._action_id('pick'), rdown=self._rdown, ldown=self._ldown, key=self._key, paint=self._paint, ) self._start_action( self._action_id('pick'), 'Right-click: add | Left-click: select nearest | Ctrl+Z: undo | Esc: stop', )
# -- handlers ------------------------------------------------------------
[docs] def _rdown(self, viewer, ctx: MouseContext) -> None: self.points.append((ctx.x_snap, ctx.y_snap)) self.selected = len(self.points) - 1 self._force_redraw()
[docs] def _ldown(self, viewer, ctx: MouseContext) -> None: if not self.points: return pts = np.array(self.points) dists = np.hypot(pts[:, 0] - ctx.x, pts[:, 1] - ctx.y) self.selected = int(np.argmin(dists)) self._force_redraw()
[docs] def _key(self, viewer, kb: KeyboardSnapshot) -> bool: if kb.key_code == _WXK_ESCAPE: self.stop() return True if kb.ctrl and kb.key_code == ord('Z'): if self.points: self.points.pop() if self.selected >= len(self.points): self.selected = len(self.points) - 1 self._force_redraw() return True return False
[docs] def _paint(self, viewer) -> None: self._draw_crosses( self.points, self._viewport_fraction(self.CROSS_FRACTION), color=self.COLOR_NORMAL, selected_idx=self.selected, selected_color=self.COLOR_SELECTED, )
# --------------------------------------------------------------------------- # PolylineCompanion # ---------------------------------------------------------------------------
[docs] class PolylineCompanion(AbstractCompanion): """Record a single polyline vertex by vertex. Interaction ----------- * **Right-click** — append a vertex * **Enter / Return** — finalise the polyline (requires ≥ 2 vertices) * **Esc** — discard all vertices and deactivate Attributes ---------- vertices : list[tuple[float, float]] Ordered vertices of the recorded polyline. Empty after a cancelled interaction, populated after a successful Enter. finished : bool ``True`` once Enter has been pressed with ≥ 2 vertices. Customisation ------------- Override ``COLOR_LINE``, ``COLOR_VERTEX``, ``CROSS_FRACTION``, ``LINE_WIDTH`` at the class level. """ #: RGBA colour for the line segments.
[docs] COLOR_LINE: tuple = (0.0, 0.5, 1.0, 1.0)
#: RGBA colour for the vertex markers.
[docs] COLOR_VERTEX: tuple = (1.0, 1.0, 1.0, 1.0)
#: Vertex cross size as a fraction of viewport width.
[docs] CROSS_FRACTION: float = 0.008
#: OpenGL line width in pixels.
[docs] LINE_WIDTH: float = 2.0
def __init__(self, viewer: 'WolfMapViewer') -> None: super().__init__(viewer) #: Ordered vertices of the polyline being collected.
[docs] self.vertices: list[tuple[float, float]] = []
#: ``True`` after the user has pressed Enter to finalise.
[docs] self.finished: bool = False
[docs] def start(self) -> None: """Reset state and activate the polyline-recording action.""" self.vertices.clear() self.finished = False if self._action_id('line') not in self._registered_action_ids: self._register_action( self._action_id('line'), rdown=self._rdown, key=self._key, paint=self._paint, ) self._start_action( self._action_id('line'), 'Right-click: add vertex | Enter: finalise (≥2 pts) | Esc: cancel', )
# -- handlers ------------------------------------------------------------
[docs] def _rdown(self, viewer, ctx: MouseContext) -> None: self.vertices.append((ctx.x_snap, ctx.y_snap)) self._force_redraw()
[docs] def _key(self, viewer, kb: KeyboardSnapshot) -> bool: if kb.key_code == _WXK_RETURN: if len(self.vertices) >= 2: self.finished = True else: self._set_status('Need at least 2 vertices to finalise.') self.stop() return True if kb.key_code == _WXK_ESCAPE: self.vertices.clear() self.finished = False self.stop() return True return False
[docs] def _paint(self, viewer) -> None: if len(self.vertices) >= 2: self._draw_polyline( self.vertices, self.COLOR_LINE, line_width=self.LINE_WIDTH, ) half = self._viewport_fraction(self.CROSS_FRACTION) self._draw_crosses(self.vertices, half, color=self.COLOR_VERTEX)
# --------------------------------------------------------------------------- # MultiPolylineCompanion # ---------------------------------------------------------------------------
[docs] class MultiPolylineCompanion(AbstractCompanion): """Record multiple polylines in a single session. Interaction ----------- * **Right-click** — append a vertex to the current in-progress line * **Enter / Return** — finalise the current line (≥ 2 vertices) and start a new one * **Esc** — discard the current in-progress line and deactivate; already-finalised lines are preserved Attributes ---------- polylines : list[list[tuple[float, float]]] All finalised polylines. current : list[tuple[float, float]] Vertices of the line currently being drawn. """ #: RGBA colour for finalised lines.
[docs] COLOR_DONE: tuple = (0.2, 0.6, 1.0, 1.0)
#: RGBA colour for the in-progress line.
[docs] COLOR_CURRENT: tuple = (1.0, 0.5, 0.0, 1.0)
#: RGBA colour for vertex markers.
[docs] COLOR_VERTEX: tuple = (1.0, 1.0, 1.0, 0.8)
#: Vertex cross size as a fraction of viewport width.
[docs] CROSS_FRACTION: float = 0.007
#: OpenGL line width in pixels.
[docs] LINE_WIDTH: float = 1.5
def __init__(self, viewer: 'WolfMapViewer') -> None: super().__init__(viewer) #: All finalised polylines.
[docs] self.polylines: list[list[tuple[float, float]]] = []
#: Vertices of the line currently being built.
[docs] self.current: list[tuple[float, float]] = []
[docs] def start(self) -> None: """Reset state and activate the multi-polyline recording action.""" self.polylines.clear() self.current.clear() if self._action_id('lines') not in self._registered_action_ids: self._register_action( self._action_id('lines'), rdown=self._rdown, key=self._key, paint=self._paint, ) self._start_action( self._action_id('lines'), 'Right-click: vertex | Enter: next line | Esc: finish session', )
# -- handlers ------------------------------------------------------------
[docs] def _rdown(self, viewer, ctx: MouseContext) -> None: self.current.append((ctx.x_snap, ctx.y_snap)) self._force_redraw()
[docs] def _key(self, viewer, kb: KeyboardSnapshot) -> bool: if kb.key_code == _WXK_RETURN: if len(self.current) >= 2: self.polylines.append(list(self.current)) self.current.clear() self._force_redraw() n = len(self.polylines) self._set_status( f'Line {n} recorded — right-click to start line {n + 1} | Esc: finish' ) else: self._set_status('Need at least 2 vertices — keep clicking.') return True if kb.key_code == _WXK_ESCAPE: self.current.clear() self._force_redraw() self.stop() return True return False
[docs] def _paint(self, viewer) -> None: for line in self.polylines: if len(line) >= 2: self._draw_polyline(line, self.COLOR_DONE, line_width=self.LINE_WIDTH) if len(self.current) >= 2: self._draw_polyline(self.current, self.COLOR_CURRENT, line_width=self.LINE_WIDTH) half = self._viewport_fraction(self.CROSS_FRACTION) all_pts = [p for line in self.polylines for p in line] + self.current if all_pts: self._draw_crosses(all_pts, half, color=self.COLOR_VERTEX)
# --------------------------------------------------------------------------- # MultiPolylineZonesCompanion # ---------------------------------------------------------------------------
[docs] class MultiPolylineZonesCompanion(AbstractCompanion): """Record multiple polylines and store them in a :class:`~wolfhece.pyvertexvectors.Zones` object. Behaves like :class:`MultiPolylineCompanion` for the interaction, but instead of keeping the results in a plain Python list each accepted polyline is immediately written into a ``Zones`` → ``zone`` → ``vector`` hierarchy and — when *auto_attach* is enabled — added to the viewer so that it appears in the layers panel and can be exported. Interaction ----------- * **Right-click** — append a vertex to the current in-progress line * **Enter / Return** — finalise the current line (≥ 2 vertices); it is added to the :attr:`zones` object as a new ``zone`` * **Esc** — discard the current in-progress vertices and stop the action; already-finalised lines in :attr:`zones` are preserved Attributes ---------- zones : Zones | None The backing :class:`~wolfhece.pyvertexvectors.Zones` object. ``None`` until the first line is accepted. Once created it is the same object that is attached to the viewer (when *auto_attach* is ``True``). current : list[tuple[float, float]] Vertices of the line currently being digitised. Parameters ---------- viewer : The map viewer. zones_id : str Identifier string for the :class:`~wolfhece.pyvertexvectors.Zones` object. Defaults to ``'multi_polyline'``. auto_attach : bool When ``True`` (default) the :class:`~wolfhece.pyvertexvectors.Zones` object is added to the viewer via ``viewer.add_object('vector', …)`` on the first accepted polyline. zone_name_prefix : str Prefix for the generated zone names (``zone_001``, ``zone_002``, …). Customisation ------------- Override the class attributes to change colours/line width:: class MyZonesCompanion(MultiPolylineZonesCompanion): COLOR_DONE = (0.9, 0.2, 0.2, 1.0) COLOR_CURRENT = (1.0, 0.8, 0.0, 1.0) """ #: RGBA colour for lines that have already been accepted into the Zones.
[docs] COLOR_DONE: tuple = (0.2, 0.6, 1.0, 1.0)
#: RGBA colour for the line currently being digitised.
[docs] COLOR_CURRENT: tuple = (1.0, 0.5, 0.0, 1.0)
#: RGBA colour for vertex cross markers.
[docs] COLOR_VERTEX: tuple = (1.0, 1.0, 1.0, 0.8)
#: Vertex cross size as a fraction of viewport width.
[docs] CROSS_FRACTION: float = 0.007
#: OpenGL line width in pixels.
[docs] LINE_WIDTH: float = 1.5
def __init__( self, viewer: 'WolfMapViewer', *, zones_id: str = 'multi_polyline', auto_attach: bool = True, zone_name_prefix: str = 'zone', ) -> None: super().__init__(viewer)
[docs] self._zones_id = zones_id
[docs] self._auto_attach = auto_attach
[docs] self._zone_name_prefix = zone_name_prefix
#: Backing Zones object (None until the first line is accepted).
[docs] self.zones: 'Zones | None' = None # type: ignore[name-defined]
#: Vertices of the line currently being digitised.
[docs] self.current: list[tuple[float, float]] = []
# -- AbstractCompanion interface -----------------------------------------
[docs] def start(self) -> None: """Activate the multi-polyline action. Only *current* is cleared; any already-accepted lines in :attr:`zones` are preserved so that the session can be resumed. """ self.current.clear() if self._action_id('mplz') not in self._registered_action_ids: self._register_action( self._action_id('mplz'), rdown=self._rdown, key=self._key, paint=self._paint, ) self._start_action( self._action_id('mplz'), 'Right-click: vertex | Enter: accept line (≥2 pts) | Esc: stop', )
# -- public API ----------------------------------------------------------
[docs] def attach_zones(self, id: str | None = None) -> 'Zones | None': # type: ignore[name-defined] """Manually add :attr:`zones` to the viewer. This is a no-op when *auto_attach* is ``True`` (attachment already happened) or when :attr:`zones` is ``None`` (no line accepted yet). :param id: Override the id used when registering with the viewer (defaults to the *zones_id* given at construction time). :return: The :class:`~wolfhece.pyvertexvectors.Zones` object, or ``None`` if nothing has been digitised yet. """ if self.zones is None: return None oid = id if id is not None else self._zones_id try: self._viewer.add_object('vector', newobj=self.zones, ToCheck=True, id=oid) except Exception as exc: _logger.warning("attach_zones: could not add object to viewer: %s", exc) return self.zones
[docs] def clear_zones(self) -> None: """Discard all accepted lines and reset :attr:`zones` to ``None``. This does *not* remove the object from the viewer if it was already attached — call ``viewer.get_obj_from_id(…)`` and remove it manually if needed. """ self.zones = None self.current.clear() self._force_redraw()
# -- private helpers -----------------------------------------------------
[docs] def _ensure_zones(self) -> 'Zones': # type: ignore[name-defined] """Return the Zones object, creating (and optionally attaching) it on the first call.""" if self.zones is None: from wolfhece.pyvertexvectors import Zones self.zones = Zones(idx=self._zones_id, mapviewer=self._viewer) if self._auto_attach: try: self._viewer.add_object( 'vector', newobj=self.zones, ToCheck=True, id=self._zones_id, ) except Exception as exc: _logger.warning( "_ensure_zones: could not attach Zones to viewer: %s", exc ) return self.zones
[docs] def _finalise_current(self) -> None: """Move *current* vertices into a new zone+vector inside :attr:`zones`.""" if len(self.current) < 2: return zones_obj = self._ensure_zones() n = zones_obj.nbzones + 1 zone_name = f'{self._zone_name_prefix}_{n:03d}' z = zones_obj._make_zone(name=zone_name, parent=zones_obj) v = zones_obj._make_vector( name=f'line_{n:03d}', parentzone=z, fromlist=list(self.current), ) z.add_vector(v, forceparent=True) zones_obj.add_zone(z, forceparent=True) try: zones_obj.find_minmax(update=True) except Exception: pass self.current.clear() self._force_redraw()
# -- event handlers ------------------------------------------------------
[docs] def _rdown(self, viewer, ctx: MouseContext) -> None: self.current.append((ctx.x_snap, ctx.y_snap)) self._force_redraw()
[docs] def _key(self, viewer, kb: KeyboardSnapshot) -> bool: if kb.key_code == _WXK_RETURN: if len(self.current) >= 2: self._finalise_current() n = self.zones.nbzones if self.zones is not None else 0 self._set_status( f'Line {n} accepted — right-click to start line {n + 1} | Esc: stop' ) else: self._set_status('Need at least 2 vertices — keep clicking.') return True if kb.key_code == _WXK_ESCAPE: self.current.clear() self._force_redraw() self.stop() return True return False
[docs] def _paint(self, viewer) -> None: # Already-accepted lines — draw from the Zones object so the overlay # stays in sync even if the viewer hasn't refreshed its own layer yet. if self.zones is not None: for z in self.zones.myzones: for v in z.myvectors: pts = [(vert.x, vert.y) for vert in v.myvertices] if len(pts) >= 2: self._draw_polyline( pts, self.COLOR_DONE, line_width=self.LINE_WIDTH, ) # In-progress line if len(self.current) >= 2: self._draw_polyline( self.current, self.COLOR_CURRENT, line_width=self.LINE_WIDTH, ) # Vertex markers half = self._viewport_fraction(self.CROSS_FRACTION) accepted_pts = [ (vert.x, vert.y) for z in (self.zones.myzones if self.zones is not None else []) for v in z.myvectors for vert in v.myvertices ] all_pts = accepted_pts + self.current if all_pts: self._draw_crosses(all_pts, half, color=self.COLOR_VERTEX)
# --------------------------------------------------------------------------- # PolygonCompanion # ---------------------------------------------------------------------------
[docs] class PolygonCompanion(AbstractCompanion): """Record one or more closed polygons. Interaction ----------- * **Right-click** — append a vertex to the current polygon * **Enter / Return** — close and finalise the current polygon (≥ 3 vertices) * **Esc** — discard the current in-progress polygon; finalised polygons are kept Attributes ---------- polygons : list[list[tuple[float, float]]] All finalised polygons. The closing segment (last → first vertex) is implicit — it is drawn but *not* stored as a duplicate vertex. current : list[tuple[float, float]] Vertices of the polygon currently being drawn. """ #: RGBA colour for finalised (closed) polygons.
[docs] COLOR_DONE: tuple = (0.0, 0.8, 0.2, 1.0)
#: RGBA colour for the in-progress polygon.
[docs] COLOR_CURRENT: tuple = (1.0, 0.5, 0.0, 1.0)
#: RGBA colour for vertex markers.
[docs] COLOR_VERTEX: tuple = (1.0, 1.0, 1.0, 0.8)
#: Vertex cross size as a fraction of viewport width.
[docs] CROSS_FRACTION: float = 0.007
#: OpenGL line width in pixels.
[docs] LINE_WIDTH: float = 1.5
def __init__(self, viewer: 'WolfMapViewer') -> None: super().__init__(viewer) #: All finalised polygons (closing segment is implicit).
[docs] self.polygons: list[list[tuple[float, float]]] = []
#: Vertices of the polygon currently being built.
[docs] self.current: list[tuple[float, float]] = []
[docs] def start(self) -> None: """Reset state and activate the polygon-recording action.""" self.polygons.clear() self.current.clear() if self._action_id('poly') not in self._registered_action_ids: self._register_action( self._action_id('poly'), rdown=self._rdown, key=self._key, paint=self._paint, ) self._start_action( self._action_id('poly'), 'Right-click: vertex | Enter: close polygon (≥3 pts) | Esc: cancel current', )
# -- handlers ------------------------------------------------------------
[docs] def _rdown(self, viewer, ctx: MouseContext) -> None: self.current.append((ctx.x_snap, ctx.y_snap)) self._force_redraw()
[docs] def _key(self, viewer, kb: KeyboardSnapshot) -> bool: if kb.key_code == _WXK_RETURN: if len(self.current) >= 3: self.polygons.append(list(self.current)) self.current.clear() self._force_redraw() n = len(self.polygons) self._set_status( f'Polygon {n} closed — right-click to start polygon {n + 1} | Esc: stop' ) else: self._set_status('Need at least 3 vertices to close a polygon.') return True if kb.key_code == _WXK_ESCAPE: self.current.clear() self._force_redraw() return True return False
[docs] def _paint(self, viewer) -> None: for poly in self.polygons: if len(poly) >= 3: self._draw_polyline( poly, self.COLOR_DONE, closed=True, line_width=self.LINE_WIDTH, ) if len(self.current) >= 2: self._draw_polyline(self.current, self.COLOR_CURRENT, line_width=self.LINE_WIDTH) half = self._viewport_fraction(self.CROSS_FRACTION) all_pts = [p for poly in self.polygons for p in poly] + self.current if all_pts: self._draw_crosses(all_pts, half, color=self.COLOR_VERTEX)
# --------------------------------------------------------------------------- # Convenience factory functions # ---------------------------------------------------------------------------
[docs] def point_picker(viewer: 'WolfMapViewer', **kwargs) -> PointPickerCompanion: """Create, register and activate a :class:`PointPickerCompanion`. :param viewer: The :class:`~wolfhece.PyDraw.WolfMapViewer` instance. :param kwargs: Forwarded to :class:`PointPickerCompanion` constructor (e.g. ``namespace=``, ``dialogs=``). :return: The activated companion. """ c = PointPickerCompanion(viewer, **kwargs) c.start() return c
[docs] def polyline(viewer: 'WolfMapViewer', **kwargs) -> PolylineCompanion: """Create, register and activate a :class:`PolylineCompanion`. :param viewer: The :class:`~wolfhece.PyDraw.WolfMapViewer` instance. :param kwargs: Forwarded to the constructor. :return: The activated companion. """ c = PolylineCompanion(viewer, **kwargs) c.start() return c
[docs] def multi_polyline(viewer: 'WolfMapViewer', **kwargs) -> MultiPolylineCompanion: """Create, register and activate a :class:`MultiPolylineCompanion`. :param viewer: The :class:`~wolfhece.PyDraw.WolfMapViewer` instance. :param kwargs: Forwarded to the constructor. :return: The activated companion. """ c = MultiPolylineCompanion(viewer, **kwargs) c.start() return c
[docs] def multi_polyline_zones( viewer: 'WolfMapViewer', zones_id: str = 'multi_polyline', auto_attach: bool = True, **kwargs, ) -> 'MultiPolylineZonesCompanion': """Create, register and activate a :class:`MultiPolylineZonesCompanion`. :param viewer: The :class:`~wolfhece.PyDraw.WolfMapViewer` instance. :param zones_id: Identifier of the :class:`~wolfhece.pyvertexvectors.Zones` object that will be added to the viewer. :param auto_attach: When ``True`` (default) the :class:`~wolfhece.pyvertexvectors.Zones` object is added to the viewer automatically on the first accepted polyline. :param kwargs: Forwarded to the constructor. :return: The activated companion. """ c = MultiPolylineZonesCompanion(viewer, zones_id=zones_id, auto_attach=auto_attach, **kwargs) c.start() return c
[docs] def polygon(viewer: 'WolfMapViewer', **kwargs) -> PolygonCompanion: """Create, register and activate a :class:`PolygonCompanion`. :param viewer: The :class:`~wolfhece.PyDraw.WolfMapViewer` instance. :param kwargs: Forwarded to the constructor. :return: The activated companion. """ c = PolygonCompanion(viewer, **kwargs) c.start() return c