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 59fb2af11208c12eb4b0d3a93427fa564ec1eab5
parent 8d8ee742750950607771532de7378b381bd2972e
Author: Anders Damsgaard <anders@adamsgaard.dk>
Date:   Fri,  5 Jun 2026 21:33:44 +0300

feat: derive feature id from target layer, drop session counter (A2+T1)

Diffstat:
Mprofile_interpreter/profile_interpreter.py | 27+++++++++++++++++++++------
Mtest/test_profile_interpreter.py | 48++++++++++++++++++++++++++++++++++++++++++------
2 files changed, 63 insertions(+), 12 deletions(-)

diff --git a/profile_interpreter/profile_interpreter.py b/profile_interpreter/profile_interpreter.py @@ -85,6 +85,23 @@ def _project_xy(xy, src_crs, dst_crs): return p.x(), p.y() +def _next_feature_id(layer): + """Next integer id for layer: max existing 'id' + 1, or 1. + Returns None if the layer has no 'id' field.""" + names = {f.name() for f in layer.fields()} + if 'id' not in names: + return None + max_id = 0 + for feat in layer.getFeatures(): + try: + val = feat['id'] + except (KeyError, IndexError): + continue + if val is not None and int(val) > max_id: + max_id = int(val) + return max_id + 1 + + class _ProfilePickTool(QgsPlotTool): """A plot tool that forwards left-button releases to a callback.""" @@ -107,7 +124,6 @@ class ProfileInterpreterPlugin: self._pick_tool = None self._pan_tool = None self._layer = None - self._next_id = 1 def initGui(self): self._action = QAction('Interpret on Profile', self.iface.mainWindow()) @@ -191,10 +207,10 @@ class ProfileInterpreterPlugin: feature = QgsFeature(layer.fields()) feature.setGeometry(QgsGeometry(QgsPoint(x, y, elevation))) - current_id = self._next_id + feature_id = _next_feature_id(layer) attrs = _point_attributes( {f.name() for f in layer.fields()}, - current_id, distance, elevation, + feature_id, distance, elevation, ) for k, v in attrs.items(): feature[k] = v @@ -206,10 +222,9 @@ class ProfileInterpreterPlugin: if hasattr(canvas, 'refresh'): canvas.refresh() - self._next_id += 1 - + id_label = f' #{feature_id}' if feature_id is not None else '' print( - f'{MENU}: placed point #{current_id} ' + f'{MENU}: placed point{id_label} ' f'at x={x:.3f} y={y:.3f} z={elevation:.3f} ' f'(distance={distance:.3f})' ) diff --git a/test/test_profile_interpreter.py b/test/test_profile_interpreter.py @@ -12,6 +12,7 @@ fakeqgis.install() from profile_interpreter.profile_interpreter import ( # noqa: E402 _is_suitable_target, + _next_feature_id, _point_attributes, _project_xy, _LEFT_BUTTON, @@ -164,6 +165,38 @@ class TestProjectXY(unittest.TestCase): self.assertEqual(len(fq.QgsCoordinateTransform._calls), 1) +# ── _next_feature_id ───────────────────────────────────────────────────── + +class TestNextFeatureId(unittest.TestCase): + def _make_layer_with_ids(self, id_values): + layer = fq.QgsVectorLayer('PointZ', 'test', 'memory') + layer._fields_list.append(fq.QgsField('id')) + for v in id_values: + feat = fq.QgsFeature(fq._FakeFields(layer._fields_list)) + feat['id'] = v + layer._features.append(feat) + return layer + + def test_empty_layer_returns_1(self): + layer = self._make_layer_with_ids([]) + self.assertEqual(_next_feature_id(layer), 1) + + def test_max_id_plus_one(self): + layer = self._make_layer_with_ids([1, 2, 5]) + self.assertEqual(_next_feature_id(layer), 6) + + def test_no_id_field_returns_none(self): + layer = fq.QgsVectorLayer('PointZ', 'test', 'memory') + self.assertIsNone(_next_feature_id(layer)) + + def test_absent_key_on_feature_is_skipped(self): + layer = fq.QgsVectorLayer('PointZ', 'test', 'memory') + layer._fields_list.append(fq.QgsField('id')) + # feature with no 'id' set in _attrs → KeyError → skipped, falls back to 1 + layer._features.append(fq.QgsFeature(fq._FakeFields(layer._fields_list))) + self.assertEqual(_next_feature_id(layer), 1) + + # ── _is_suitable_target ─────────────────────────────────────────────────── class TestIsSuitableTarget(unittest.TestCase): @@ -206,19 +239,19 @@ class TestOnPickEarlyExits(unittest.TestCase): canvas = _FakeCanvas(distance=math.nan, elevation=5.0) plugin, _ = _make_plugin(canvas=canvas) self._pick(plugin) - self.assertEqual(plugin._next_id, 1) # not incremented + self.assertIsNone(plugin._layer) def test_nan_elevation_skipped(self): canvas = _FakeCanvas(distance=10.0, elevation=math.nan) plugin, _ = _make_plugin(canvas=canvas) self._pick(plugin) - self.assertEqual(plugin._next_id, 1) + self.assertIsNone(plugin._layer) def test_none_curve_skipped(self): canvas = _FakeCanvas(curve=None) plugin, _ = _make_plugin(canvas=canvas) self._pick(plugin) - self.assertEqual(plugin._next_id, 1) + self.assertIsNone(plugin._layer) def test_empty_interpolated_geom_skipped(self): canvas = _FakeCanvas() @@ -226,7 +259,7 @@ class TestOnPickEarlyExits(unittest.TestCase): fq.QgsGeometry._next_interpolate_empty = True self._pick(plugin) fq.QgsGeometry._next_interpolate_empty = False - self.assertEqual(plugin._next_id, 1) + self.assertIsNone(plugin._layer) # ── _on_pick successful pick ────────────────────────────────────────────── @@ -245,10 +278,13 @@ class TestOnPickSuccess(unittest.TestCase): self.assertIsNotNone(layer) self.assertEqual(len(layer._features), 1) - def test_next_id_increments(self): + def test_feature_ids_do_not_collide(self): plugin, _ = _make_plugin() plugin._on_pick(_FakeEvent()) - self.assertEqual(plugin._next_id, 2) + plugin._on_pick(_FakeEvent()) + layer = plugin._layer + ids = [f._attrs.get('id') for f in layer._features] + self.assertEqual(ids, [1, 2]) def test_memory_layer_attrs_set(self): plugin, _ = _make_plugin(active_layer=None)