commit d4f0a9ed0be9e4c33f638997164a16785369ca99
parent de5f7a4ce65191bed6b10d728267b7c2948a7c55
Author: Anders Damsgaard <anders@adamsgaard.dk>
Date: Tue, 29 Sep 2026 22:17:00 +0200
feat(gui): add Edit Point and Delete Point buttons to the elevation profile panel
Diffstat:
4 files changed, 635 insertions(+), 38 deletions(-)
diff --git a/README.md b/README.md
@@ -55,7 +55,16 @@ altered). Otherwise points are stored in a memory layer named
always available under **Plugins > Profile Interpreter** as well, and stays
disabled until you have placed at least one point. Repeated clicks walk back
through the points in reverse order.
-6. Toggle the tool off to return to normal pan/zoom on the profile.
+6. To move a point, check **Edit Point**, click the point (it is selected), then
+ click its new position. Its `distance` and `elevation` fields are updated if
+ the layer has them. Right-click or uncheck **Edit Point** to cancel a
+ selection.
+7. To remove a point, check **Delete Point** and click the point.
+8. **Edit Point** and **Delete Point** sit next to **Undo Point** on the panel
+ toolbar and in the plugin menu. Only one of them can be checked at a time;
+ with neither checked, clicks add points. A point counts as clicked when it is
+ drawn within 10 pixels of the cursor.
+9. Toggle the tool off to return to normal pan/zoom on the profile.
# Limitations
@@ -68,9 +77,14 @@ altered). Otherwise points are stored in a memory layer named
- **Undo Point** only walks back points placed in the current QGIS session, in
reverse order; it does not participate in QGIS's own undo stack (points are
written straight to the data provider, not through an edit buffer).
-- The **Undo Point** button is placed on the profile panel by locating the
+- Moving and deleting points cannot be undone. **Undo Point** skips deleted
+ points; undoing after moving a point deletes the moved point.
+- **Edit Point** and **Delete Point** only search the layer new points go to
+ (the active PointZ layer, otherwise the fallback memory layer). Selecting a
+ point for editing replaces that layer's current selection.
+- The panel buttons are placed on the profile panel by locating the
panel's toolbar in the Qt widget tree. If a QGIS build lays the panel out
- differently, the button is silently omitted there and remains in the plugin
+ differently, the buttons are silently omitted there and remain in the plugin
menu.
- No snapping configuration UI; snapping is left to the user's QGIS settings.
diff --git a/profile_interpreter/profile_interpreter.py b/profile_interpreter/profile_interpreter.py
@@ -22,9 +22,11 @@ from qgis.PyQt.QtGui import QIcon
from qgis.PyQt.QtWidgets import QAction, QToolBar
try:
- _LEFT_BUTTON = Qt.MouseButton.LeftButton # PyQt6 / QGIS 4
+ _LEFT_BUTTON = Qt.MouseButton.LeftButton # PyQt6 / QGIS 4
+ _RIGHT_BUTTON = Qt.MouseButton.RightButton
except AttributeError:
- _LEFT_BUTTON = Qt.LeftButton # PyQt5 / QGIS 3
+ _LEFT_BUTTON = Qt.LeftButton # PyQt5 / QGIS 3
+ _RIGHT_BUTTON = Qt.RightButton
from qgis.core import (
Qgis,
@@ -36,6 +38,7 @@ from qgis.core import (
QgsMessageLog,
QgsPoint,
QgsPointXY,
+ QgsProfilePoint,
QgsProject,
QgsVectorDataProvider,
QgsVectorLayer,
@@ -53,14 +56,19 @@ try:
_POINT_GEOMETRY = QgsWkbTypes.GeometryType.PointGeometry # PyQt6 / QGIS 4
_ADD_FEATURES_CAP = QgsVectorDataProvider.Capability.AddFeatures
_DELETE_FEATURES_CAP = QgsVectorDataProvider.Capability.DeleteFeatures
+ _CHANGE_GEOMETRIES_CAP = QgsVectorDataProvider.Capability.ChangeGeometries
+ _CHANGE_ATTRIBUTES_CAP = QgsVectorDataProvider.Capability.ChangeAttributeValues
except AttributeError:
_POINT_GEOMETRY = QgsWkbTypes.PointGeometry # PyQt5 / QGIS 3
_ADD_FEATURES_CAP = QgsVectorDataProvider.AddFeatures
_DELETE_FEATURES_CAP = QgsVectorDataProvider.DeleteFeatures
+ _CHANGE_GEOMETRIES_CAP = QgsVectorDataProvider.ChangeGeometries
+ _CHANGE_ATTRIBUTES_CAP = QgsVectorDataProvider.ChangeAttributeValues
MENU = 'Profile Interpreter'
LAYER_NAME = 'Profile interpretations'
+HIT_TOLERANCE_PX = 10.0
def _is_suitable_target(layer):
@@ -116,6 +124,26 @@ def _can_delete(layer):
return bool(caps & _DELETE_FEATURES_CAP)
+def _can_move(layer):
+ """True if feature geometries can be changed in layer's provider."""
+ if layer is None:
+ return False
+ caps = layer.dataProvider().capabilities()
+ return bool(caps & _CHANGE_GEOMETRIES_CAP)
+
+
+def _nearest_hit(candidates, click_x, click_y, tolerance_px):
+ """Feature id of the (fid, px, py) candidate nearest the click within tolerance, or None."""
+ best_fid = None
+ best_dist = tolerance_px
+ for fid, px, py in candidates:
+ dist = math.hypot(px - click_x, py - click_y)
+ if dist <= best_dist:
+ best_fid = fid
+ best_dist = dist
+ return best_fid
+
+
def _added_feature_id(added, fallback):
"""Provider-assigned id of the feature just written, else the fallback feature's id."""
feature = added[0] if added else fallback
@@ -138,17 +166,23 @@ def _find_panel_toolbar(canvas, main_window):
class _ProfilePickTool(QgsPlotTool):
- """A plot tool that forwards left-button releases to a callback."""
+ """A plot tool that forwards left-button releases to a callback.
- def __init__(self, canvas, on_pick):
+ Right-button releases call on_cancel, if given, before default handling.
+ """
+
+ def __init__(self, canvas, on_pick, on_cancel=None):
super().__init__(canvas, 'Interpret on Profile')
self._on_pick = on_pick
+ self._on_cancel = on_cancel
def plotReleaseEvent(self, event):
- if event.button() != _LEFT_BUTTON:
- super().plotReleaseEvent(event)
+ if event.button() == _LEFT_BUTTON:
+ self._on_pick(event)
return
- self._on_pick(event)
+ if event.button() == _RIGHT_BUTTON and self._on_cancel is not None:
+ self._on_cancel()
+ super().plotReleaseEvent(event)
class ProfileInterpreterPlugin:
@@ -160,8 +194,12 @@ class ProfileInterpreterPlugin:
self._pan_tool = None
self._layer = None
self._undo_action = None
+ self._edit_action = None
+ self._delete_action = None
self._panel_toolbar = None
self._history = []
+ self._mode = 'add'
+ self._selected = None
def initGui(self):
icon_path = os.path.join(os.path.dirname(__file__), 'icon.svg')
@@ -179,13 +217,35 @@ class ProfileInterpreterPlugin:
self._undo_action.triggered.connect(self._undo_last_point)
self.iface.addPluginToMenu(MENU, self._undo_action)
+ self._edit_action = QAction(
+ QgsApplication.getThemeIcon('mActionVertexTool.svg'),
+ 'Edit Point', self.iface.mainWindow())
+ self._edit_action.setToolTip(
+ 'Click a point to select it, then click its new position')
+ self._edit_action.setCheckable(True)
+ self._edit_action.setEnabled(False)
+ self._edit_action.toggled.connect(lambda on: self._set_mode('edit', on))
+ self.iface.addPluginToMenu(MENU, self._edit_action)
+
+ self._delete_action = QAction(
+ QgsApplication.getThemeIcon('mActionDeleteSelectedFeatures.svg'),
+ 'Delete Point', self.iface.mainWindow())
+ self._delete_action.setToolTip('Click a point to delete it')
+ self._delete_action.setCheckable(True)
+ self._delete_action.setEnabled(False)
+ self._delete_action.toggled.connect(lambda on: self._set_mode('delete', on))
+ self.iface.addPluginToMenu(MENU, self._delete_action)
+
def unload(self):
self._set_active(False)
self.iface.removeToolBarIcon(self._action)
- self.iface.removePluginMenu(MENU, self._action)
- self.iface.removePluginMenu(MENU, self._undo_action)
+ for action in (self._action, self._undo_action, self._edit_action,
+ self._delete_action):
+ self.iface.removePluginMenu(MENU, action)
self._action = None
self._undo_action = None
+ self._edit_action = None
+ self._delete_action = None
self._history.clear()
def _set_active(self, active):
@@ -206,9 +266,9 @@ class ProfileInterpreterPlugin:
return
self._canvas = canvas
- self._pick_tool = _ProfilePickTool(canvas, self._on_pick)
+ self._pick_tool = _ProfilePickTool(canvas, self._on_pick, self._clear_selection)
canvas.setTool(self._pick_tool)
- self._attach_undo_action(canvas)
+ self._attach_panel_actions(canvas)
self.iface.messageBar().pushMessage(
MENU,
'Click on the elevation profile to place interpretation points.',
@@ -216,7 +276,7 @@ class ProfileInterpreterPlugin:
)
def _deactivate(self):
- self._detach_undo_action()
+ self._detach_panel_actions()
if self._canvas is not None and self._pick_tool is not None:
if QgsPlotToolPan is not None:
self._pan_tool = QgsPlotToolPan(self._canvas)
@@ -226,25 +286,69 @@ class ProfileInterpreterPlugin:
self._pick_tool = None
self._canvas = None
- def _attach_undo_action(self, canvas):
- """Put Undo Point on the Elevation Profile panel toolbar, if it has one."""
- if self._undo_action is None:
+ def _panel_actions(self):
+ actions = (self._undo_action, self._edit_action, self._delete_action)
+ return [a for a in actions if a is not None]
+
+ def _attach_panel_actions(self, canvas):
+ """Enable the mode actions and put all panel actions on the profile panel toolbar."""
+ for action in (self._edit_action, self._delete_action):
+ if action is not None:
+ action.setEnabled(True)
+ actions = self._panel_actions()
+ if not actions:
return
toolbar = _find_panel_toolbar(canvas, self.iface.mainWindow())
if toolbar is None:
return
- toolbar.addAction(self._undo_action)
+ for action in actions:
+ toolbar.addAction(action)
self._panel_toolbar = toolbar
- def _detach_undo_action(self):
- if self._panel_toolbar is not None and self._undo_action is not None:
- self._panel_toolbar.removeAction(self._undo_action)
+ def _detach_panel_actions(self):
+ for action in (self._edit_action, self._delete_action):
+ if action is not None:
+ action.setChecked(False)
+ action.setEnabled(False)
+ self._clear_selection()
+ self._mode = 'add'
+ if self._panel_toolbar is not None:
+ for action in self._panel_actions():
+ self._panel_toolbar.removeAction(action)
self._panel_toolbar = None
+ def _set_mode(self, mode, checked):
+ """Switch between add, edit and delete; edit and delete are mutually exclusive."""
+ self._clear_selection()
+ if not checked:
+ if self._mode == mode:
+ self._mode = 'add'
+ return
+ self._mode = mode
+ other = self._delete_action if mode == 'edit' else self._edit_action
+ if other is not None:
+ other.setChecked(False)
+ hint = ('Click a point to select it, then click its new position.'
+ if mode == 'edit' else 'Click a point to delete it.')
+ self.iface.messageBar().pushMessage(MENU, hint, level=Qgis.Info, duration=3)
+
+ def _clear_selection(self):
+ if self._selected is not None:
+ layer, _ = self._selected
+ if QgsProject.instance().mapLayer(layer.id()) is not None:
+ layer.removeSelection()
+ self._selected = None
+
def _refresh_undo_state(self):
if self._undo_action is not None:
self._undo_action.setEnabled(bool(self._history))
+ def _refresh_layer(self, layer):
+ layer.updateExtents()
+ layer.triggerRepaint()
+ if self._canvas is not None and hasattr(self._canvas, 'refresh'):
+ self._canvas.refresh()
+
def _undo_last_point(self):
"""Delete the most recently placed point, skipping layers since removed."""
project = QgsProject.instance()
@@ -266,10 +370,7 @@ class ProfileInterpreterPlugin:
level=Qgis.Warning,
)
break
- layer.updateExtents()
- layer.triggerRepaint()
- if self._canvas is not None and hasattr(self._canvas, 'refresh'):
- self._canvas.refresh()
+ self._refresh_layer(layer)
QgsMessageLog.logMessage(
f'removed point {feature_id} from "{layer.name()}"',
MENU, level=Qgis.Info,
@@ -297,21 +398,69 @@ class ProfileInterpreterPlugin:
return active
return self._interpretation_layer(crs)
- def _on_pick(self, event):
+ def _search_layer(self):
+ """The layer points are added to, without creating the fallback layer."""
+ active = self.iface.activeLayer()
+ if _is_suitable_target(active):
+ return active
+ if self._layer is not None and QgsProject.instance().mapLayer(self._layer.id()):
+ return self._layer
+ return None
+
+ def _click_to_point(self, event):
+ """(distance, elevation, xy in canvas CRS) under the click, or None off the section."""
canvas = self._canvas
profile_point = canvas.canvasPointToPlotPoint(QPointF(event.pos()))
distance = profile_point.distance()
elevation = profile_point.elevation()
if math.isnan(distance) or math.isnan(elevation):
- return
+ return None
curve = canvas.profileCurve()
if curve is None:
- return
+ return None
point_geom = QgsGeometry(curve.clone()).interpolate(distance)
if point_geom.isEmpty():
+ return None
+ return distance, elevation, point_geom.asPoint()
+
+ def _find_point_at(self, event, layer):
+ """Feature id in layer drawn within HIT_TOLERANCE_PX of the click, or None."""
+ canvas = self._canvas
+ curve = canvas.profileCurve()
+ if curve is None:
+ return None
+ curve_geom = QgsGeometry(curve.clone())
+ tolerance = canvas.tolerance() if hasattr(canvas, 'tolerance') else 0.0
+ candidates = []
+ for feature in layer.getFeatures():
+ point = feature.geometry().constGet()
+ if not isinstance(point, QgsPoint):
+ continue
+ x, y = _project_xy(point, layer.crs(), canvas.crs())
+ xy_geom = QgsGeometry(QgsPoint(x, y))
+ if tolerance > 0 and curve_geom.distance(xy_geom) > tolerance:
+ continue
+ distance = curve_geom.lineLocatePoint(xy_geom)
+ pixel = canvas.plotPointToCanvasPoint(QgsProfilePoint(distance, point.z()))
+ candidates.append((feature.id(), pixel.x(), pixel.y()))
+ pos = event.pos()
+ return _nearest_hit(candidates, pos.x(), pos.y(), HIT_TOLERANCE_PX)
+
+ def _on_pick(self, event):
+ if self._mode == 'edit':
+ self._edit_click(event)
+ elif self._mode == 'delete':
+ self._delete_click(event)
+ else:
+ self._add_point(event)
+
+ def _add_point(self, event):
+ picked = self._click_to_point(event)
+ if picked is None:
return
- xy = point_geom.asPoint()
+ distance, elevation, xy = picked
+ canvas = self._canvas
layer = self._target_layer(canvas.crs())
x, y = _project_xy(xy, canvas.crs(), layer.crs())
@@ -336,11 +485,7 @@ class ProfileInterpreterPlugin:
return
self._history.append((layer, _added_feature_id(added, feature)))
self._refresh_undo_state()
- layer.updateExtents()
- layer.triggerRepaint()
-
- if hasattr(canvas, 'refresh'):
- canvas.refresh()
+ self._refresh_layer(layer)
id_label = f' #{feature_id}' if feature_id is not None else ''
QgsMessageLog.logMessage(
@@ -356,6 +501,99 @@ class ProfileInterpreterPlugin:
duration=2,
)
+ def _edit_click(self, event):
+ if self._selected is None:
+ self._select_for_edit(event)
+ else:
+ self._move_selected(event)
+
+ def _select_for_edit(self, event):
+ layer = self._search_layer()
+ fid = self._find_point_at(event, layer) if layer is not None else None
+ if fid is None:
+ self.iface.messageBar().pushMessage(
+ MENU, 'No point near the click.', level=Qgis.Info, duration=2)
+ return
+ if not _can_move(layer):
+ self.iface.messageBar().pushMessage(
+ MENU,
+ f'Layer "{layer.name()}" does not support moving features.',
+ level=Qgis.Warning,
+ )
+ return
+ layer.selectByIds([fid])
+ self._selected = (layer, fid)
+ self.iface.messageBar().pushMessage(
+ MENU, 'Point selected; click its new position.', level=Qgis.Info, duration=2)
+
+ def _move_selected(self, event):
+ layer, fid = self._selected
+ if QgsProject.instance().mapLayer(layer.id()) is None:
+ self._selected = None
+ return
+ picked = self._click_to_point(event)
+ if picked is None:
+ return
+ distance, elevation, xy = picked
+ x, y = _project_xy(xy, self._canvas.crs(), layer.crs())
+ provider = layer.dataProvider()
+ geometry = QgsGeometry(QgsPoint(x, y, elevation))
+ if not provider.changeGeometryValues({fid: geometry}):
+ self.iface.messageBar().pushMessage(
+ MENU, 'Failed to move the point.', level=Qgis.Warning)
+ return
+
+ fields = layer.fields()
+ attrs = _point_attributes({f.name() for f in fields}, None, distance, elevation)
+ attrs.pop('id', None)
+ if attrs and provider.capabilities() & _CHANGE_ATTRIBUTES_CAP:
+ provider.changeAttributeValues(
+ {fid: {fields.indexOf(k): v for k, v in attrs.items()}})
+
+ self._clear_selection()
+ self._refresh_layer(layer)
+ QgsMessageLog.logMessage(
+ f'moved point {fid} in "{layer.name()}" to x={x:.3f} y={y:.3f} '
+ f'z={elevation:.3f} (distance={distance:.3f})',
+ MENU, level=Qgis.Info,
+ )
+ self.iface.messageBar().pushMessage(
+ MENU,
+ f'Moved point to x={xy.x():.1f}, y={xy.y():.1f}, '
+ f'elevation={elevation:.2f}',
+ level=Qgis.Success,
+ duration=2,
+ )
+
+ def _delete_click(self, event):
+ layer = self._search_layer()
+ fid = self._find_point_at(event, layer) if layer is not None else None
+ if fid is None:
+ self.iface.messageBar().pushMessage(
+ MENU, 'No point near the click.', level=Qgis.Info, duration=2)
+ return
+ if not _can_delete(layer):
+ self.iface.messageBar().pushMessage(
+ MENU,
+ f'Layer "{layer.name()}" does not support deleting features.',
+ level=Qgis.Warning,
+ )
+ return
+ if not layer.dataProvider().deleteFeatures([fid]):
+ self.iface.messageBar().pushMessage(
+ MENU, 'Failed to delete the point.', level=Qgis.Warning)
+ return
+ self._history = [
+ (lyr, f) for lyr, f in self._history
+ if not (lyr.id() == layer.id() and f == fid)
+ ]
+ self._refresh_undo_state()
+ self._refresh_layer(layer)
+ QgsMessageLog.logMessage(
+ f'deleted point {fid} from "{layer.name()}"', MENU, level=Qgis.Info)
+ self.iface.messageBar().pushMessage(
+ MENU, 'Deleted interpretation point.', level=Qgis.Success, duration=2)
+
def _interpretation_layer(self, crs):
project = QgsProject.instance()
if self._layer is not None and project.mapLayer(self._layer.id()):
diff --git a/test/fakeqgis.py b/test/fakeqgis.py
@@ -58,7 +58,13 @@ class QAction:
self._checkable = v
def setChecked(self, v):
+ if v == self._checked:
+ return
self._checked = v
+ self.toggled.emit(v)
+
+ def isChecked(self):
+ return self._checked
def setEnabled(self, v):
self._enabled = v
@@ -111,6 +117,10 @@ class _FakeFields:
def __iter__(self):
return iter(self._fields)
+ def indexOf(self, name):
+ names = [f.name() for f in self._fields]
+ return names.index(name) if name in names else -1
+
class QgsFeature:
def __init__(self, fields=None):
@@ -128,6 +138,9 @@ class QgsFeature:
def fields(self):
return self._fields
+ def geometry(self):
+ return self._geom if self._geom is not None else QgsGeometry()
+
def setGeometry(self, geom):
self._geom = geom
@@ -181,6 +194,8 @@ class QgsPointXY:
class QgsGeometry:
# Set to True in tests to make interpolate() return an empty geometry.
_next_interpolate_empty = False
+ # Lateral offset of every point from the profile curve, as seen by distance().
+ _lateral_distance = 0.0
def __init__(self, obj=None):
self._obj = obj
@@ -202,6 +217,15 @@ class QgsGeometry:
def clone(self):
return self
+ def constGet(self):
+ return self._obj
+
+ def lineLocatePoint(self, point_geom):
+ return point_geom.constGet().x()
+
+ def distance(self, other):
+ return QgsGeometry._lateral_distance
+
class QgsPoint:
def __init__(self, x=0.0, y=0.0, z=0.0):
@@ -209,6 +233,27 @@ class QgsPoint:
self._y = y
self._z = z
+ def x(self):
+ return self._x
+
+ def y(self):
+ return self._y
+
+ def z(self):
+ return self._z
+
+
+class QgsProfilePoint:
+ def __init__(self, distance=0.0, elevation=0.0):
+ self._distance = distance
+ self._elevation = elevation
+
+ def distance(self):
+ return self._distance
+
+ def elevation(self):
+ return self._elevation
+
class QgsCoordinateTransform:
_calls = [] # (src_authid, dst_authid, in_x, in_y)
@@ -260,15 +305,20 @@ class QgsWkbTypes:
class QgsVectorDataProvider:
AddFeatures = 1 # PyQt5 style
DeleteFeatures = 2
+ ChangeGeometries = 4
+ ChangeAttributeValues = 8
class Capability: # PyQt6 style
AddFeatures = 1
DeleteFeatures = 2
+ ChangeGeometries = 4
+ ChangeAttributeValues = 8
class _FakeDataProvider:
_fail_next = False # set True in a test to simulate addFeatures failure
_fail_next_delete = False # set True in a test to simulate deleteFeatures failure
+ _fail_next_change = False # set True in a test to simulate changeGeometryValues failure
def __init__(self, caps, features_store, fields_ref):
self._caps = caps
@@ -297,6 +347,22 @@ class _FakeDataProvider:
self._features[:] = remaining
return True
+ def changeGeometryValues(self, geometries):
+ if _FakeDataProvider._fail_next_change:
+ _FakeDataProvider._fail_next_change = False
+ return False
+ for feature in self._features:
+ if feature.id() in geometries:
+ feature.setGeometry(geometries[feature.id()])
+ return True
+
+ def changeAttributeValues(self, changes):
+ names = [f.name() for f in self._fields_ref]
+ for feature in self._features:
+ for idx, value in changes.get(feature.id(), {}).items():
+ feature[names[idx]] = value
+ return True
+
def addAttributes(self, attrs):
self._fields_ref.extend(attrs)
@@ -309,10 +375,13 @@ class QgsVectorLayer:
self._geom_type = QgsWkbTypes.PointGeometry
self._wkb_type = QgsWkbTypes.PointZ
self._provider = _FakeDataProvider(
- QgsVectorDataProvider.AddFeatures | QgsVectorDataProvider.DeleteFeatures,
+ QgsVectorDataProvider.AddFeatures | QgsVectorDataProvider.DeleteFeatures |
+ QgsVectorDataProvider.ChangeGeometries |
+ QgsVectorDataProvider.ChangeAttributeValues,
self._features,
self._fields_list,
)
+ self.selected_ids = []
def name(self):
return self._name
@@ -338,6 +407,12 @@ class QgsVectorLayer:
def getFeatures(self):
return iter(self._features)
+ def selectByIds(self, ids):
+ self.selected_ids = list(ids)
+
+ def removeSelection(self):
+ self.selected_ids = []
+
def updateExtents(self):
pass
@@ -405,6 +480,9 @@ class QgsElevationProfileCanvas:
def canvasPointToPlotPoint(self, pt):
return _FakeProfilePoint()
+ def plotPointToCanvasPoint(self, point):
+ return QgsPointXY(point.distance(), point.elevation())
+
def profileCurve(self):
return _FakeCurve()
@@ -462,7 +540,7 @@ def install():
for name in [
'Qgis', 'QgsApplication', 'QgsCoordinateTransform', 'QgsFeature', 'QgsField',
- 'QgsGeometry', 'QgsMessageLog', 'QgsPoint', 'QgsPointXY',
+ 'QgsGeometry', 'QgsMessageLog', 'QgsPoint', 'QgsPointXY', 'QgsProfilePoint',
'QgsProject', 'QgsVectorDataProvider', 'QgsVectorLayer', 'QgsWkbTypes',
]:
setattr(qgis_core, name, globals()[name])
diff --git a/test/test_profile_interpreter.py b/test/test_profile_interpreter.py
@@ -13,12 +13,15 @@ fakeqgis.install()
from profile_interpreter.profile_interpreter import ( # noqa: E402
_added_feature_id,
_can_delete,
+ _can_move,
_find_panel_toolbar,
_is_suitable_target,
+ _nearest_hit,
_next_feature_id,
_point_attributes,
_project_xy,
_LEFT_BUTTON,
+ _RIGHT_BUTTON,
_ProfilePickTool,
ProfileInterpreterPlugin,
)
@@ -30,12 +33,13 @@ import fakeqgis as fq # noqa: E402
class _FakeEvent:
def __init__(self, button=None, x=0.0, y=0.0):
self._button = button if button is not None else _LEFT_BUTTON
+ self._pos = fq.QgsPointXY(x, y)
def button(self):
return self._button
def pos(self):
- return None # consumed by fake QPointF(*args)
+ return self._pos
class _FakeCanvas:
@@ -50,6 +54,9 @@ class _FakeCanvas:
def canvasPointToPlotPoint(self, pt):
return fq._FakeProfilePoint(self._distance, self._elevation)
+ def plotPointToCanvasPoint(self, point):
+ return fq.QgsPointXY(point.distance(), point.elevation())
+
def profileCurve(self):
return self._curve
@@ -720,5 +727,265 @@ class TestUndoLastPoint(unittest.TestCase):
self.assertEqual(plugin._history, [])
+# ── _nearest_hit / _can_move ──────────────────────────────────────────────
+
+class TestNearestHit(unittest.TestCase):
+ def test_no_candidates(self):
+ self.assertIsNone(_nearest_hit([], 0.0, 0.0, 10.0))
+
+ def test_outside_tolerance(self):
+ self.assertIsNone(_nearest_hit([(1, 20.0, 0.0)], 0.0, 0.0, 10.0))
+
+ def test_nearest_of_several(self):
+ candidates = [(1, 8.0, 0.0), (2, 3.0, 4.0), (3, 0.0, 9.0)]
+ self.assertEqual(_nearest_hit(candidates, 0.0, 0.0, 10.0), 2)
+
+ def test_on_tolerance_boundary_hits(self):
+ self.assertEqual(_nearest_hit([(4, 6.0, 8.0)], 0.0, 0.0, 10.0), 4)
+
+
+class TestCanMove(unittest.TestCase):
+ def test_none_rejected(self):
+ self.assertFalse(_can_move(None))
+
+ def test_memory_layer_accepted(self):
+ self.assertTrue(_can_move(fq.QgsVectorLayer('PointZ', 'test', 'memory')))
+
+ def test_provider_without_capability_rejected(self):
+ layer = fq.QgsVectorLayer('PointZ', 'test', 'memory')
+ layer._provider._caps = fq.QgsVectorDataProvider.AddFeatures
+ self.assertFalse(_can_move(layer))
+
+
+# ── edit / delete modes ───────────────────────────────────────────────────
+
+def _layer_with_points(points, fields=('id', 'distance', 'elevation')):
+ """Writable PointZ layer holding a feature per (x, z); the fake maps it to pixel (x, z)."""
+ layer = fq.QgsVectorLayer('PointZ', 'points', 'memory')
+ for name in fields:
+ layer._fields_list.append(fq.QgsField(name))
+ for x, z in points:
+ feature = fq.QgsFeature(fq._FakeFields(layer._fields_list))
+ feature.setGeometry(fq.QgsGeometry(fq.QgsPoint(x, 0.0, z)))
+ layer._provider.addFeatures([feature])
+ return layer
+
+
+def _xyz(feature):
+ point = feature._geom._obj
+ return point.x(), point.y(), point.z()
+
+
+class _ModeTestCase(unittest.TestCase):
+ def setUp(self):
+ fq.QgsProject._instance = None
+ fq.QgsGeometry._next_interpolate_empty = False
+ fq.QgsGeometry._lateral_distance = 0.0
+ fq._FakeDataProvider._fail_next = False
+ fq._FakeDataProvider._fail_next_delete = False
+ fq._FakeDataProvider._fail_next_change = False
+
+ def _plugin(self, layer=None, canvas=None):
+ plugin, iface = _make_plugin(active_layer=layer, canvas=canvas)
+ if layer is not None:
+ fq.QgsProject.instance().addMapLayer(layer)
+ iface._bar = _TrackingBar()
+ plugin.initGui()
+ return plugin, iface
+
+
+class TestModeActions(_ModeTestCase):
+ def _activated_plugin(self):
+ canvas = fq.QgsElevationProfileCanvas()
+ toolbar = fq.QToolBar()
+ main_window = _FakeMainWindow([canvas])
+ canvas._parent = _FakeWidget(parent=main_window, children=[toolbar])
+ iface = _FakeIface()
+ iface._window = main_window
+ plugin = ProfileInterpreterPlugin(iface)
+ plugin.initGui()
+ plugin._set_active(True)
+ return plugin, iface, toolbar
+
+ def test_added_to_plugin_menu_disabled(self):
+ plugin, iface = self._plugin()
+ for action in (plugin._edit_action, plugin._delete_action):
+ self.assertIn(action, iface.menu_actions)
+ self.assertFalse(action.isEnabled())
+
+ def test_attached_next_to_undo_while_active(self):
+ plugin, _, toolbar = self._activated_plugin()
+ self.assertEqual(
+ toolbar.actions_added,
+ [plugin._undo_action, plugin._edit_action, plugin._delete_action])
+ self.assertTrue(plugin._edit_action.isEnabled())
+ self.assertTrue(plugin._delete_action.isEnabled())
+
+ def test_detached_and_reset_on_deactivate(self):
+ plugin, _, toolbar = self._activated_plugin()
+ plugin._edit_action.setChecked(True)
+ plugin._set_active(False)
+ self.assertEqual(toolbar.actions_added, [])
+ self.assertFalse(plugin._edit_action.isChecked())
+ self.assertFalse(plugin._edit_action.isEnabled())
+ self.assertEqual(plugin._mode, 'add')
+
+ def test_edit_and_delete_are_exclusive(self):
+ plugin, _ = self._plugin()
+ plugin._edit_action.setChecked(True)
+ self.assertEqual(plugin._mode, 'edit')
+ plugin._delete_action.setChecked(True)
+ self.assertEqual(plugin._mode, 'delete')
+ self.assertFalse(plugin._edit_action.isChecked())
+ plugin._delete_action.setChecked(False)
+ self.assertEqual(plugin._mode, 'add')
+
+ def test_menu_entries_removed_on_unload(self):
+ plugin, iface, _ = self._activated_plugin()
+ edit, delete = plugin._edit_action, plugin._delete_action
+ plugin.unload()
+ self.assertNotIn(edit, iface.menu_actions)
+ self.assertNotIn(delete, iface.menu_actions)
+
+
+class TestEditPoint(_ModeTestCase):
+ def test_select_then_move_updates_geometry_and_attrs(self):
+ layer = _layer_with_points([(30.0, 5.0), (60.0, 5.0)])
+ canvas = _FakeCanvas(distance=12.0, elevation=7.0)
+ plugin, _ = self._plugin(layer, canvas)
+ plugin._edit_action.setChecked(True)
+
+ plugin._on_pick(_FakeEvent(x=31.0, y=6.0))
+ self.assertEqual(plugin._selected, (layer, 1))
+ self.assertEqual(layer.selected_ids, [1])
+
+ plugin._on_pick(_FakeEvent(x=200.0, y=200.0))
+ moved = layer._features[0]
+ self.assertEqual(_xyz(moved), (100.0, 200.0, 7.0))
+ self.assertEqual(moved._attrs['distance'], 12.0)
+ self.assertEqual(moved._attrs['elevation'], 7.0)
+ self.assertNotIn('id', moved._attrs)
+ self.assertEqual(_xyz(layer._features[1]), (60.0, 0.0, 5.0))
+ self.assertIsNone(plugin._selected)
+ self.assertEqual(layer.selected_ids, [])
+ self.assertEqual(canvas.refresh_count, 1)
+
+ def test_miss_with_nothing_selected_changes_nothing(self):
+ layer = _layer_with_points([(30.0, 5.0)])
+ plugin, iface = self._plugin(layer)
+ plugin._edit_action.setChecked(True)
+ plugin._on_pick(_FakeEvent(x=80.0, y=80.0))
+ self.assertIsNone(plugin._selected)
+ self.assertEqual(_xyz(layer._features[0]), (30.0, 0.0, 5.0))
+ self.assertIn('No point near the click.', [m for _, m, _ in iface._bar.messages])
+
+ def test_right_click_cancels_selection(self):
+ layer = _layer_with_points([(30.0, 5.0)])
+ plugin, _ = self._plugin(layer)
+ plugin._edit_action.setChecked(True)
+ plugin._on_pick(_FakeEvent(x=30.0, y=5.0))
+ tool = _ProfilePickTool(plugin._canvas, plugin._on_pick, plugin._clear_selection)
+ tool.plotReleaseEvent(_FakeEvent(button=_RIGHT_BUTTON))
+ self.assertIsNone(plugin._selected)
+ self.assertEqual(layer.selected_ids, [])
+
+ def test_toggling_edit_off_cancels_selection(self):
+ layer = _layer_with_points([(30.0, 5.0)])
+ plugin, _ = self._plugin(layer)
+ plugin._edit_action.setChecked(True)
+ plugin._on_pick(_FakeEvent(x=30.0, y=5.0))
+ plugin._edit_action.setChecked(False)
+ self.assertIsNone(plugin._selected)
+ self.assertEqual(plugin._mode, 'add')
+
+ def test_layer_without_change_geometries_warns(self):
+ layer = _layer_with_points([(30.0, 5.0)])
+ layer._provider._caps = fq.QgsVectorDataProvider.AddFeatures
+ plugin, iface = self._plugin(layer)
+ plugin._edit_action.setChecked(True)
+ plugin._on_pick(_FakeEvent(x=30.0, y=5.0))
+ self.assertIsNone(plugin._selected)
+ self.assertTrue(any(lvl == fq.Qgis.Warning for _, _, lvl in iface._bar.messages))
+
+ def test_change_failure_warns_and_keeps_selection(self):
+ layer = _layer_with_points([(30.0, 5.0)])
+ plugin, iface = self._plugin(layer)
+ plugin._edit_action.setChecked(True)
+ plugin._on_pick(_FakeEvent(x=30.0, y=5.0))
+ fq._FakeDataProvider._fail_next_change = True
+ plugin._on_pick(_FakeEvent(x=0.0, y=0.0))
+ self.assertEqual(_xyz(layer._features[0]), (30.0, 0.0, 5.0))
+ self.assertEqual(plugin._selected, (layer, 1))
+ self.assertTrue(any(lvl == fq.Qgis.Warning for _, _, lvl in iface._bar.messages))
+
+ def test_points_beyond_profile_tolerance_ignored(self):
+ layer = _layer_with_points([(30.0, 5.0)])
+ canvas = _FakeCanvas()
+ canvas.tolerance = lambda: 5.0
+ plugin, _ = self._plugin(layer, canvas)
+ plugin._edit_action.setChecked(True)
+ fq.QgsGeometry._lateral_distance = 6.0
+ plugin._on_pick(_FakeEvent(x=30.0, y=5.0))
+ self.assertIsNone(plugin._selected)
+ fq.QgsGeometry._lateral_distance = 3.0
+ plugin._on_pick(_FakeEvent(x=30.0, y=5.0))
+ self.assertEqual(plugin._selected, (layer, 1))
+
+ def test_no_fallback_layer_created(self):
+ plugin, _ = self._plugin()
+ plugin._edit_action.setChecked(True)
+ plugin._on_pick(_FakeEvent(x=30.0, y=5.0))
+ self.assertIsNone(plugin._layer)
+
+
+class TestDeletePoint(_ModeTestCase):
+ def test_deletes_point_under_click(self):
+ layer = _layer_with_points([(30.0, 5.0), (60.0, 5.0)])
+ canvas = _FakeCanvas()
+ plugin, _ = self._plugin(layer, canvas)
+ plugin._delete_action.setChecked(True)
+ plugin._on_pick(_FakeEvent(x=58.0, y=5.0))
+ self.assertEqual([f.id() for f in layer._features], [1])
+ self.assertEqual(canvas.refresh_count, 1)
+
+ def test_miss_is_a_no_op(self):
+ layer = _layer_with_points([(30.0, 5.0)])
+ plugin, _ = self._plugin(layer)
+ plugin._delete_action.setChecked(True)
+ plugin._on_pick(_FakeEvent(x=45.0, y=5.0))
+ self.assertEqual(len(layer._features), 1)
+
+ def test_deleted_point_pruned_from_undo_history(self):
+ plugin, _ = self._plugin()
+ plugin._on_pick(_FakeEvent())
+ plugin._on_pick(_FakeEvent())
+ layer = plugin._layer
+ layer._features[0].setGeometry(fq.QgsGeometry(fq.QgsPoint(30.0, 0.0, 5.0)))
+ plugin._delete_action.setChecked(True)
+ plugin._on_pick(_FakeEvent(x=30.0, y=5.0))
+ self.assertEqual(plugin._history, [(layer, 2)])
+ plugin._undo_last_point()
+ self.assertEqual(layer._features, [])
+ self.assertFalse(plugin._undo_action.isEnabled())
+
+ def test_read_only_layer_warns_and_keeps_point(self):
+ layer = _layer_with_points([(30.0, 5.0)])
+ layer._provider._caps = fq.QgsVectorDataProvider.AddFeatures
+ plugin, iface = self._plugin(layer)
+ plugin._delete_action.setChecked(True)
+ plugin._on_pick(_FakeEvent(x=30.0, y=5.0))
+ self.assertEqual(len(layer._features), 1)
+ self.assertTrue(any(lvl == fq.Qgis.Warning for _, _, lvl in iface._bar.messages))
+
+ def test_delete_failure_warns(self):
+ layer = _layer_with_points([(30.0, 5.0)])
+ plugin, iface = self._plugin(layer)
+ plugin._delete_action.setChecked(True)
+ fq._FakeDataProvider._fail_next_delete = True
+ plugin._on_pick(_FakeEvent(x=30.0, y=5.0))
+ self.assertEqual(len(layer._features), 1)
+ self.assertTrue(any(lvl == fq.Qgis.Warning for _, _, lvl in iface._bar.messages))
+
+
if __name__ == '__main__':
unittest.main()