Source code for sec_interp.gui.tools.measure_tool
from __future__ import annotations
"""Measurement tool for Profile View.
This module provides the ProfileMeasureTool for measuring distances,
elevation differences, and slopes in the profile preview window.
It separates UI event handling from spatial snapping logic.
"""
from qgis.core import (
QgsMapLayer,
QgsPointLocator,
QgsPointXY,
QgsProject,
QgsVectorLayer,
QgsWkbTypes,
)
from qgis.gui import (
QgsMapCanvas,
QgsMapToolEmitPoint,
QgsMapToolPan,
QgsRubberBand,
QgsVertexMarker,
)
from qgis.PyQt.QtCore import QPoint, Qt, pyqtSignal
from qgis.PyQt.QtGui import QColor
from sec_interp.core.utils.geometry_utils.measurement import calculate_polyline_metrics
from sec_interp.logger_config import get_logger
logger = get_logger(__name__)
[docs]
class ProfileSnapper:
"""Helper class to handle point snapping functionality."""
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}
# Clean obsolete locators
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
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()
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 ProfileMeasureTool(QgsMapToolEmitPoint):
"""Map tool for measuring distances in profile view.
Supports multi-point polyline measurements:
- Click to add points along the trace
- Click "Finalizar" button in UI to complete measurement
- Right-click or Escape to cancel and reset
"""
# signals use dict with measurement metrics
measurementChanged = pyqtSignal(dict)
measurementCleared = pyqtSignal()
measurementFinished = pyqtSignal()
def __init__(self, canvas: QgsMapCanvas):
super().__init__(canvas)
self.canvas = canvas
self.points: list[QgsPointXY] = []
self.finalized: bool = False # Track if measurement is finalized
self.finalized_points: list[QgsPointXY] = [] # Store final points
self.rubber_band: QgsRubberBand | None = None
self.vertex_markers: list[QgsVertexMarker] = []
self.cursor = Qt.CrossCursor
# Delegate snapping logic
self.snapper = ProfileSnapper(canvas)
[docs]
def activate(self) -> None:
"""Activate the measurement tool."""
super().activate()
self.canvas.setCursor(self.cursor)
logger.debug("ProfileMeasureTool activated")
[docs]
def deactivate(self) -> None:
"""Deactivate the measurement tool.
Note: We no longer call reset() here to allow measurements to persist
visually until a new one is started or explicitly cleared.
"""
super().deactivate()
logger.debug("ProfileMeasureTool deactivated")
[docs]
def reset(self):
"""Reset the tool state.
If measurement is finalized, only clears the points data but keeps
the visual elements (rubber band and markers) visible.
"""
logger.info(f"reset() called, finalized={self.finalized}")
# If finalized, only clear the data, keep visuals AND results text
if self.finalized:
logger.info(
"Measurement is finalized - keeping visuals and results, clearing data only"
)
self.points = []
self.finalized = False
# Don't clear finalized_points yet - they're needed for display
# Don't clear rubber_band, vertex_markers, or emit measurementCleared
# This keeps everything visible!
return
# Normal reset - clear everything
self.points = []
self.finalized = False
self.finalized_points = []
if self.rubber_band:
self.canvas.scene().removeItem(self.rubber_band)
self.rubber_band = None
# Remove all vertex markers
for marker in self.vertex_markers:
self.canvas.scene().removeItem(marker)
self.vertex_markers = []
self.measurementCleared.emit()
[docs]
def canvasReleaseEvent(self, event: Any) -> None:
"""Handle mouse click release.
- Left click: Add point to measurement
- Right click: Cancel and reset
- Press Enter to finalize (see keyPressEvent)
Args:
event: Map tool event from QGIS
"""
if event.button() == Qt.RightButton:
self.reset()
return
# Don't add points if measurement is finalized
if self.finalized:
logger.info("Ignoring click - measurement is finalized")
return
snapped_point = self.snapper.snap(event.pos())
# Simply add point to the polyline
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
"""
# Don't update rubber band if measurement is finalized
if self.finalized:
return
if len(self.points) > 0:
current_point = self.snapper.snap(event.pos())
self._update_rubber_band(current_point)
self._calculate_and_emit_preview(current_point)
[docs]
def keyPressEvent(self, event: Any) -> None:
"""Handle keyboard events.
- Enter/Return: Finalize measurement
- Escape: Cancel measurement
Args:
event: Key event from QGIS
"""
if event.key() in (Qt.Key_Return, Qt.Key_Enter):
if len(self.points) >= 2:
self.finalize_measurement()
event.accept()
return
if event.key() == Qt.Key_Escape:
self.reset()
event.accept()
return
# Let parent handle other keys
super().keyPressEvent(event)
def _add_point(self, point: QgsPointXY):
"""Add a point to the measurement polyline."""
self.points.append(point)
self._ensure_rubber_band()
self.rubber_band.addPoint(point, True)
self._add_vertex_marker(point)
logger.debug(f"Point {len(self.points)} added: {point.x():.2f}, {point.y():.2f}")
# Emit measurement update if we have at least 2 points
if len(self.points) >= 2:
metrics = calculate_polyline_metrics(self.points)
self.measurementChanged.emit(metrics)
[docs]
def finalize_measurement(self):
"""Finalize the measurement and emit final metrics.
This is a public method that can be called from UI buttons.
After finalizing, the tool is deactivated but results remain visible.
"""
logger.info(f"finalize_measurement called with {len(self.points)} points")
if len(self.points) < 2:
logger.warning("Cannot finalize measurement with less than 2 points")
return
# Mark as finalized to prevent adding more points
# Save a copy of the points before clearing
self.finalized_points = self.points.copy()
self.finalized = True
logger.info("Setting finalized = True")
metrics = calculate_polyline_metrics(self.points)
self.measurementChanged.emit(metrics)
logger.info(
f"Measurement finalized: {len(self.points)} points, "
f"{metrics['total_distance']:.2f}m total distance"
)
# Redraw rubber band with ONLY the final points (no temporary line)
if self.rubber_band:
self.rubber_band.reset(QgsWkbTypes.LineGeometry)
for point in self.finalized_points:
self.rubber_band.addPoint(point, False)
self.rubber_band.show()
logger.info("Rubber band redrawn with final points only")
# Switch back to pan tool (this preserves the measurement)
# The main dialog will handle unchecking the measure button
logger.info("Switching to pan tool to stop measurement")
pan_tool = QgsMapToolPan(self.canvas)
self.canvas.setMapTool(pan_tool)
logger.info("Pan tool activated - measurement should be frozen")
# Notify that measurement is officially finished
self.measurementFinished.emit()
def _add_vertex_marker(self, point: QgsPointXY):
"""Add a visual marker at the point location."""
marker = QgsVertexMarker(self.canvas)
marker.setCenter(point)
marker.setColor(QColor(0, 255, 0)) # Green for intermediate points
marker.setIconSize(8)
marker.setIconType(QgsVertexMarker.ICON_CIRCLE)
marker.setPenWidth(2)
self.vertex_markers.append(marker)
def _ensure_rubber_band(self):
"""Create rubber band if not exists."""
if self.rubber_band:
return
self.rubber_band = QgsRubberBand(self.canvas, QgsWkbTypes.LineGeometry)
self.rubber_band.setColor(QColor(255, 0, 0))
self.rubber_band.setWidth(2)
def _update_rubber_band(self, current_point: QgsPointXY):
"""Update the rubber band geometry dynamically."""
if not self.rubber_band or len(self.points) == 0:
return
self.rubber_band.reset(QgsWkbTypes.LineGeometry)
# Add all existing points
for point in self.points:
self.rubber_band.addPoint(point, False)
# Add temporary line to current cursor position
self.rubber_band.addPoint(current_point, True)
def _calculate_and_emit_preview(self, target_point: QgsPointXY):
"""Calculate and emit preview metrics while moving cursor."""
if len(self.points) == 0:
return
# Create temporary points list including cursor position
temp_points = [*self.points, target_point]
metrics = calculate_polyline_metrics(temp_points)
self.measurementChanged.emit(metrics)