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 fe78528a985140c234580b2f9b093cb5fc6a6ed3
Author: Anders Damsgaard <anders@adamsgaard.dk>
Date:   Thu,  4 Jun 2026 20:59:33 +0300

feat: initial proof-of-concept implementation

Diffstat:
A.gitignore | 10++++++++++
A.gitlab-ci.yml | 26++++++++++++++++++++++++++
ALICENSE | 15+++++++++++++++
AMakefile | 19+++++++++++++++++++
AREADME.md | 78++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aprofile_interpreter/__init__.py | 3+++
Aprofile_interpreter/metadata.txt | 22++++++++++++++++++++++
Aprofile_interpreter/profile_interpreter.py | 185+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asetup.cfg | 8++++++++
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