Source code for sec_interp.gui.tools.interpretation_tool
from __future__ import annotations
"""Interpretation tool for Profile View.
This module provides the ProfileInterpretationTool for drawing
interpretation polygons in the profile preview window.
"""
import contextlib
import datetime
import random
import uuid
from qgis.core import (
QgsMapLayer,
QgsPointLocator,
QgsPointXY,
QgsProject,
QgsVectorLayer,
QgsWkbTypes,
)
from qgis.gui import (
QgsMapCanvas,
QgsMapToolEmitPoint,
QgsRubberBand,
QgsVertexMarker,
)
from qgis.PyQt.QtCore import QCoreApplication, QPoint, Qt, pyqtSignal
from qgis.PyQt.QtGui import QColor
from sec_interp.core.types import InterpretationPolygon
from sec_interp.logger_config import get_logger, log_critical_operation
logger = get_logger(__name__)
[docs]
class ProfileSnapper:
"""Helper class to handle point snapping functionality.
Duplicates logic from ProfileMeasureTool to avoid tight coupling.
"""
def __init__(self, canvas: QgsMapCanvas):
self.canvas = canvas
self._locators: dict[str, QgsPointLocator] = {}
[docs]
def snap(self, mouse_pos: QPoint) -> QgsPointXY:
"""Find the nearest vertex or edge to the mouse position."""
point = self.canvas.getCoordinateTransform().toMapCoordinates(mouse_pos)
# Search tolerance in map units (approx 12 pixels)
tolerance = (self.canvas.mapUnitsPerPixel() or 1.0) * 12
best_match = None
best_dist = float("inf")
layers = self.canvas.layers()
current_layer_ids = {layer.id() for layer in layers if layer is not None}
self._cleanup_locators(current_layer_ids)
crs = self.canvas.mapSettings().destinationCrs()
context = QgsProject.instance().transformContext()
for layer in layers:
if not self._is_snappable(layer):
continue
try:
locator = self._get_locator(layer, crs, context)
if not locator:
continue
# Try vertex snap
v_match = locator.nearestVertex(point, tolerance)
if v_match.isValid() and v_match.distance() < best_dist:
best_match = v_match
best_dist = v_match.distance()
# Try edge snap
e_match = locator.nearestEdge(point, tolerance)
if e_match.isValid() and e_match.distance() < best_dist:
best_match = e_match
best_dist = e_match.distance()
except Exception:
# If layer was deleted or something went wrong with locator
continue
if best_match:
return best_match.point()
return point
def _cleanup_locators(self, current_ids: set[str]):
"""Remove locators for layers that are no longer active."""
hits_to_remove = [lid for lid in self._locators if lid not in current_ids]
for lid in hits_to_remove:
del self._locators[lid]
def _is_snappable(self, layer: QgsMapLayer) -> bool:
"""Check if a layer is valid for snapping."""
return bool(layer and layer.type() == QgsMapLayer.VectorLayer)
def _get_locator(self, layer: QgsVectorLayer, crs, context) -> QgsPointLocator | None:
"""Retrieve or create a locator for a layer."""
if layer.id() not in self._locators:
try:
self._locators[layer.id()] = QgsPointLocator(layer, crs, context)
except Exception as e:
logger.warning(f"Failed to create locator for layer {layer.name()}: {e}")
return None
return self._locators[layer.id()]
[docs]
class ProfileInterpretationTool(QgsMapToolEmitPoint):
"""Tool for drawing interpretation polygons on the profile canvas.
- Left Click: Add vertex
- Move: Update preview rubber band
- Right Click: Remove last vertex
- Enter/Double Click: Finalize polygon
- Escape: Cancel
"""
polygonFinished = pyqtSignal(InterpretationPolygon)
def __init__(self, canvas: QgsMapCanvas):
super().__init__(canvas)
self.canvas = canvas
self.points: list[QgsPointXY] = []
self.rubber_band: QgsRubberBand | None = None
self.vertex_markers: list[QgsVertexMarker] = []
self.snapper = ProfileSnapper(canvas)
self.cursor = Qt.CrossCursor
[docs]
def activate(self) -> None:
"""Activate the interpretation tool."""
log_critical_operation(logger, "activate_interpretation_tool")
logger.debug("ProfileInterpretationTool.activate() called")
super().activate()
self.canvas.setCursor(self.cursor)
logger.debug("ProfileInterpretationTool activated successfully")
[docs]
def deactivate(self) -> None:
"""Deactivate the interpretation tool."""
log_critical_operation(logger, "deactivate_interpretation_tool")
logger.debug("ProfileInterpretationTool.deactivate() called")
self.reset()
super().deactivate()
logger.debug("ProfileInterpretationTool deactivated successfully")
[docs]
def reset(self):
"""Reset the tool state safely."""
log_critical_operation(
logger,
"reset_interpretation_tool",
points=len(self.points) if self.points else 0,
)
logger.debug(
f"ProfileInterpretationTool.reset() called - {len(self.points)} points, {len(self.vertex_markers)} markers"
)
self.points = []
# Rubber band cleanup
if self.rubber_band:
with contextlib.suppress(Exception):
self.rubber_band.reset(QgsWkbTypes.PolygonGeometry)
self.canvas.scene().removeItem(self.rubber_band)
self.rubber_band = None
# Markers cleanup
for marker in self.vertex_markers:
with contextlib.suppress(Exception):
self.canvas.scene().removeItem(marker)
self.vertex_markers = []
self.is_drawing = False
if self.canvas:
logger.debug("ProfileInterpretationTool.reset() - refreshing canvas")
self.canvas.refresh()
logger.debug("ProfileInterpretationTool.reset() completed")
[docs]
def canvasReleaseEvent(self, event: Any) -> None:
"""Handle mouse click release.
Args:
event: Map tool event from QGIS
"""
if event.button() == Qt.RightButton:
if self.points:
self._remove_last_point()
return
snapped_point = self.snapper.snap(event.pos())
self._add_point(snapped_point)
[docs]
def canvasMoveEvent(self, event: Any) -> None:
"""Handle mouse move for rubber band update.
Args:
event: Map tool event from QGIS
"""
if not self.points:
return
current_point = self.snapper.snap(event.pos())
self._update_rubber_band(current_point)
[docs]
def canvasDoubleClickEvent(self, event: Any) -> None:
"""Finalize polygon on double click.
Args:
event: Map tool event from QGIS
"""
if len(self.points) >= 3:
self.finalize_polygon()
[docs]
def keyPressEvent(self, event: Any) -> None:
"""Handle keyboard events.
Args:
event: Key event from QGIS
"""
if event.key() in (Qt.Key_Return, Qt.Key_Enter):
if len(self.points) >= 3:
self.finalize_polygon()
event.accept()
return
if event.key() == Qt.Key_Escape:
self.reset()
event.accept()
return
super().keyPressEvent(event)
def _add_point(self, point: QgsPointXY):
"""Add a vertex to the current polygon."""
# Prevent adding the exact same point twice in a row (e.g. slow click)
if self.points and self.points[-1].compare(point, 1e-6):
return
self.points.append(point)
self._ensure_rubber_band()
self.rubber_band.addPoint(point, True)
self._add_vertex_marker(point)
def _remove_last_point(self):
"""Remove the last added vertex."""
if not self.points:
return
self.points.pop()
if self.vertex_markers:
marker = self.vertex_markers.pop()
self.canvas.scene().removeItem(marker)
if not self.points:
if self.rubber_band:
self.canvas.scene().removeItem(self.rubber_band)
self.rubber_band = None
else:
self.rubber_band.reset(QgsWkbTypes.PolygonGeometry)
for p in self.points:
self.rubber_band.addPoint(p, False)
def _add_vertex_marker(self, point: QgsPointXY):
"""Add a visual marker for a vertex."""
marker = QgsVertexMarker(self.canvas)
marker.setCenter(point)
marker.setColor(QColor(255, 165, 0)) # Orange
marker.setIconSize(10)
marker.setIconType(QgsVertexMarker.ICON_X)
marker.setPenWidth(2)
self.vertex_markers.append(marker)
def _ensure_rubber_band(self):
"""Ensure the rubber band exists."""
if self.rubber_band:
return
self.rubber_band = QgsRubberBand(self.canvas, QgsWkbTypes.PolygonGeometry)
color = QColor(255, 0, 0, 100) # Semi-transparent red
self.rubber_band.setColor(color)
self.rubber_band.setFillColor(color)
self.rubber_band.setWidth(2)
def _update_rubber_band(self, current_point: QgsPointXY):
"""Update rubber band geometry."""
if not self.rubber_band or not self.points:
return
self.rubber_band.reset(QgsWkbTypes.PolygonGeometry)
for p in self.points:
self.rubber_band.addPoint(p, False)
self.rubber_band.addPoint(current_point, True)
[docs]
def finalize_polygon(self):
"""Finalize the polygon and emit signal."""
log_critical_operation(logger, "finalize_polygon", points=len(self.points))
logger.debug(
f"ProfileInterpretationTool.finalize_polygon() called with {len(self.points)} points"
)
if len(self.points) < 3:
logger.warning("finalize_polygon() aborted - less than 3 points")
return
# Capture the points as (dist, elev) which are (x, y) in profile units
vertices_2d = [(p.x(), p.y()) for p in self.points]
# Generate a random vivid color for the new interpretation
# Random hue (0-359), high saturation (200-255), medium-lightness (100-200)
hue = random.randint(0, 359)
sat = random.randint(200, 255)
val = random.randint(150, 255)
# Use simple hex format if QColor is not easily serializable, but QColor.name() works
rand_color = QColor.fromHsv(hue, sat, val)
color_hex = rand_color.name() # e.g. #RRGGBB
interp = InterpretationPolygon(
id=str(uuid.uuid4()),
name=QCoreApplication.translate("ProfileInterpretationTool", "New Interpretation"),
type="lithology",
vertices_2d=vertices_2d,
attributes={},
color=color_hex,
created_at=datetime.datetime.now().isoformat(),
)
logger.debug(f"finalize_polygon() - emitting polygonFinished signal for {interp.id}")
self.polygonFinished.emit(interp)
# Note: Do NOT call reset() here.
# The dialog handler should deactivate the tool, which calls reset() cleanly.
logger.info(
f"Interpretation polygon finalized with {len(vertices_2d)} vertices, ID: {interp.id}"
)
logger.debug("finalize_polygon() completed")