qgis-profile-interpreter

qgis plugin for placing 3D points along elevation profiles
git clone git://src.adamsgaard.dk/qgis-profile-interpreter # fast
git clone https://src.adamsgaard.dk/qgis-profile-interpreter.git # slow
Log | Files | Refs | README | LICENSE Back to index

commit 53b50fcde9bc34a113593b5b2a89bef754a28ec9
parent dd528061bfd5634a7a932fc82aa41cc3bc93526c
Author: Anders Damsgaard <anders@adamsgaard.dk>
Date:   Sat, 29 Aug 2026 14:30:36 +0200

feat(gui): add Undo Point button to the elevation profile panel

Diffstat:
MREADME.md | 15+++++++++++++--
Mprofile_interpreter/profile_interpreter.py | 111+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mtest/fakeqgis.py | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mtest/test_profile_interpreter.py | 246+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
4 files changed, 436 insertions(+), 18 deletions(-)

diff --git a/README.md b/README.md @@ -50,7 +50,12 @@ altered). Otherwise points are stored in a memory layer named added to the active layer (if it is a writable PointZ layer) or to the fallback **Profile interpretations** memory layer. The point's X/Y/Z is printed to the Python console and shown in the message bar. -5. Toggle the tool off to return to normal pan/zoom on the profile. +5. To remove the point you just placed, click **Undo Point**. While the tool is + on, this button is added to the Elevation Profile panel's own toolbar; it is + 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. # Limitations @@ -60,7 +65,13 @@ altered). Otherwise points are stored in a memory layer named - The plugin grabs the profile canvas by walking the Qt widget tree (`findChildren(QgsElevationProfileCanvas)`); there is no official PyQGIS accessor. With multiple profile docks open it picks the first visible one. -- No editing/undo of placed points beyond standard layer editing. +- **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 + 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 + menu. - No snapping configuration UI; snapping is left to the user's QGIS settings. ## Development diff --git a/profile_interpreter/profile_interpreter.py b/profile_interpreter/profile_interpreter.py @@ -19,7 +19,7 @@ import os from qgis.PyQt.QtCore import Qt, QPointF from qgis.PyQt.QtGui import QIcon -from qgis.PyQt.QtWidgets import QAction +from qgis.PyQt.QtWidgets import QAction, QToolBar try: _LEFT_BUTTON = Qt.MouseButton.LeftButton # PyQt6 / QGIS 4 @@ -28,6 +28,7 @@ except AttributeError: from qgis.core import ( Qgis, + QgsApplication, QgsCoordinateTransform, QgsFeature, QgsField, @@ -51,9 +52,11 @@ except ImportError: # older builds expose it under a different path try: _POINT_GEOMETRY = QgsWkbTypes.GeometryType.PointGeometry # PyQt6 / QGIS 4 _ADD_FEATURES_CAP = QgsVectorDataProvider.Capability.AddFeatures + _DELETE_FEATURES_CAP = QgsVectorDataProvider.Capability.DeleteFeatures except AttributeError: _POINT_GEOMETRY = QgsWkbTypes.PointGeometry # PyQt5 / QGIS 3 _ADD_FEATURES_CAP = QgsVectorDataProvider.AddFeatures + _DELETE_FEATURES_CAP = QgsVectorDataProvider.DeleteFeatures MENU = 'Profile Interpreter' @@ -105,6 +108,35 @@ def _next_feature_id(layer): return max_id + 1 +def _can_delete(layer): + """True if features can be removed from layer's provider.""" + if layer is None: + return False + caps = layer.dataProvider().capabilities() + return bool(caps & _DELETE_FEATURES_CAP) + + +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 + return feature.id() + + +def _find_panel_toolbar(canvas, main_window): + """First QToolBar in the panel holding canvas, or None. + + Walks up from the canvas and stops at the main window so the plugin never + lands its action on one of the main QGIS toolbars. + """ + widget = canvas.parentWidget() + while widget is not None and widget is not main_window: + bars = widget.findChildren(QToolBar) + if bars: + return bars[0] + widget = widget.parentWidget() + return None + + class _ProfilePickTool(QgsPlotTool): """A plot tool that forwards left-button releases to a callback.""" @@ -127,6 +159,9 @@ class ProfileInterpreterPlugin: self._pick_tool = None self._pan_tool = None self._layer = None + self._undo_action = None + self._panel_toolbar = None + self._history = [] def initGui(self): icon_path = os.path.join(os.path.dirname(__file__), 'icon.svg') @@ -136,11 +171,22 @@ class ProfileInterpreterPlugin: self.iface.addPluginToMenu(MENU, self._action) self.iface.addToolBarIcon(self._action) + self._undo_action = QAction( + QgsApplication.getThemeIcon('mActionUndo.svg'), + 'Undo Point', self.iface.mainWindow()) + self._undo_action.setToolTip('Remove the last interpretation point') + self._undo_action.setEnabled(False) + self._undo_action.triggered.connect(self._undo_last_point) + self.iface.addPluginToMenu(MENU, self._undo_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) self._action = None + self._undo_action = None + self._history.clear() def _set_active(self, active): if not active: @@ -162,6 +208,7 @@ class ProfileInterpreterPlugin: self._canvas = canvas self._pick_tool = _ProfilePickTool(canvas, self._on_pick) canvas.setTool(self._pick_tool) + self._attach_undo_action(canvas) self.iface.messageBar().pushMessage( MENU, 'Click on the elevation profile to place interpretation points.', @@ -169,6 +216,7 @@ class ProfileInterpreterPlugin: ) def _deactivate(self): + self._detach_undo_action() if self._canvas is not None and self._pick_tool is not None: if QgsPlotToolPan is not None: self._pan_tool = QgsPlotToolPan(self._canvas) @@ -178,6 +226,63 @@ 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: + return + toolbar = _find_panel_toolbar(canvas, self.iface.mainWindow()) + if toolbar is None: + return + toolbar.addAction(self._undo_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) + self._panel_toolbar = None + + def _refresh_undo_state(self): + if self._undo_action is not None: + self._undo_action.setEnabled(bool(self._history)) + + def _undo_last_point(self): + """Delete the most recently placed point, skipping layers since removed.""" + project = QgsProject.instance() + while self._history: + layer, feature_id = self._history.pop() + if project.mapLayer(layer.id()) is None: + continue + if not _can_delete(layer): + self.iface.messageBar().pushMessage( + MENU, + f'Layer "{layer.name()}" does not support deleting features.', + level=Qgis.Warning, + ) + break + if not layer.dataProvider().deleteFeatures([feature_id]): + self.iface.messageBar().pushMessage( + MENU, + 'Failed to remove the last point.', + level=Qgis.Warning, + ) + break + layer.updateExtents() + layer.triggerRepaint() + if self._canvas is not None and hasattr(self._canvas, 'refresh'): + self._canvas.refresh() + QgsMessageLog.logMessage( + f'removed point {feature_id} from "{layer.name()}"', + MENU, level=Qgis.Info, + ) + self.iface.messageBar().pushMessage( + MENU, + 'Removed last interpretation point.', + level=Qgis.Success, + duration=2, + ) + break + self._refresh_undo_state() + def _find_profile_canvas(self): main_window = self.iface.mainWindow() canvases = main_window.findChildren(QgsElevationProfileCanvas) @@ -221,7 +326,7 @@ class ProfileInterpreterPlugin: for k, v in attrs.items(): feature[k] = v - ok, _ = layer.dataProvider().addFeatures([feature]) + ok, added = layer.dataProvider().addFeatures([feature]) if not ok: self.iface.messageBar().pushMessage( MENU, @@ -229,6 +334,8 @@ class ProfileInterpreterPlugin: level=Qgis.Warning, ) return + self._history.append((layer, _added_feature_id(added, feature))) + self._refresh_undo_state() layer.updateExtents() layer.triggerRepaint() diff --git a/test/fakeqgis.py b/test/fakeqgis.py @@ -33,11 +33,26 @@ class QIcon: pass +class _FakeSignal: + def __init__(self): + self._callbacks = [] + + def connect(self, cb): + self._callbacks.append(cb) + + def emit(self, *args): + for cb in self._callbacks: + cb(*args) + + class QAction: def __init__(self, *args, **kwargs): self._checkable = False self._checked = False - self._callbacks = [] + self._enabled = True + self._tooltip = '' + self.toggled = _FakeSignal() + self.triggered = _FakeSignal() def setCheckable(self, v): self._checkable = v @@ -45,11 +60,26 @@ class QAction: def setChecked(self, v): self._checked = v - def toggled(self): - pass + def setEnabled(self, v): + self._enabled = v - def connect(self, cb): - self._callbacks.append(cb) + def isEnabled(self): + return self._enabled + + def setToolTip(self, v): + self._tooltip = v + + +class QToolBar: + def __init__(self, parent=None): + self.actions_added = [] + + def addAction(self, action): + self.actions_added.append(action) + + def removeAction(self, action): + if action in self.actions_added: + self.actions_added.remove(action) # ── qgis.core stubs ─────────────────────────────────────────────────────── @@ -60,6 +90,12 @@ class Qgis: Success = 3 +class QgsApplication: + @staticmethod + def getThemeIcon(name): + return QIcon() + + class QgsField: def __init__(self, name, type_=None): self._name = name @@ -81,6 +117,13 @@ class QgsFeature: self._fields = fields if fields is not None else _FakeFields([]) self._attrs = {} self._geom = None + self._id = -1 + + def id(self): + return self._id + + def setId(self, fid): + self._id = fid def fields(self): return self._fields @@ -215,19 +258,23 @@ class QgsWkbTypes: class QgsVectorDataProvider: - AddFeatures = 1 # PyQt5 style + AddFeatures = 1 # PyQt5 style + DeleteFeatures = 2 - class Capability: # PyQt6 style + class Capability: # PyQt6 style AddFeatures = 1 + DeleteFeatures = 2 class _FakeDataProvider: - _fail_next = False # set True in a test to simulate addFeatures failure + _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 def __init__(self, caps, features_store, fields_ref): self._caps = caps self._features = features_store self._fields_ref = fields_ref + self._next_id = 1 def capabilities(self): return self._caps @@ -236,9 +283,20 @@ class _FakeDataProvider: if _FakeDataProvider._fail_next: _FakeDataProvider._fail_next = False return (False, []) + for feature in features: + feature.setId(self._next_id) + self._next_id += 1 self._features.extend(features) return (True, list(features)) + def deleteFeatures(self, ids): + if _FakeDataProvider._fail_next_delete: + _FakeDataProvider._fail_next_delete = False + return False + remaining = [f for f in self._features if f.id() not in ids] + self._features[:] = remaining + return True + def addAttributes(self, attrs): self._fields_ref.extend(attrs) @@ -251,7 +309,7 @@ class QgsVectorLayer: self._geom_type = QgsWkbTypes.PointGeometry self._wkb_type = QgsWkbTypes.PointZ self._provider = _FakeDataProvider( - QgsVectorDataProvider.AddFeatures, + QgsVectorDataProvider.AddFeatures | QgsVectorDataProvider.DeleteFeatures, self._features, self._fields_list, ) @@ -335,6 +393,9 @@ class QgsElevationProfileCanvas: def isVisible(self): return self._visible + def parentWidget(self): + return getattr(self, '_parent', None) + def setTool(self, tool): self._tool = tool @@ -400,7 +461,7 @@ def install(): qgis_pyqt_qtwidgets = ModuleType('qgis.PyQt.QtWidgets') for name in [ - 'Qgis', 'QgsCoordinateTransform', 'QgsFeature', 'QgsField', + 'Qgis', 'QgsApplication', 'QgsCoordinateTransform', 'QgsFeature', 'QgsField', 'QgsGeometry', 'QgsMessageLog', 'QgsPoint', 'QgsPointXY', 'QgsProject', 'QgsVectorDataProvider', 'QgsVectorLayer', 'QgsWkbTypes', ]: @@ -414,6 +475,7 @@ def install(): qgis_pyqt_qtcore.QMetaType = QMetaType qgis_pyqt_qtgui.QIcon = QIcon qgis_pyqt_qtwidgets.QAction = QAction + qgis_pyqt_qtwidgets.QToolBar = QToolBar sys.modules['qgis'] = qgis sys.modules['qgis.core'] = qgis_core diff --git a/test/test_profile_interpreter.py b/test/test_profile_interpreter.py @@ -11,6 +11,9 @@ sys.path.insert(0, _HERE) # test/ dir fakeqgis.install() from profile_interpreter.profile_interpreter import ( # noqa: E402 + _added_feature_id, + _can_delete, + _find_panel_toolbar, _is_suitable_target, _next_feature_id, _point_attributes, @@ -53,6 +56,9 @@ class _FakeCanvas: def crs(self): return self._crs_val if self._crs_val is not None else fq._FakeCrs() + def parentWidget(self): + return getattr(self, '_parent', None) + def setTool(self, tool): self._tool = tool @@ -86,6 +92,8 @@ class _FakeIface: self._active_layer = active_layer self._window = _FakeMainWindow(canvases or []) self._bar = _FakeBar() + self.menu_actions = [] + self.toolbar_actions = [] def activeLayer(self): return self._active_layer @@ -97,16 +105,20 @@ class _FakeIface: return self._bar def addPluginToMenu(self, *args): - pass + self.menu_actions.append(args[-1]) def addToolBarIcon(self, *args): - pass + self.toolbar_actions.append(args[-1]) def removeToolBarIcon(self, *args): - pass + action = args[-1] + if action in self.toolbar_actions: + self.toolbar_actions.remove(action) def removePluginMenu(self, *args): - pass + action = args[-1] + if action in self.menu_actions: + self.menu_actions.remove(action) def _make_plugin(active_layer=None, canvas=None): @@ -482,5 +494,231 @@ class TestDeactivate(unittest.TestCase): self.assertIsNone(plugin._canvas) +# ── _can_delete ─────────────────────────────────────────────────────────── + +class TestCanDelete(unittest.TestCase): + def test_none_rejected(self): + self.assertFalse(_can_delete(None)) + + def test_memory_layer_accepted(self): + layer = fq.QgsVectorLayer('PointZ', 'test', 'memory') + self.assertTrue(_can_delete(layer)) + + def test_provider_without_capability_rejected(self): + layer = fq.QgsVectorLayer('PointZ', 'test', 'memory') + layer._provider._caps = fq.QgsVectorDataProvider.AddFeatures + self.assertFalse(_can_delete(layer)) + + +# ── _added_feature_id ───────────────────────────────────────────────────── + +class TestAddedFeatureId(unittest.TestCase): + def test_prefers_provider_assigned_feature(self): + added = fq.QgsFeature() + added.setId(7) + fallback = fq.QgsFeature() + fallback.setId(1) + self.assertEqual(_added_feature_id([added], fallback), 7) + + def test_falls_back_when_provider_returns_nothing(self): + fallback = fq.QgsFeature() + fallback.setId(3) + self.assertEqual(_added_feature_id([], fallback), 3) + + +# ── _find_panel_toolbar ─────────────────────────────────────────────────── + +class _FakeWidget: + def __init__(self, parent=None, children=None): + self._parent = parent + self._children = children or [] + + def parentWidget(self): + return self._parent + + def findChildren(self, cls): + return [c for c in self._children if isinstance(c, cls)] + + +class TestFindPanelToolbar(unittest.TestCase): + def test_finds_toolbar_in_panel(self): + main_window = _FakeWidget() + toolbar = fq.QToolBar() + panel = _FakeWidget(parent=main_window, children=[toolbar]) + canvas = _FakeCanvas() + canvas._parent = panel + self.assertIs(_find_panel_toolbar(canvas, main_window), toolbar) + + def test_walks_up_until_a_toolbar_is_found(self): + main_window = _FakeWidget() + toolbar = fq.QToolBar() + panel = _FakeWidget(parent=main_window, children=[toolbar]) + inner = _FakeWidget(parent=panel) + canvas = _FakeCanvas() + canvas._parent = inner + self.assertIs(_find_panel_toolbar(canvas, main_window), toolbar) + + def test_stops_at_main_window(self): + main_toolbar = fq.QToolBar() + main_window = _FakeWidget(children=[main_toolbar]) + panel = _FakeWidget(parent=main_window) + canvas = _FakeCanvas() + canvas._parent = panel + self.assertIsNone(_find_panel_toolbar(canvas, main_window)) + + def test_returns_none_without_parent(self): + canvas = _FakeCanvas() + self.assertIsNone(_find_panel_toolbar(canvas, _FakeWidget())) + + +# ── undo action lifecycle ───────────────────────────────────────────────── + +class TestUndoAction(unittest.TestCase): + def setUp(self): + fq.QgsProject._instance = None + fq.QgsGeometry._next_interpolate_empty = False + fq._FakeDataProvider._fail_next = False + fq._FakeDataProvider._fail_next_delete = False + + def _activated_plugin(self): + """Plugin with initGui() run and the pick tool active on a panel canvas.""" + canvas = fq.QgsElevationProfileCanvas() + toolbar = fq.QToolBar() + main_window = _FakeMainWindow([canvas]) + panel = _FakeWidget(parent=main_window, children=[toolbar]) + canvas._parent = panel + + 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_on_init(self): + iface = _FakeIface() + plugin = ProfileInterpreterPlugin(iface) + plugin.initGui() + self.assertIn(plugin._undo_action, iface.menu_actions) + + def test_disabled_until_a_point_is_placed(self): + plugin, _, _ = self._activated_plugin() + self.assertFalse(plugin._undo_action.isEnabled()) + plugin._on_pick(_FakeEvent()) + self.assertTrue(plugin._undo_action.isEnabled()) + + def test_appears_on_panel_toolbar_while_active(self): + plugin, _, toolbar = self._activated_plugin() + self.assertIn(plugin._undo_action, toolbar.actions_added) + + def test_removed_from_panel_toolbar_when_deactivated(self): + plugin, _, toolbar = self._activated_plugin() + plugin._set_active(False) + self.assertNotIn(plugin._undo_action, toolbar.actions_added) + + def test_missing_panel_toolbar_is_tolerated(self): + canvas = fq.QgsElevationProfileCanvas() + main_window = _FakeMainWindow([canvas]) + canvas._parent = _FakeWidget(parent=main_window) + iface = _FakeIface() + iface._window = main_window + plugin = ProfileInterpreterPlugin(iface) + plugin.initGui() + plugin._set_active(True) + self.assertIsNone(plugin._panel_toolbar) + + def test_menu_entry_removed_on_unload(self): + plugin, iface, _ = self._activated_plugin() + undo_action = plugin._undo_action + plugin.unload() + self.assertNotIn(undo_action, iface.menu_actions) + + +# ── _undo_last_point ────────────────────────────────────────────────────── + +class TestUndoLastPoint(unittest.TestCase): + def setUp(self): + fq.QgsProject._instance = None + fq.QgsGeometry._next_interpolate_empty = False + fq._FakeDataProvider._fail_next = False + fq._FakeDataProvider._fail_next_delete = False + + def test_removes_last_placed_feature(self): + plugin, _ = _make_plugin() + plugin._on_pick(_FakeEvent()) + plugin._on_pick(_FakeEvent()) + layer = plugin._layer + plugin._undo_last_point() + self.assertEqual([f._attrs['id'] for f in layer._features], [1]) + + def test_repeated_undo_empties_the_layer(self): + plugin, _ = _make_plugin() + plugin._on_pick(_FakeEvent()) + plugin._on_pick(_FakeEvent()) + layer = plugin._layer + plugin._undo_last_point() + plugin._undo_last_point() + self.assertEqual(layer._features, []) + self.assertEqual(plugin._history, []) + + def test_undo_with_empty_history_is_a_no_op(self): + plugin, _ = _make_plugin() + plugin._undo_last_point() + self.assertEqual(plugin._history, []) + + def test_ids_reused_after_undo(self): + plugin, _ = _make_plugin() + plugin._on_pick(_FakeEvent()) + plugin._on_pick(_FakeEvent()) + plugin._undo_last_point() + plugin._on_pick(_FakeEvent()) + self.assertEqual([f._attrs['id'] for f in plugin._layer._features], [1, 2]) + + def test_canvas_refreshed_after_undo(self): + canvas = _FakeCanvas() + plugin, _ = _make_plugin(canvas=canvas) + plugin._on_pick(_FakeEvent()) + plugin._undo_last_point() + self.assertEqual(canvas.refresh_count, 2) + + def test_layer_removed_from_project_is_skipped(self): + plugin, _ = _make_plugin() + plugin._on_pick(_FakeEvent()) + plugin._on_pick(_FakeEvent()) + layer = plugin._layer + fq.QgsProject.instance()._layers.pop(layer.id()) + plugin._undo_last_point() + # both history entries pointed at the vanished layer; nothing deleted + self.assertEqual(len(layer._features), 2) + self.assertEqual(plugin._history, []) + + def test_read_only_layer_warns_and_keeps_feature(self): + plugin, iface = _make_plugin() + bar = _TrackingBar() + iface._bar = bar + plugin._on_pick(_FakeEvent()) + layer = plugin._layer + layer._provider._caps = fq.QgsVectorDataProvider.AddFeatures + plugin._undo_last_point() + self.assertEqual(len(layer._features), 1) + self.assertTrue(any(level == fq.Qgis.Warning for _, _, level in bar.messages)) + + def test_delete_failure_warns(self): + plugin, iface = _make_plugin() + bar = _TrackingBar() + iface._bar = bar + plugin._on_pick(_FakeEvent()) + fq._FakeDataProvider._fail_next_delete = True + plugin._undo_last_point() + self.assertEqual(len(plugin._layer._features), 1) + self.assertTrue(any(level == fq.Qgis.Warning for _, _, level in bar.messages)) + + def test_failed_pick_adds_no_history(self): + plugin, _ = _make_plugin() + fq._FakeDataProvider._fail_next = True + plugin._on_pick(_FakeEvent()) + self.assertEqual(plugin._history, []) + + if __name__ == '__main__': unittest.main()