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 ce370b652a5ca8eae63504a2cbda1f8ed4f09157
parent ed41dcd7e4f1ba93c9dc20a8c56a5d82a3525047
Author: Anders Damsgaard <anders@adamsgaard.dk>
Date:   Thu, 22 Oct 2026 09:09:54 +0000

feat(gui): place and move points at the profile snap position (thanks Malte Mau)

Diffstat:
MREADME.md | 4+++-
Mprofile_interpreter/profile_interpreter.py | 11+++++++++--
Mtest/fakeqgis.py | 11+++++++++--
Mtest/test_profile_interpreter.py | 58+++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
4 files changed, 78 insertions(+), 6 deletions(-)

diff --git a/README.md b/README.md @@ -86,7 +86,9 @@ altered). Otherwise points are stored in a memory layer named panel's toolbar in the Qt widget tree. If a QGIS build lays the panel out 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. +- Placing and moving points follow the profile's snap toggle (magnet icon): + with snapping on, the point lands on the snapped crosshair position. + Selecting a point for **Edit Point** or **Delete Point** uses the raw cursor. ## Development diff --git a/profile_interpreter/profile_interpreter.py b/profile_interpreter/profile_interpreter.py @@ -7,7 +7,7 @@ geophysical layer tops, ...) directly on a profile view. The conversion path is: - click position (canvas pixels) + click position (canvas pixels, snapped when profile snapping is on) -> QgsElevationProfileCanvas.canvasPointToPlotPoint() -> QgsProfilePoint(distance_along_section, elevation) -> interpolate XY along the profile curve at `distance` @@ -150,6 +150,13 @@ def _added_feature_id(added, fallback): return feature.id() +def _event_canvas_point(event): + """Canvas position of event, snapped to the plot when profile snapping is on.""" + snapped = getattr(event, 'snappedPoint', None) + point = snapped() if snapped is not None else event.pos() + return QPointF(point.x(), point.y()) + + def _find_panel_toolbar(canvas, main_window): """First QToolBar in the panel holding canvas, or None. @@ -410,7 +417,7 @@ class ProfileInterpreterPlugin: 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())) + profile_point = canvas.canvasPointToPlotPoint(_event_canvas_point(event)) distance = profile_point.distance() elevation = profile_point.elevation() if math.isnan(distance) or math.isnan(elevation): diff --git a/test/fakeqgis.py b/test/fakeqgis.py @@ -17,8 +17,15 @@ class Qt: class QPointF: - def __init__(self, *args): - pass + def __init__(self, x=0.0, y=0.0): + self._x = x + self._y = y + + def x(self): + return self._x + + def y(self): + return self._y class QMetaType: diff --git a/test/test_profile_interpreter.py b/test/test_profile_interpreter.py @@ -14,6 +14,7 @@ from profile_interpreter.profile_interpreter import ( # noqa: E402 _added_feature_id, _can_delete, _can_move, + _event_canvas_point, _find_panel_toolbar, _is_suitable_target, _nearest_hit, @@ -31,9 +32,10 @@ import fakeqgis as fq # noqa: E402 # ── test helpers ────────────────────────────────────────────────────────── class _FakeEvent: - def __init__(self, button=None, x=0.0, y=0.0): + def __init__(self, button=None, x=0.0, y=0.0, snapped=None): self._button = button if button is not None else _LEFT_BUTTON self._pos = fq.QgsPointXY(x, y) + self._snapped = fq.QgsPointXY(*snapped) if snapped is not None else self._pos def button(self): return self._button @@ -41,6 +43,17 @@ class _FakeEvent: def pos(self): return self._pos + def snappedPoint(self): + return self._snapped + + +class _RawEvent: + def __init__(self, x=0.0, y=0.0): + self._pos = fq.QgsPointXY(x, y) + + def pos(self): + return self._pos + class _FakeCanvas: def __init__(self, distance=10.0, elevation=5.0, curve='default', crs=None): @@ -50,8 +63,10 @@ class _FakeCanvas: self._curve = fq._FakeCurve() if curve == 'default' else curve self._tool = None self._crs_val = crs + self.last_canvas_point = None def canvasPointToPlotPoint(self, pt): + self.last_canvas_point = (pt.x(), pt.y()) return fq._FakeProfilePoint(self._distance, self._elevation) def plotPointToCanvasPoint(self, point): @@ -938,6 +953,47 @@ class TestEditPoint(_ModeTestCase): self.assertIsNone(plugin._layer) +class TestSnapping(_ModeTestCase): + def test_event_point_uses_snapped_position(self): + point = _event_canvas_point(_FakeEvent(x=1.0, y=2.0, snapped=(3.0, 4.0))) + self.assertEqual((point.x(), point.y()), (3.0, 4.0)) + + def test_event_point_falls_back_to_pos(self): + point = _event_canvas_point(_RawEvent(x=1.0, y=2.0)) + self.assertEqual((point.x(), point.y()), (1.0, 2.0)) + + def test_add_converts_snapped_point(self): + canvas = _FakeCanvas() + plugin, _ = self._plugin(canvas=canvas) + plugin._on_pick(_FakeEvent(x=1.0, y=2.0, snapped=(3.0, 4.0))) + self.assertEqual(canvas.last_canvas_point, (3.0, 4.0)) + self.assertEqual(len(plugin._layer._features), 1) + + def test_move_converts_snapped_point(self): + layer = _layer_with_points([(30.0, 5.0)]) + canvas = _FakeCanvas() + plugin, _ = self._plugin(layer, canvas) + plugin._edit_action.setChecked(True) + plugin._on_pick(_FakeEvent(x=30.0, y=5.0)) + plugin._on_pick(_FakeEvent(x=1.0, y=2.0, snapped=(3.0, 4.0))) + self.assertEqual(canvas.last_canvas_point, (3.0, 4.0)) + self.assertIsNone(plugin._selected) + + def test_select_for_edit_uses_raw_cursor(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, snapped=(200.0, 200.0))) + self.assertEqual(plugin._selected, (layer, 1)) + + def test_delete_uses_raw_cursor(self): + layer = _layer_with_points([(30.0, 5.0)]) + plugin, _ = self._plugin(layer) + plugin._delete_action.setChecked(True) + plugin._on_pick(_FakeEvent(x=30.0, y=5.0, snapped=(200.0, 200.0))) + self.assertEqual(layer._features, []) + + class TestDeletePoint(_ModeTestCase): def test_deletes_point_under_click(self): layer = _layer_with_points([(30.0, 5.0), (60.0, 5.0)])