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 8d8ee742750950607771532de7378b381bd2972e
parent 3434bfe611b8a12b595d90496b94a6fb211688bb
Author: Anders Damsgaard <anders@adamsgaard.dk>
Date:   Fri,  5 Jun 2026 21:30:07 +0300

feat: transform geometry into target layer CRS on pick (A1)

Add _project_xy helper that reprojects the horizontal XY position from
the profile canvas CRS to the target layer's CRS before building the
feature geometry. Elevation (Z) is left untransformed — it is a vertical
measurement, not a 3D coordinate.

Also add QgsCoordinateTransform, QgsPointXY, QgsMessageLog stubs to
fakeqgis, add crs()/getFeatures() to QgsVectorLayer, and parameterise
_FakeCrs with __eq__/__hash__ so same-authid CRS equality is testable.
Adds T2 (unit tests for _project_xy) and T3 (integration: different-CRS
active layer produces transformed geometry).

Diffstat:
Mprofile_interpreter/profile_interpreter.py | 17+++++++++++++++--
Mtest/fakeqgis.py | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mtest/test_profile_interpreter.py | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
3 files changed, 135 insertions(+), 6 deletions(-)

diff --git a/profile_interpreter/profile_interpreter.py b/profile_interpreter/profile_interpreter.py @@ -26,10 +26,12 @@ except AttributeError: from qgis.core import ( Qgis, + QgsCoordinateTransform, QgsFeature, QgsField, QgsGeometry, QgsPoint, + QgsPointXY, QgsProject, QgsVectorDataProvider, QgsVectorLayer, @@ -73,6 +75,16 @@ def _point_attributes(field_names, feature_id, distance, elevation): return {k: v for k, v in values.items() if k in field_names} +def _project_xy(xy, src_crs, dst_crs): + """Map x/y from src_crs to dst_crs; identity if either CRS is invalid or they are equal.""" + if (not src_crs.isValid() or not dst_crs.isValid() + or src_crs == dst_crs): + return xy.x(), xy.y() + ct = QgsCoordinateTransform(src_crs, dst_crs, QgsProject.instance()) + p = ct.transform(QgsPointXY(xy.x(), xy.y())) + return p.x(), p.y() + + class _ProfilePickTool(QgsPlotTool): """A plot tool that forwards left-button releases to a callback.""" @@ -175,8 +187,9 @@ class ProfileInterpreterPlugin: xy = point_geom.asPoint() layer = self._target_layer(canvas.crs()) + x, y = _project_xy(xy, canvas.crs(), layer.crs()) feature = QgsFeature(layer.fields()) - feature.setGeometry(QgsGeometry(QgsPoint(xy.x(), xy.y(), elevation))) + feature.setGeometry(QgsGeometry(QgsPoint(x, y, elevation))) current_id = self._next_id attrs = _point_attributes( @@ -197,7 +210,7 @@ class ProfileInterpreterPlugin: print( f'{MENU}: placed point #{current_id} ' - f'at x={xy.x():.3f} y={xy.y():.3f} z={elevation:.3f} ' + f'at x={x:.3f} y={y:.3f} z={elevation:.3f} ' f'(distance={distance:.3f})' ) self.iface.messageBar().pushMessage( diff --git a/test/fakeqgis.py b/test/fakeqgis.py @@ -118,6 +118,18 @@ class _FakeXY: return self._y +class QgsPointXY: + 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 QgsGeometry: # Set to True in tests to make interpolate() return an empty geometry. _next_interpolate_empty = False @@ -150,6 +162,36 @@ class QgsPoint: self._z = z +class QgsCoordinateTransform: + _calls = [] # (src_authid, dst_authid, in_x, in_y) + + def __init__(self, src, dst, project): + self._src = src + self._dst = dst + + def transform(self, pt): + QgsCoordinateTransform._calls.append( + (self._src.authid(), self._dst.authid(), pt.x(), pt.y()) + ) + return QgsPointXY(pt.x() + 1000.0, pt.y() + 1000.0) + + @classmethod + def reset(cls): + cls._calls.clear() + + +class QgsMessageLog: + _log = [] + + @classmethod + def logMessage(cls, msg, tag='', level=None): + cls._log.append((tag, msg, level)) + + @classmethod + def reset(cls): + cls._log.clear() + + class QgsWkbTypes: PointZ = 1001 Point = 1 @@ -222,6 +264,12 @@ class QgsVectorLayer: def fields(self): return _FakeFields(self._fields_list) + def crs(self): + return getattr(self, '_crs', _FakeCrs()) + + def getFeatures(self): + return iter(self._features) + def updateExtents(self): pass @@ -314,11 +362,22 @@ class _FakeCurve: class _FakeCrs: + def __init__(self, authid='EPSG:4326'): + self._authid = authid + def isValid(self): return True def authid(self): - return 'EPSG:4326' + return self._authid + + def __eq__(self, other): + if not isinstance(other, _FakeCrs): + return NotImplemented + return self._authid == other._authid + + def __hash__(self): + return hash(self._authid) # ── installer ───────────────────────────────────────────────────────────── @@ -333,7 +392,8 @@ def install(): qgis_pyqt_qtwidgets = ModuleType('qgis.PyQt.QtWidgets') for name in [ - 'Qgis', 'QgsFeature', 'QgsField', 'QgsGeometry', 'QgsPoint', + 'Qgis', 'QgsCoordinateTransform', 'QgsFeature', 'QgsField', + 'QgsGeometry', 'QgsMessageLog', 'QgsPoint', 'QgsPointXY', '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,6 +13,7 @@ fakeqgis.install() from profile_interpreter.profile_interpreter import ( # noqa: E402 _is_suitable_target, _point_attributes, + _project_xy, _LEFT_BUTTON, _ProfilePickTool, ProfileInterpreterPlugin, @@ -34,12 +35,13 @@ class _FakeEvent: class _FakeCanvas: - def __init__(self, distance=10.0, elevation=5.0, curve='default'): + def __init__(self, distance=10.0, elevation=5.0, curve='default', crs=None): self.refresh_count = 0 self._distance = distance self._elevation = elevation self._curve = fq._FakeCurve() if curve == 'default' else curve self._tool = None + self._crs_val = crs def canvasPointToPlotPoint(self, pt): return fq._FakeProfilePoint(self._distance, self._elevation) @@ -48,7 +50,7 @@ class _FakeCanvas: return self._curve def crs(self): - return fq._FakeCrs() + return self._crs_val if self._crs_val is not None else fq._FakeCrs() def setSnappingEnabled(self, v): pass @@ -131,6 +133,37 @@ class TestPointAttributes(unittest.TestCase): self.assertNotIn('note', result) +# ── _project_xy ────────────────────────────────────────────────────────── + +class TestProjectXY(unittest.TestCase): + def setUp(self): + fq.QgsCoordinateTransform.reset() + + def _xy(self, x=100.0, y=200.0): + return fq._FakeXY(x, y) + + def test_same_crs_object_returns_identity(self): + crs = fq._FakeCrs('EPSG:4326') + x, y = _project_xy(self._xy(), crs, crs) + self.assertEqual((x, y), (100.0, 200.0)) + self.assertEqual(fq.QgsCoordinateTransform._calls, []) + + def test_equal_crs_authid_returns_identity(self): + src = fq._FakeCrs('EPSG:4326') + dst = fq._FakeCrs('EPSG:4326') + x, y = _project_xy(self._xy(), src, dst) + self.assertEqual((x, y), (100.0, 200.0)) + self.assertEqual(fq.QgsCoordinateTransform._calls, []) + + def test_different_crs_transforms_xy(self): + src = fq._FakeCrs('EPSG:4326') + dst = fq._FakeCrs('EPSG:32632') + x, y = _project_xy(self._xy(), src, dst) + self.assertAlmostEqual(x, 1100.0) + self.assertAlmostEqual(y, 1200.0) + self.assertEqual(len(fq.QgsCoordinateTransform._calls), 1) + + # ── _is_suitable_target ─────────────────────────────────────────────────── class TestIsSuitableTarget(unittest.TestCase): @@ -202,6 +235,7 @@ class TestOnPickSuccess(unittest.TestCase): def setUp(self): fq.QgsProject._instance = None fq.QgsGeometry._next_interpolate_empty = False + fq.QgsCoordinateTransform.reset() def test_uses_memory_layer_when_no_suitable_active(self): plugin, _ = _make_plugin(active_layer=None) @@ -257,6 +291,28 @@ class TestOnPickSuccess(unittest.TestCase): plugin._on_pick(_FakeEvent()) self.assertEqual(canvas.refresh_count, 0) + def test_active_layer_different_crs_transforms_geometry(self): + active = fq.QgsVectorLayer('PointZ', 'my_layer', 'memory') + active._crs = fq._FakeCrs('EPSG:32632') + canvas = _FakeCanvas(crs=fq._FakeCrs('EPSG:4326')) + plugin, _ = _make_plugin(active_layer=active, canvas=canvas) + plugin._on_pick(_FakeEvent()) + self.assertEqual(len(fq.QgsCoordinateTransform._calls), 1) + feature = active._features[0] + self.assertAlmostEqual(feature._geom._obj._x, 1100.0) + self.assertAlmostEqual(feature._geom._obj._y, 1200.0) + + def test_same_crs_no_transform(self): + active = fq.QgsVectorLayer('PointZ', 'my_layer', 'memory') + active._crs = fq._FakeCrs('EPSG:4326') + canvas = _FakeCanvas(crs=fq._FakeCrs('EPSG:4326')) + plugin, _ = _make_plugin(active_layer=active, canvas=canvas) + plugin._on_pick(_FakeEvent()) + self.assertEqual(fq.QgsCoordinateTransform._calls, []) + feature = active._features[0] + self.assertAlmostEqual(feature._geom._obj._x, 100.0) + self.assertAlmostEqual(feature._geom._obj._y, 200.0) + # ── _ProfilePickTool ──────────────────────────────────────────────────────