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:
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 ──────────────────────────────────────────────────────