"""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._plugin_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 dataclasses import dataclass, field
from typing import TYPE_CHECKING
_logger = logging.getLogger(__name__)
import numpy as np
from .abc import AbstractCompanionModel, AbstractUICompanion, ActionSpec
from .types import Keys
from .._viewer_plugin_handlers import MouseContext, KeyboardSnapshot
if TYPE_CHECKING:
from ..PyDraw import WolfMapViewer
__all__ = [
'PointPickerCompanion',
'PolylineCompanion',
'MultiPolylineCompanion',
'MultiPolylineZonesCompanion',
'PolygonCompanion',
'point_picker',
'polyline',
'multi_polyline',
'multi_polyline_zones',
'polygon',
]
@dataclass
class PointPickerModel(AbstractCompanionModel):
points: list[tuple[float, float]] = field(default_factory=list)
selected: int = -1
def reset(self) -> None:
self.points.clear()
self.selected = -1
@dataclass
class PolylineModel(AbstractCompanionModel):
vertices: list[tuple[float, float]] = field(default_factory=list)
finished: bool = False
def reset(self) -> None:
self.vertices.clear()
self.finished = False
@dataclass
class MultiPolylineModel(AbstractCompanionModel):
polylines: list[list[tuple[float, float]]] = field(default_factory=list)
current: list[tuple[float, float]] = field(default_factory=list)
def reset(self) -> None:
self.polylines.clear()
self.current.clear()
@dataclass
class MultiPolylineZonesModel(AbstractCompanionModel):
zones: 'Zones | None' = None # type: ignore[name-defined]
current: list[tuple[float, float]] = field(default_factory=list)
def reset(self) -> None:
self.zones = None
self.current.clear()
@dataclass
class PolygonModel(AbstractCompanionModel):
polygons: list[list[tuple[float, float]]] = field(default_factory=list)
current: list[tuple[float, float]] = field(default_factory=list)
def reset(self) -> None:
self.polygons.clear()
self.current.clear()
# ---------------------------------------------------------------------------
# PointPickerCompanion
# ---------------------------------------------------------------------------
[docs]
class PointPickerCompanion(AbstractUICompanion):
"""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
[docs]
def create_model(self) -> PointPickerModel:
return PointPickerModel()
@property
[docs]
def points(self) -> list[tuple[float, float]]:
return self.model.points # type: ignore[union-attr]
@points.setter
def points(self, value: list[tuple[float, float]]) -> None:
self.model.points = value # type: ignore[union-attr]
@property
[docs]
def selected(self) -> int:
return self.model.selected # type: ignore[union-attr]
@selected.setter
def selected(self, value: int) -> None:
self.model.selected = value # type: ignore[union-attr]
[docs]
def actions_spec(self):
return [
ActionSpec(
'pick',
rdown=self._rdown,
ldown=self._ldown,
key=self._key,
paint=self._paint,
primary=True,
start_message='Right-click: add | Left-click: select nearest | Ctrl+Z: undo | Esc: stop',
),
]
[docs]
def start(self) -> None:
"""Activate the point-picker action."""
self.proxy.start_action(
'pick',
'Right-click: add | Left-click: select nearest | Ctrl+Z: undo | Esc: stop',
)
# -- handlers ------------------------------------------------------------
[docs]
def _rdown(self, ctx: MouseContext) -> None:
self.points.append((ctx.x_snap, ctx.y_snap))
self.selected = len(self.points) - 1
self.proxy.force_redraw()
[docs]
def _ldown(self, 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.proxy.force_redraw()
[docs]
def _key(self, kb: KeyboardSnapshot) -> bool:
if kb.key_code == Keys.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.proxy.force_redraw()
return True
return False
[docs]
def _paint(self) -> None:
self.proxy.draw_crosses(
self.points,
self.proxy.viewport_fraction(self.CROSS_FRACTION),
color=self.COLOR_NORMAL,
selected_idx=self.selected,
selected_color=self.COLOR_SELECTED,
)
# ---------------------------------------------------------------------------
# PolylineCompanion
# ---------------------------------------------------------------------------
[docs]
class PolylineCompanion(AbstractUICompanion):
"""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
[docs]
def create_model(self) -> PolylineModel:
return PolylineModel()
@property
[docs]
def vertices(self) -> list[tuple[float, float]]:
return self.model.vertices # type: ignore[union-attr]
@vertices.setter
def vertices(self, value: list[tuple[float, float]]) -> None:
self.model.vertices = value # type: ignore[union-attr]
@property
[docs]
def finished(self) -> bool:
return self.model.finished # type: ignore[union-attr]
@finished.setter
def finished(self, value: bool) -> None:
self.model.finished = value # type: ignore[union-attr]
[docs]
def actions_spec(self):
return [
ActionSpec(
'line',
rdown=self._rdown,
key=self._key,
paint=self._paint,
primary=True,
start_message='Right-click: add vertex | Enter: finalise (≥2 pts) | Esc: cancel',
),
]
[docs]
def start(self) -> None:
"""Reset state and activate the polyline-recording action."""
self.vertices.clear()
self.finished = False
self.proxy.start_action(
'line',
'Right-click: add vertex | Enter: finalise (≥2 pts) | Esc: cancel',
)
# -- handlers ------------------------------------------------------------
[docs]
def _rdown(self, ctx: MouseContext) -> None:
self.vertices.append((ctx.x_snap, ctx.y_snap))
self.proxy.force_redraw()
[docs]
def _key(self, kb: KeyboardSnapshot) -> bool:
if kb.key_code == Keys.RETURN:
if len(self.vertices) >= 2:
self.finished = True
else:
self.proxy.set_status('Need at least 2 vertices to finalise.')
self.stop()
return True
if kb.key_code == Keys.ESCAPE:
self.vertices.clear()
self.finished = False
self.stop()
return True
return False
[docs]
def _paint(self) -> None:
if len(self.vertices) >= 2:
self.proxy.draw_polyline(
self.vertices, self.COLOR_LINE, line_width=self.LINE_WIDTH,
)
half = self.proxy.viewport_fraction(self.CROSS_FRACTION)
self.proxy.draw_crosses(self.vertices, half, color=self.COLOR_VERTEX)
# ---------------------------------------------------------------------------
# MultiPolylineCompanion
# ---------------------------------------------------------------------------
[docs]
class MultiPolylineCompanion(AbstractUICompanion):
"""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
[docs]
def create_model(self) -> MultiPolylineModel:
return MultiPolylineModel()
@property
[docs]
def polylines(self) -> list[list[tuple[float, float]]]:
return self.model.polylines # type: ignore[union-attr]
@polylines.setter
def polylines(self, value: list[list[tuple[float, float]]]) -> None:
self.model.polylines = value # type: ignore[union-attr]
@property
[docs]
def current(self) -> list[tuple[float, float]]:
return self.model.current # type: ignore[union-attr]
@current.setter
def current(self, value: list[tuple[float, float]]) -> None:
self.model.current = value # type: ignore[union-attr]
[docs]
def actions_spec(self):
return [
ActionSpec(
'lines',
rdown=self._rdown,
key=self._key,
paint=self._paint,
primary=True,
start_message='Right-click: vertex | Enter: next line | Esc: finish session',
),
]
[docs]
def start(self) -> None:
"""Reset state and activate the multi-polyline recording action."""
self.polylines.clear()
self.current.clear()
self.proxy.start_action(
'lines',
'Right-click: vertex | Enter: next line | Esc: finish session',
)
# -- handlers ------------------------------------------------------------
[docs]
def _rdown(self, ctx: MouseContext) -> None:
self.current.append((ctx.x_snap, ctx.y_snap))
self.proxy.force_redraw()
[docs]
def _key(self, kb: KeyboardSnapshot) -> bool:
if kb.key_code == Keys.RETURN:
if len(self.current) >= 2:
self.polylines.append(list(self.current))
self.current.clear()
self.proxy.force_redraw()
n = len(self.polylines)
self.proxy.set_status(
f'Line {n} recorded — right-click to start line {n + 1} | Esc: finish'
)
else:
self.proxy.set_status('Need at least 2 vertices — keep clicking.')
return True
if kb.key_code == Keys.ESCAPE:
self.current.clear()
self.proxy.force_redraw()
self.stop()
return True
return False
[docs]
def _paint(self) -> None:
for line in self.polylines:
if len(line) >= 2:
self.proxy.draw_polyline(line, self.COLOR_DONE, line_width=self.LINE_WIDTH)
if len(self.current) >= 2:
self.proxy.draw_polyline(self.current, self.COLOR_CURRENT, line_width=self.LINE_WIDTH)
half = self.proxy.viewport_fraction(self.CROSS_FRACTION)
all_pts = [p for line in self.polylines for p in line] + self.current
if all_pts:
self.proxy.draw_crosses(all_pts, half, color=self.COLOR_VERTEX)
# ---------------------------------------------------------------------------
# MultiPolylineZonesCompanion
# ---------------------------------------------------------------------------
[docs]
class MultiPolylineZonesCompanion(AbstractUICompanion):
"""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
----------
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,
*,
zones_id: str = 'multi_polyline',
auto_attach: bool = True,
zone_name_prefix: str = 'zone',
) -> None:
[docs]
self._zones_id = zones_id
[docs]
self._auto_attach = auto_attach
[docs]
self._zone_name_prefix = zone_name_prefix
[docs]
def create_model(self) -> MultiPolylineZonesModel:
return MultiPolylineZonesModel()
@property
[docs]
def zones(self) -> 'Zones | None': # type: ignore[name-defined]
return self.model.zones # type: ignore[union-attr]
@zones.setter
def zones(self, value: 'Zones | None') -> None: # type: ignore[name-defined]
self.model.zones = value # type: ignore[union-attr]
@property
[docs]
def current(self) -> list[tuple[float, float]]:
return self.model.current # type: ignore[union-attr]
@current.setter
def current(self, value: list[tuple[float, float]]) -> None:
self.model.current = value # type: ignore[union-attr]
# -- AbstractUICompanion interface ---------------------------------------
[docs]
def actions_spec(self):
return [
ActionSpec(
'mplz',
rdown=self._rdown,
key=self._key,
paint=self._paint,
primary=True,
start_message='Right-click: vertex | Enter: accept line (≥2 pts) | Esc: stop',
),
]
[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()
self.proxy.start_action(
'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.proxy._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.proxy.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 ..pyvertexvectors import Zones
self.zones = Zones(idx=self._zones_id, mapviewer=self.proxy._viewer)
if self._auto_attach:
try:
self.proxy._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.proxy.force_redraw()
# -- event handlers ------------------------------------------------------
[docs]
def _rdown(self, ctx: MouseContext) -> None:
self.current.append((ctx.x_snap, ctx.y_snap))
self.proxy.force_redraw()
[docs]
def _key(self, kb: KeyboardSnapshot) -> bool:
if kb.key_code == Keys.RETURN:
if len(self.current) >= 2:
self._finalise_current()
n = self.zones.nbzones if self.zones is not None else 0
self.proxy.set_status(
f'Line {n} accepted — right-click to start line {n + 1} | Esc: stop'
)
else:
self.proxy.set_status('Need at least 2 vertices — keep clicking.')
return True
if kb.key_code == Keys.ESCAPE:
self.current.clear()
self.proxy.force_redraw()
self.stop()
return True
return False
[docs]
def _paint(self) -> 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.proxy.draw_polyline(
pts, self.COLOR_DONE, line_width=self.LINE_WIDTH,
)
# In-progress line
if len(self.current) >= 2:
self.proxy.draw_polyline(
self.current, self.COLOR_CURRENT, line_width=self.LINE_WIDTH,
)
# Vertex markers
half = self.proxy.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.proxy.draw_crosses(all_pts, half, color=self.COLOR_VERTEX)
# ---------------------------------------------------------------------------
# PolygonCompanion
# ---------------------------------------------------------------------------
[docs]
class PolygonCompanion(AbstractUICompanion):
"""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
[docs]
def create_model(self) -> PolygonModel:
return PolygonModel()
@property
[docs]
def polygons(self) -> list[list[tuple[float, float]]]:
return self.model.polygons # type: ignore[union-attr]
@polygons.setter
def polygons(self, value: list[list[tuple[float, float]]]) -> None:
self.model.polygons = value # type: ignore[union-attr]
@property
[docs]
def current(self) -> list[tuple[float, float]]:
return self.model.current # type: ignore[union-attr]
@current.setter
def current(self, value: list[tuple[float, float]]) -> None:
self.model.current = value # type: ignore[union-attr]
[docs]
def actions_spec(self):
return [
ActionSpec(
'poly',
rdown=self._rdown,
key=self._key,
paint=self._paint,
primary=True,
start_message='Right-click: vertex | Enter: close polygon (≥3 pts) | Esc: cancel current',
),
]
[docs]
def start(self) -> None:
"""Reset state and activate the polygon-recording action."""
self.polygons.clear()
self.current.clear()
self.proxy.start_action(
'poly',
'Right-click: vertex | Enter: close polygon (≥3 pts) | Esc: cancel current',
)
# -- handlers ------------------------------------------------------------
[docs]
def _rdown(self, ctx: MouseContext) -> None:
self.current.append((ctx.x_snap, ctx.y_snap))
self.proxy.force_redraw()
[docs]
def _key(self, kb: KeyboardSnapshot) -> bool:
if kb.key_code == Keys.RETURN:
if len(self.current) >= 3:
self.polygons.append(list(self.current))
self.current.clear()
self.proxy.force_redraw()
n = len(self.polygons)
self.proxy.set_status(
f'Polygon {n} closed — right-click to start polygon {n + 1} | Esc: stop'
)
else:
self.proxy.set_status('Need at least 3 vertices to close a polygon.')
return True
if kb.key_code == Keys.ESCAPE:
self.current.clear()
self.proxy.force_redraw()
return True
return False
[docs]
def _paint(self) -> None:
for poly in self.polygons:
if len(poly) >= 3:
self.proxy.draw_polyline(
poly, self.COLOR_DONE, closed=True, line_width=self.LINE_WIDTH,
)
if len(self.current) >= 2:
self.proxy.draw_polyline(self.current, self.COLOR_CURRENT, line_width=self.LINE_WIDTH)
half = self.proxy.viewport_fraction(self.CROSS_FRACTION)
all_pts = [p for poly in self.polygons for p in poly] + self.current
if all_pts:
self.proxy.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(**kwargs)
viewer.attach_companion(c)
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(**kwargs)
viewer.attach_companion(c)
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(**kwargs)
viewer.attach_companion(c)
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(zones_id=zones_id,
auto_attach=auto_attach, **kwargs)
viewer.attach_companion(c)
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(**kwargs)
viewer.attach_companion(c)
c.start()
return c