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 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:
MREADME.md | 20+++++++++++++++++---
Mprofile_interpreter/profile_interpreter.py | 302++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Mtest/fakeqgis.py | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mtest/test_profile_interpreter.py | 269++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
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()