commit fe78528a985140c234580b2f9b093cb5fc6a6ed3
Author: Anders Damsgaard <anders@adamsgaard.dk>
Date: Thu, 4 Jun 2026 20:59:33 +0300
feat: initial proof-of-concept implementation
Diffstat:
9 files changed, 366 insertions(+), 0 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -0,0 +1,10 @@
+.artifacts/
+
+# Generated plugin/package outputs
+*.zip
+
+# Local/demo media and documentation artifacts
+*.mov
+*.mp4
+*.webloc
+*.png
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
@@ -0,0 +1,26 @@
+stages:
+ - test
+ - package
+
+default:
+ image: python:3.12
+
+test:
+ stage: test
+ before_script:
+ - python -m pip install --upgrade pip
+ - python -m pip install flake8
+ script:
+ - make verify
+
+package:
+ stage: package
+ before_script:
+ - apt-get update
+ - apt-get install --yes zip
+ script:
+ - make package
+ artifacts:
+ paths:
+ - profile_interpreter.zip
+ expire_in: 1 week
diff --git a/LICENSE b/LICENSE
@@ -0,0 +1,15 @@
+ISC License
+
+Copyright (c) 2026 Anders Damsgaard <anders@andersdamsgaard.dk>
+
+Permission to use, copy, modify, and/or distribute this software for any
+purpose with or without fee is hereby granted, provided that the above
+copyright notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
diff --git a/Makefile b/Makefile
@@ -0,0 +1,19 @@
+PLUGIN_NAME = profile_interpreter
+ZIPFILE = $(PLUGIN_NAME).zip
+PYTHON ?= python3
+FLAKE8 ?= flake8
+LINT_TARGETS = $(PLUGIN_NAME)
+
+.PHONY: package clean lint verify
+
+lint:
+ $(FLAKE8) $(LINT_TARGETS)
+
+verify: lint
+
+package:
+ rm -f $(ZIPFILE)
+ zip -r $(ZIPFILE) $(PLUGIN_NAME)/ -x '*/__pycache__/*' '*/__pycache__/' '*/.DS_Store'
+
+clean:
+ rm -f $(ZIPFILE)
diff --git a/README.md b/README.md
@@ -0,0 +1,78 @@
+# Profile Interpreter
+
+A QGIS 3.40+ plugin (proof of concept) for placing 3D interpretation points by
+clicking directly on the **Elevation Profile** view.
+
+It is data-agnostic: use it to digitize geological boundaries, borehole picks,
+geophysical layer tops, or any other interpretation on a profile/cross-section
+view — for example the profiles produced by the
+[TEM Loader](https://gitlab.com/qgeomodel/qgis-tem-loader) plugin.
+
+> [!IMPORTANT]
+> This is an early proof of concept. It demonstrates the core interaction and
+> coordinate math; it is not yet a finished interpretation tool. See
+> [Limitations](#limitations-proof-of-concept-scope).
+
+## How it works
+
+When you click on the elevation profile, the plugin converts the click into a
+real-world 3D coordinate:
+
+```
+click position (canvas pixels)
+ -> QgsElevationProfileCanvas.canvasPointToPlotPoint()
+ -> QgsProfilePoint(distance_along_section, elevation)
+ -> interpolate X/Y along the profile curve at `distance`
+ -> QgsPoint(x, y, z = elevation) -> PointZ feature
+```
+
+The clicked vertical position becomes the feature's elevation (Z), and the
+horizontal position is mapped back to a true map X/Y along the cross-section
+line. Points are stored in a memory layer named **Profile interpretations**
+with `id`, `distance`, `elevation`, and `note` fields.
+
+## Requirements
+
+- QGIS 3.40.0 or later
+- Python 3 (bundled with QGIS)
+
+## Installation
+
+1. Build the zip: `make package`.
+2. In QGIS: **Plugins > Manage and Install Plugins > Install from ZIP**.
+3. Select `profile_interpreter.zip` and click **Install Plugin**.
+
+## Usage
+
+1. Load data that supports elevation profiles (e.g. TEM Loader layers).
+2. Open the profile panel: **View > Elevation Profile**, then draw a profile
+ line across your data so the section renders.
+3. Toggle **Interpret on Profile** (toolbar button or **Plugins > Profile
+ Interpreter**) on.
+4. Click on the profile where you interpret a boundary. A PointZ feature is
+ added to the **Profile interpretations** layer, and its X/Y/Z is printed to
+ the Python console and shown in the message bar.
+5. Toggle the tool off to return to normal pan/zoom on the profile.
+
+## Limitations (proof-of-concept scope)
+
+- Interpretation points are stored in an in-memory layer only; there is no
+ GeoPackage persistence or per-boundary classification yet.
+- The plugin grabs the profile canvas by walking the Qt widget tree
+ (`findChildren(QgsElevationProfileCanvas)`); there is no official PyQGIS
+ accessor. With multiple profile docks open it picks the first visible one.
+- No editing/undo of placed points beyond standard layer editing.
+- No snapping configuration UI (cursor snapping is simply enabled).
+
+## Development
+
+```sh
+make lint # run flake8
+make verify # alias for lint
+make package # build profile_interpreter.zip for installation
+make clean # remove the zip
+```
+
+## License
+
+ISC — see [LICENSE](LICENSE).
diff --git a/profile_interpreter/__init__.py b/profile_interpreter/__init__.py
@@ -0,0 +1,3 @@
+def classFactory(iface):
+ from .profile_interpreter import ProfileInterpreterPlugin
+ return ProfileInterpreterPlugin(iface)
diff --git a/profile_interpreter/metadata.txt b/profile_interpreter/metadata.txt
@@ -0,0 +1,22 @@
+[general]
+name=Profile Interpreter
+qgisMinimumVersion=3.40.0
+qgisMaximumVersion=4.99.0
+description=Place 3D interpretation points by clicking on the Elevation Profile view
+version=0.0.1
+author=Anders Damsgaard
+email=anders@andersdamsgaard.dk
+about=Proof-of-concept QGIS plugin for interpreting geophysical sections and
+ borehole data. Adds a tool that captures clicks on the QGIS Elevation
+ Profile panel and places PointZ features at the clicked along-section
+ position and elevation. Useful for digitizing geological boundaries and
+ other interpretation points from profile views such as those produced by
+ the TEM Loader plugin.
+experimental=True
+changelog=
+ 0.0.1
+ * Proof of concept: pick PointZ interpretation features on the
+ Elevation Profile canvas
+tracker=https://gitlab.com/qgeomodel/qgis-profile-interpreter/issues
+repository=https://gitlab.com/qgeomodel/qgis-profile-interpreter
+homepage=https://gitlab.com/qgeomodel/qgis-profile-interpreter
diff --git a/profile_interpreter/profile_interpreter.py b/profile_interpreter/profile_interpreter.py
@@ -0,0 +1,185 @@
+"""Profile Interpreter — proof of concept.
+
+Captures mouse clicks on the QGIS Elevation Profile canvas and places PointZ
+features at the corresponding along-section position and elevation. This lets
+you digitize interpretation points (geological boundaries, borehole picks,
+geophysical layer tops, ...) directly on a profile view.
+
+The conversion path is:
+
+ click position (canvas pixels)
+ -> QgsElevationProfileCanvas.canvasPointToPlotPoint()
+ -> QgsProfilePoint(distance_along_section, elevation)
+ -> interpolate XY along the profile curve at `distance`
+ -> QgsPoint(x, y, z = elevation) -> PointZ feature
+"""
+
+import math
+
+from qgis.PyQt.QtCore import Qt, QPointF
+from qgis.PyQt.QtWidgets import QAction
+
+try:
+ _LEFT_BUTTON = Qt.MouseButton.LeftButton # PyQt6 / QGIS 4
+except AttributeError:
+ _LEFT_BUTTON = Qt.LeftButton # PyQt5 / QGIS 3
+from qgis.core import (
+ Qgis,
+ QgsFeature,
+ QgsField,
+ QgsGeometry,
+ QgsPoint,
+ QgsProject,
+ QgsVectorLayer,
+)
+from qgis.PyQt.QtCore import QMetaType
+from qgis.gui import QgsElevationProfileCanvas, QgsPlotTool
+
+try:
+ from qgis.gui import QgsPlotToolPan
+except ImportError: # older builds expose it under a different path
+ QgsPlotToolPan = None
+
+
+MENU = 'Profile Interpreter'
+LAYER_NAME = 'Profile interpretations'
+
+
+class _ProfilePickTool(QgsPlotTool):
+ """A plot tool that forwards left-button releases to a callback."""
+
+ def __init__(self, canvas, on_pick):
+ super().__init__(canvas, 'Interpret on Profile')
+ self._on_pick = on_pick
+
+ def plotReleaseEvent(self, event):
+ if event.button() != _LEFT_BUTTON:
+ super().plotReleaseEvent(event)
+ return
+ self._on_pick(event)
+
+
+class ProfileInterpreterPlugin:
+ def __init__(self, iface):
+ self.iface = iface
+ self._action = None
+ self._canvas = None
+ 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())
+ self._action.setCheckable(True)
+ self._action.toggled.connect(self._set_active)
+ self.iface.addPluginToMenu(MENU, self._action)
+ self.iface.addToolBarIcon(self._action)
+
+ def unload(self):
+ self._set_active(False)
+ self.iface.removeToolBarIcon(self._action)
+ self.iface.removePluginMenu(MENU, self._action)
+ self._action = None
+
+ def _set_active(self, active):
+ if not active:
+ self._deactivate()
+ return
+
+ canvas = self._find_profile_canvas()
+ if canvas is None:
+ self.iface.messageBar().pushMessage(
+ MENU,
+ 'Open the Elevation Profile panel first '
+ '(View > Elevation Profile), then enable this tool.',
+ level=Qgis.Warning,
+ )
+ if self._action is not None:
+ self._action.setChecked(False)
+ return
+
+ self._canvas = canvas
+ canvas.setSnappingEnabled(True)
+ self._pick_tool = _ProfilePickTool(canvas, self._on_pick)
+ canvas.setTool(self._pick_tool)
+ self.iface.messageBar().pushMessage(
+ MENU,
+ 'Click on the elevation profile to place interpretation points.',
+ level=Qgis.Info,
+ )
+
+ def _deactivate(self):
+ if self._canvas is not None and self._pick_tool is not None:
+ if QgsPlotToolPan is not None:
+ self._pan_tool = QgsPlotToolPan(self._canvas)
+ self._canvas.setTool(self._pan_tool)
+ self._pick_tool = None
+ self._canvas = None
+
+ def _find_profile_canvas(self):
+ main_window = self.iface.mainWindow()
+ canvases = main_window.findChildren(QgsElevationProfileCanvas)
+ for canvas in canvases:
+ if canvas.isVisible():
+ return canvas
+ return canvases[0] if canvases else None
+
+ def _on_pick(self, event):
+ canvas = self._canvas
+ profile_point = canvas.canvasPointToPlotPoint(QPointF(event.pos()))
+ distance = profile_point.distance()
+ elevation = profile_point.elevation()
+ if math.isnan(distance) or math.isnan(elevation):
+ return
+
+ curve = canvas.profileCurve()
+ if curve is None:
+ return
+ point_geom = QgsGeometry(curve.clone()).interpolate(distance)
+ if point_geom.isEmpty():
+ return
+ xy = point_geom.asPoint()
+
+ layer = self._interpretation_layer(canvas.crs())
+ feature = QgsFeature(layer.fields())
+ feature.setGeometry(QgsGeometry(QgsPoint(xy.x(), xy.y(), elevation)))
+ feature['id'] = self._next_id
+ feature['distance'] = distance
+ feature['elevation'] = elevation
+ layer.dataProvider().addFeatures([feature])
+ layer.updateExtents()
+ layer.triggerRepaint()
+ self._next_id += 1
+
+ print(
+ f'{MENU}: placed point #{feature["id"]} '
+ f'at x={xy.x():.3f} y={xy.y():.3f} z={elevation:.3f} '
+ f'(distance={distance:.3f})'
+ )
+ self.iface.messageBar().pushMessage(
+ MENU,
+ f'Point at x={xy.x():.1f}, y={xy.y():.1f}, '
+ f'elevation={elevation:.2f}',
+ level=Qgis.Success,
+ duration=2,
+ )
+
+ def _interpretation_layer(self, crs):
+ project = QgsProject.instance()
+ if self._layer is not None and project.mapLayer(self._layer.id()):
+ return self._layer
+
+ uri = f'PointZ?crs={crs.authid()}' if crs.isValid() else 'PointZ'
+ layer = QgsVectorLayer(uri, LAYER_NAME, 'memory')
+ provider = layer.dataProvider()
+ provider.addAttributes([
+ QgsField('id', QMetaType.Type.Int),
+ QgsField('distance', QMetaType.Type.Double),
+ QgsField('elevation', QMetaType.Type.Double),
+ QgsField('note', QMetaType.Type.QString),
+ ])
+ layer.updateFields()
+ project.addMapLayer(layer)
+ self._layer = layer
+ return layer
diff --git a/setup.cfg b/setup.cfg
@@ -0,0 +1,8 @@
+[flake8]
+max-line-length = 99
+exclude =
+ .artifacts,
+ .git,
+ __pycache__,
+ build,
+ dist