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:
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)])