From dca2f4590d14ac41e4aa8ab0f65194df4b94efad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Carrillo?= Date: Fri, 26 Apr 2024 16:59:05 +0200 Subject: [PATCH 01/12] [core] Introduce SwissProfileSource, Generator and Results classes to create profiles via profile API --- .../core/profiles/profile_generator.py | 115 ++++++++++++++++++ .../core/profiles/profile_results.py | 82 +++++++++++++ 2 files changed, 197 insertions(+) create mode 100644 swiss_locator/core/profiles/profile_generator.py create mode 100644 swiss_locator/core/profiles/profile_results.py diff --git a/swiss_locator/core/profiles/profile_generator.py b/swiss_locator/core/profiles/profile_generator.py new file mode 100644 index 0000000..dcd1649 --- /dev/null +++ b/swiss_locator/core/profiles/profile_generator.py @@ -0,0 +1,115 @@ +import json + +from qgis.PyQt.QtCore import QEventLoop, QUrl, QUrlQuery +from qgis.PyQt.QtNetwork import QNetworkRequest +from qgis.core import ( + QgsAbstractProfileGenerator, + QgsAbstractProfileSource, + QgsFeedback, + QgsGeometry, + QgsNetworkAccessManager, + QgsPoint, +) + +from swiss_locator.core.profiles.profile_results import SwissProfileResults + +NB_POINTS = '100' # The number of points used for the polyline segmentation. Default “200”. + + +class SwissProfileGenerator(QgsAbstractProfileGenerator): + X = "easting" + Y = "northing" + DISTANCE = "dist" + Z_DICT = "alts" + Z = "DTM2" + + def __init__(self, request): + QgsAbstractProfileGenerator.__init__(self) + self.__request = request + self.__profile_curve = request.profileCurve().clone() if request.profileCurve() else None + self.__results = None # SwissProfileResults() + self.__feedback = QgsFeedback() + + def sourceId(self): + return "swiss-profile" + + def __get_profile_from_rest_api(self): + def url_with_param(url, params) -> str: + url = QUrl(url) + q = QUrlQuery(url) + for key, value in params.items(): + q.addQueryItem(key, value) + url.setQuery(q) + return url + + result = {} + url = "https://api3.geo.admin.ch/rest/services/profile.json" + + geojson = self.__profile_curve.asJson(3) + params = {"geom": geojson, "sr": '2056', "nb_points": NB_POINTS} + url = url_with_param(url, params) + + network_access_manager = QgsNetworkAccessManager.instance() + + req = QNetworkRequest(QUrl(url)) + reply = network_access_manager.get(req) + + loop = QEventLoop() + reply.finished.connect(loop.quit) + loop.exec_() + + if reply.error(): + print(reply.errorString()) + result = {"error": reply.errorString()} + else: + content = reply.readAll() + result = json.loads(str(content, 'utf-8')) + + reply.deleteLater() + + return result + + def __parse_response_point(self, point): + return point[self.X], point[self.Y], point[self.Z_DICT][self.Z], point[self.DISTANCE] + + def generateProfile(self, context): # QgsProfileGenerationContext + if self.__profile_curve is None: + return False + + self.__results = SwissProfileResults() + self.__results.copyPropertiesFromGenerator(self) + + result = self.__get_profile_from_rest_api() + + if "error" in result: + print(result["error"]) + return False + + for point in result: + if self.__feedback.isCanceled(): + return False + + x, y, z, d = self.__parse_response_point(point) + self.__results.raw_points.append(QgsPoint(x, y)) + self.__results.distance_to_height[d] = z + if z < self.__results.min_z: + self.__results.min_z = z + + if z > self.__results.max_z: + self.__results.max_z = z + + self.__results.geometries.append(QgsGeometry(QgsPoint(x, y, z))) + self.__results.cross_section_geometries = QgsGeometry(QgsPoint(d, z)) + + return not self.__feedback.isCanceled() + + def takeResults(self): + return self.__results + + +class SwissProfileSource(QgsAbstractProfileSource): + def __init__(self): + QgsAbstractProfileSource.__init__(self) + + def createProfileGenerator(self, request): + return SwissProfileGenerator(request) diff --git a/swiss_locator/core/profiles/profile_results.py b/swiss_locator/core/profiles/profile_results.py new file mode 100644 index 0000000..cdf752b --- /dev/null +++ b/swiss_locator/core/profiles/profile_results.py @@ -0,0 +1,82 @@ +from qgis.PyQt.QtCore import QRectF, Qt, QPointF +from qgis.PyQt.QtGui import QPainterPath, QPolygonF +from qgis.core import ( + Qgis, + QgsAbstractProfileResults, + QgsDoubleRange, + QgsMarkerSymbol, + QgsProfileRenderContext, +) + + +class SwissProfileResults(QgsAbstractProfileResults): + def __init__(self): + QgsAbstractProfileResults.__init__(self) + + self.__profile_curve = None + self.raw_points = [] # QgsPointSequence + self.__symbology = None + + self.distance_to_height = {} + self.geometries = [] + self.cross_section_geometries = [] + self.min_z = 4500 + self.max_z = -100 + + self.marker_symbol = QgsMarkerSymbol.createSimple( + {'name': 'square', 'size': 2, 'color': '#00ff00', + 'outline_style': 'no'}) + + def asFeatures(self, profile_export_type, feedback): + result = [] + + if type == Qgis.ProfileExportType.Features3D: + for geom in self.geometries: + feature = QgsAbstractProfileResults.Feature() + feature.geometry = geom + result.append(feature) + + elif type == Qgis.ProfileExportType.Profile2D: + for geom in self.cross_section_geometries: + feature = QgsAbstractProfileResults.Feature() + feature.geometry = geom + result.append(feature) + + return result + + def asGeometries(self): + return self.geometries + + def zRange(self): + return QgsDoubleRange(self.min_z, self.max_z) + + def type(self): + return "swiss-web-service" + + def renderResults(self, context: QgsProfileRenderContext): + painter = context.renderContext().painter() + if not painter: + return + + painter.setBrush(Qt.NoBrush) + painter.setPen(Qt.NoPen) + + minDistance = context.distanceRange().lower() + maxDistance = context.distanceRange().upper() + minZ = context.elevationRange().lower() + maxZ = context.elevationRange().upper() + + visibleRegion = QRectF(minDistance, minZ, maxDistance - minDistance, maxZ - minZ) + clipPath = QPainterPath() + clipPath.addPolygon(context.worldTransform().map(QPolygonF(visibleRegion))) + painter.setClipPath(clipPath, Qt.ClipOperation.IntersectClip) + + self.marker_symbol.startRender(context.renderContext()) + + for k, v in self.distance_to_height.items(): + if not v: + continue + + self.marker_symbol.renderPoint(context.worldTransform().map(QPointF(k, v)), None, context.renderContext()) + + self.marker_symbol.stopRender(context.renderContext()) From f5286481e9e7d3ab547be9e137f1153ce00907f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Carrillo?= Date: Fri, 26 Apr 2024 17:01:28 +0200 Subject: [PATCH 02/12] Register custom profile source in QgsProfileSourceRegistry from plugin's initGui() and unregister it from registry in plugin's unload() --- swiss_locator/swiss_locator_plugin.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/swiss_locator/swiss_locator_plugin.py b/swiss_locator/swiss_locator_plugin.py index 9896152..4b56f0a 100644 --- a/swiss_locator/swiss_locator_plugin.py +++ b/swiss_locator/swiss_locator_plugin.py @@ -20,7 +20,7 @@ import os from PyQt5.QtCore import QCoreApplication, QLocale, QSettings, QTranslator from PyQt5.QtWidgets import QWidget -from qgis.core import Qgis, NULL +from qgis.core import Qgis, QgsApplication, NULL from qgis.gui import QgisInterface, QgsMessageBarItem from swiss_locator.core.filters.swiss_locator_filter_feature import ( @@ -36,6 +36,7 @@ from swiss_locator.core.filters.swiss_locator_filter_vector_tiles import ( SwissLocatorFilterVectorTiles, ) +from swiss_locator.core.profiles.profile_generator import SwissProfileSource class SwissLocatorPlugin: @@ -52,6 +53,7 @@ def __init__(self, iface: QgisInterface): QCoreApplication.installTranslator(self.translator) self.locator_filters = [] + self.profile_source = SwissProfileSource() def initGui(self): for _filter in ( @@ -65,11 +67,15 @@ def initGui(self): self.iface.registerLocatorFilter(self.locator_filters[-1]) self.locator_filters[-1].message_emitted.connect(self.show_message) + QgsApplication.profileSourceRegistry().registerProfileSource(self.profile_source) + def unload(self): for locator_filter in self.locator_filters: locator_filter.message_emitted.disconnect(self.show_message) self.iface.deregisterLocatorFilter(locator_filter) + QgsApplication.profileSourceRegistry().unregisterProfileSource(self.profile_source) + def show_message( self, title: str, msg: str, level: Qgis.MessageLevel, widget: QWidget = None ): From 49c4a63fde128b3444c4f0caf1658cc148c54e9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Carrillo?= Date: Mon, 29 Apr 2024 15:16:51 +0200 Subject: [PATCH 03/12] Check QGIS version >= 3.37 (introduction of QgsProfileSourceRegistry) --- swiss_locator/swiss_locator_plugin.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/swiss_locator/swiss_locator_plugin.py b/swiss_locator/swiss_locator_plugin.py index 4b56f0a..d4b845c 100644 --- a/swiss_locator/swiss_locator_plugin.py +++ b/swiss_locator/swiss_locator_plugin.py @@ -67,14 +67,16 @@ def initGui(self): self.iface.registerLocatorFilter(self.locator_filters[-1]) self.locator_filters[-1].message_emitted.connect(self.show_message) - QgsApplication.profileSourceRegistry().registerProfileSource(self.profile_source) + if Qgis.QGIS_VERSION_INT >= 33700: + QgsApplication.profileSourceRegistry().registerProfileSource(self.profile_source) def unload(self): for locator_filter in self.locator_filters: locator_filter.message_emitted.disconnect(self.show_message) self.iface.deregisterLocatorFilter(locator_filter) - QgsApplication.profileSourceRegistry().unregisterProfileSource(self.profile_source) + if Qgis.QGIS_VERSION_INT >= 33700: + QgsApplication.profileSourceRegistry().unregisterProfileSource(self.profile_source) def show_message( self, title: str, msg: str, level: Qgis.MessageLevel, widget: QWidget = None From 4f85d41a6049fd02a7f0f74c1d820c459368e97f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Carrillo?= Date: Mon, 29 Apr 2024 15:19:35 +0200 Subject: [PATCH 04/12] Use blockingGet for the GET request to profile API --- .../core/profiles/profile_generator.py | 21 ++++++------------- swiss_locator/core/profiles/profile_url.py | 9 ++++++++ 2 files changed, 15 insertions(+), 15 deletions(-) create mode 100644 swiss_locator/core/profiles/profile_url.py diff --git a/swiss_locator/core/profiles/profile_generator.py b/swiss_locator/core/profiles/profile_generator.py index dcd1649..f0b1eba 100644 --- a/swiss_locator/core/profiles/profile_generator.py +++ b/swiss_locator/core/profiles/profile_generator.py @@ -1,6 +1,6 @@ import json -from qgis.PyQt.QtCore import QEventLoop, QUrl, QUrlQuery +from qgis.PyQt.QtCore import QUrl, QUrlQuery from qgis.PyQt.QtNetwork import QNetworkRequest from qgis.core import ( QgsAbstractProfileGenerator, @@ -12,8 +12,7 @@ ) from swiss_locator.core.profiles.profile_results import SwissProfileResults - -NB_POINTS = '100' # The number of points used for the polyline segmentation. Default “200”. +from swiss_locator.core.profiles.profile_url import profile_url class SwissProfileGenerator(QgsAbstractProfileGenerator): @@ -43,30 +42,22 @@ def url_with_param(url, params) -> str: return url result = {} - url = "https://api3.geo.admin.ch/rest/services/profile.json" - geojson = self.__profile_curve.asJson(3) - params = {"geom": geojson, "sr": '2056', "nb_points": NB_POINTS} - url = url_with_param(url, params) + base_url, base_params = profile_url(geojson) + url = url_with_param(base_url, base_params) network_access_manager = QgsNetworkAccessManager.instance() req = QNetworkRequest(QUrl(url)) - reply = network_access_manager.get(req) - - loop = QEventLoop() - reply.finished.connect(loop.quit) - loop.exec_() + reply = network_access_manager.blockingGet(req, feedback=self.__feedback) if reply.error(): print(reply.errorString()) result = {"error": reply.errorString()} else: - content = reply.readAll() + content = reply.content() result = json.loads(str(content, 'utf-8')) - reply.deleteLater() - return result def __parse_response_point(self, point): diff --git a/swiss_locator/core/profiles/profile_url.py b/swiss_locator/core/profiles/profile_url.py new file mode 100644 index 0000000..7241f3b --- /dev/null +++ b/swiss_locator/core/profiles/profile_url.py @@ -0,0 +1,9 @@ +def profile_url(geojson: str): + base_url = "https://api3.geo.admin.ch/rest/services/profile.json" + base_params = { + "geom": geojson, + "sr": "2056", + "nb_points": "200", # Number of points used for polyline segmentation. API: 200 + "distinct_points": "true" + } + return base_url, base_params From 23b839bae0927e263d3f5c6b1fe6873b53ad5d42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Carrillo?= Date: Mon, 29 Apr 2024 17:42:35 +0200 Subject: [PATCH 05/12] Make sure profile curve is in EPSG:2056 before sending it to profile REST service --- .../core/profiles/profile_generator.py | 30 +++++++++++++++---- .../core/profiles/profile_results.py | 5 +++- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/swiss_locator/core/profiles/profile_generator.py b/swiss_locator/core/profiles/profile_generator.py index f0b1eba..c295bc7 100644 --- a/swiss_locator/core/profiles/profile_generator.py +++ b/swiss_locator/core/profiles/profile_generator.py @@ -3,10 +3,15 @@ from qgis.PyQt.QtCore import QUrl, QUrlQuery from qgis.PyQt.QtNetwork import QNetworkRequest from qgis.core import ( + Qgis, QgsAbstractProfileGenerator, QgsAbstractProfileSource, + QgsCoordinateReferenceSystem, + QgsCoordinateTransform, + QgsCsException, QgsFeedback, QgsGeometry, + QgsMessageLog, QgsNetworkAccessManager, QgsPoint, ) @@ -26,6 +31,10 @@ def __init__(self, request): QgsAbstractProfileGenerator.__init__(self) self.__request = request self.__profile_curve = request.profileCurve().clone() if request.profileCurve() else None + self.__transformed_curve = None + self.__transformation = QgsCoordinateTransform(request.crs(), # Profile curve's CRS + QgsCoordinateReferenceSystem("EPSG:2056"), + request.transformContext()) self.__results = None # SwissProfileResults() self.__feedback = QgsFeedback() @@ -42,7 +51,7 @@ def url_with_param(url, params) -> str: return url result = {} - geojson = self.__profile_curve.asJson(3) + geojson = self.__transformed_curve.asJson(3) base_url, base_params = profile_url(geojson) url = url_with_param(base_url, base_params) @@ -52,7 +61,6 @@ def url_with_param(url, params) -> str: reply = network_access_manager.blockingGet(req, feedback=self.__feedback) if reply.error(): - print(reply.errorString()) result = {"error": reply.errorString()} else: content = reply.content() @@ -67,13 +75,22 @@ def generateProfile(self, context): # QgsProfileGenerationContext if self.__profile_curve is None: return False + self.__transformed_curve = self.__profile_curve.clone() + try: + self.__transformed_curve.transform(self.__transformation) + except QgsCsException as e: + QgsMessageLog.logMessage("Error transforming profile line to EPSG:2056.", + "Swiss locator", + Qgis.Critical) + return False + self.__results = SwissProfileResults() self.__results.copyPropertiesFromGenerator(self) result = self.__get_profile_from_rest_api() if "error" in result: - print(result["error"]) + QgsMessageLog.logMessage(result["error"], "Swiss locator", Qgis.Critical) return False for point in result: @@ -81,7 +98,10 @@ def generateProfile(self, context): # QgsProfileGenerationContext return False x, y, z, d = self.__parse_response_point(point) - self.__results.raw_points.append(QgsPoint(x, y)) + point_z = QgsPoint(x, y, z) + point_z.transform(self.__transformation, Qgis.TransformDirection.Reverse) + + self.__results.raw_points.append(point_z) self.__results.distance_to_height[d] = z if z < self.__results.min_z: self.__results.min_z = z @@ -89,7 +109,7 @@ def generateProfile(self, context): # QgsProfileGenerationContext if z > self.__results.max_z: self.__results.max_z = z - self.__results.geometries.append(QgsGeometry(QgsPoint(x, y, z))) + self.__results.geometries.append(QgsGeometry(point_z)) self.__results.cross_section_geometries = QgsGeometry(QgsPoint(d, z)) return not self.__feedback.isCanceled() diff --git a/swiss_locator/core/profiles/profile_results.py b/swiss_locator/core/profiles/profile_results.py index cdf752b..4bfe8fa 100644 --- a/swiss_locator/core/profiles/profile_results.py +++ b/swiss_locator/core/profiles/profile_results.py @@ -27,7 +27,7 @@ def __init__(self): {'name': 'square', 'size': 2, 'color': '#00ff00', 'outline_style': 'no'}) - def asFeatures(self, profile_export_type, feedback): + def asFeatures(self, type, feedback): result = [] if type == Qgis.ProfileExportType.Features3D: @@ -47,6 +47,9 @@ def asFeatures(self, profile_export_type, feedback): def asGeometries(self): return self.geometries + def sampledPoints(self): + return self.raw_points + def zRange(self): return QgsDoubleRange(self.min_z, self.max_z) From cc6bd36ce971b95798176829da1d9732094632ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Carrillo?= Date: Wed, 1 May 2024 10:20:37 +0200 Subject: [PATCH 06/12] Profile Generator: make sure feedback() is implemented (layout uses this method), fix cross_section_geometries append --- swiss_locator/core/profiles/profile_generator.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/swiss_locator/core/profiles/profile_generator.py b/swiss_locator/core/profiles/profile_generator.py index c295bc7..ff041f0 100644 --- a/swiss_locator/core/profiles/profile_generator.py +++ b/swiss_locator/core/profiles/profile_generator.py @@ -71,6 +71,9 @@ def url_with_param(url, params) -> str: def __parse_response_point(self, point): return point[self.X], point[self.Y], point[self.Z_DICT][self.Z], point[self.DISTANCE] + def feedback(self): + return self.__feedback + def generateProfile(self, context): # QgsProfileGenerationContext if self.__profile_curve is None: return False @@ -110,7 +113,7 @@ def generateProfile(self, context): # QgsProfileGenerationContext self.__results.max_z = z self.__results.geometries.append(QgsGeometry(point_z)) - self.__results.cross_section_geometries = QgsGeometry(QgsPoint(d, z)) + self.__results.cross_section_geometries.append(QgsGeometry(QgsPoint(d, z))) return not self.__feedback.isCanceled() From fd358e4a2d384250873ceb5a97eaae6b8471a025 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Carrillo?= Date: Wed, 1 May 2024 10:37:26 +0200 Subject: [PATCH 07/12] To get meaningful value in exported 3d and 2d layers, set layerIdentifier as swiss-profile-web-service --- swiss_locator/core/profiles/profile_results.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/swiss_locator/core/profiles/profile_results.py b/swiss_locator/core/profiles/profile_results.py index 4bfe8fa..aa77b55 100644 --- a/swiss_locator/core/profiles/profile_results.py +++ b/swiss_locator/core/profiles/profile_results.py @@ -34,12 +34,14 @@ def asFeatures(self, type, feedback): for geom in self.geometries: feature = QgsAbstractProfileResults.Feature() feature.geometry = geom + feature.layerIdentifier = self.type() result.append(feature) elif type == Qgis.ProfileExportType.Profile2D: for geom in self.cross_section_geometries: feature = QgsAbstractProfileResults.Feature() feature.geometry = geom + feature.layerIdentifier = self.type() result.append(feature) return result @@ -54,7 +56,7 @@ def zRange(self): return QgsDoubleRange(self.min_z, self.max_z) def type(self): - return "swiss-web-service" + return "swiss-profile-web-service" def renderResults(self, context: QgsProfileRenderContext): painter = context.renderContext().painter() From 46e8ea903dcd62fd528095282256f3c3730be76c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Carrillo?= Date: Wed, 1 May 2024 11:18:35 +0200 Subject: [PATCH 08/12] Format profile results to get a proper exported distance/elevation table --- swiss_locator/core/profiles/profile_results.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/swiss_locator/core/profiles/profile_results.py b/swiss_locator/core/profiles/profile_results.py index aa77b55..d25625b 100644 --- a/swiss_locator/core/profiles/profile_results.py +++ b/swiss_locator/core/profiles/profile_results.py @@ -44,6 +44,19 @@ def asFeatures(self, type, feedback): feature.layerIdentifier = self.type() result.append(feature) + elif type == Qgis.ProfileExportType.DistanceVsElevationTable: + for i, geom in enumerate(self.geometries): + feature = QgsAbstractProfileResults.Feature() + feature.geometry = geom + feature.layerIdentifier = self.type() + + # Since we've got distance/elevation pairs as + # x,y for cross-section geometries, and since + # both point arrays have the same length: + p = self.cross_section_geometries[i].asPoint() + feature.attributes = {"distance": p.x(), "elevation": p.y()} + result.append(feature) + return result def asGeometries(self): From c600bef4011e13c4f6d0dea3bfe0e39eb8dd2a04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Carrillo?= Date: Wed, 1 May 2024 12:08:29 +0200 Subject: [PATCH 09/12] Check QGIS >= 3.37 to subclass profile sources (before that version, even if we can subclass it, we won't be able to register it anyways); handle exceptions when reading service responses --- swiss_locator/core/profiles/profile_generator.py | 9 ++++++++- swiss_locator/swiss_locator_plugin.py | 11 +++++++++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/swiss_locator/core/profiles/profile_generator.py b/swiss_locator/core/profiles/profile_generator.py index ff041f0..ff34825 100644 --- a/swiss_locator/core/profiles/profile_generator.py +++ b/swiss_locator/core/profiles/profile_generator.py @@ -64,7 +64,14 @@ def url_with_param(url, params) -> str: result = {"error": reply.errorString()} else: content = reply.content() - result = json.loads(str(content, 'utf-8')) + try: + result = json.loads(str(content, 'utf-8')) + except json.decoder.JSONDecodeError as e: + QgsMessageLog.logMessage( + "Unable to parse results from Profile service. Details: {}".format(e.msg), + "Swiss locator", + Qgis.Critical + ) return result diff --git a/swiss_locator/swiss_locator_plugin.py b/swiss_locator/swiss_locator_plugin.py index d4b845c..4825f5f 100644 --- a/swiss_locator/swiss_locator_plugin.py +++ b/swiss_locator/swiss_locator_plugin.py @@ -36,7 +36,11 @@ from swiss_locator.core.filters.swiss_locator_filter_vector_tiles import ( SwissLocatorFilterVectorTiles, ) -from swiss_locator.core.profiles.profile_generator import SwissProfileSource +try: + from swiss_locator.core.profiles.profile_generator import SwissProfileSource +except ImportError: + # Should fail only for QGIS < 3.26, where profiles weren't available + SwissProfileSource = None class SwissLocatorPlugin: @@ -53,7 +57,10 @@ def __init__(self, iface: QgisInterface): QCoreApplication.installTranslator(self.translator) self.locator_filters = [] - self.profile_source = SwissProfileSource() + + if Qgis.QGIS_VERSION_INT >= 33700: + # Only on QGIS 3.37+ we'll be able to register profile sources + self.profile_source = SwissProfileSource() def initGui(self): for _filter in ( From d5fe4390c745db1ca024fff03a4a14c9e42423e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Carrillo?= Date: Thu, 2 May 2024 17:57:12 +0200 Subject: [PATCH 10/12] [log] Notify profile source registration and unregistration in QgsMessageLog --- swiss_locator/swiss_locator_plugin.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/swiss_locator/swiss_locator_plugin.py b/swiss_locator/swiss_locator_plugin.py index 4825f5f..acb6020 100644 --- a/swiss_locator/swiss_locator_plugin.py +++ b/swiss_locator/swiss_locator_plugin.py @@ -20,7 +20,7 @@ import os from PyQt5.QtCore import QCoreApplication, QLocale, QSettings, QTranslator from PyQt5.QtWidgets import QWidget -from qgis.core import Qgis, QgsApplication, NULL +from qgis.core import Qgis, QgsApplication, QgsMessageLog, NULL from qgis.gui import QgisInterface, QgsMessageBarItem from swiss_locator.core.filters.swiss_locator_filter_feature import ( @@ -76,6 +76,11 @@ def initGui(self): if Qgis.QGIS_VERSION_INT >= 33700: QgsApplication.profileSourceRegistry().registerProfileSource(self.profile_source) + QgsMessageLog.logMessage( + "Swiss profile source has been registered!", + "Swiss locator", + Qgis.Info + ) def unload(self): for locator_filter in self.locator_filters: @@ -84,6 +89,11 @@ def unload(self): if Qgis.QGIS_VERSION_INT >= 33700: QgsApplication.profileSourceRegistry().unregisterProfileSource(self.profile_source) + QgsMessageLog.logMessage( + "Swiss profile source has been unregistered!", + "Swiss locator", + Qgis.Info + ) def show_message( self, title: str, msg: str, level: Qgis.MessageLevel, widget: QWidget = None From 65e1ee7449c9a9ec65f05844738486045029fad9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Carrillo?= Date: Fri, 3 May 2024 16:35:34 +0200 Subject: [PATCH 11/12] Add line and polygon profile symbology --- .../core/profiles/profile_results.py | 94 ++++++++++++++++++- 1 file changed, 89 insertions(+), 5 deletions(-) diff --git a/swiss_locator/core/profiles/profile_results.py b/swiss_locator/core/profiles/profile_results.py index d25625b..b1f89da 100644 --- a/swiss_locator/core/profiles/profile_results.py +++ b/swiss_locator/core/profiles/profile_results.py @@ -4,10 +4,15 @@ Qgis, QgsAbstractProfileResults, QgsDoubleRange, + QgsFillSymbol, + QgsLineSymbol, QgsMarkerSymbol, QgsProfileRenderContext, ) +PROFILE_SYMBOLOGY = Qgis.ProfileSurfaceSymbology.FillBelow +INCLUDE_PROFILE_MARKERS = False + class SwissProfileResults(QgsAbstractProfileResults): def __init__(self): @@ -15,17 +20,27 @@ def __init__(self): self.__profile_curve = None self.raw_points = [] # QgsPointSequence - self.__symbology = None - self.distance_to_height = {} self.geometries = [] self.cross_section_geometries = [] self.min_z = 4500 self.max_z = -100 - self.marker_symbol = QgsMarkerSymbol.createSimple( - {'name': 'square', 'size': 2, 'color': '#00ff00', - 'outline_style': 'no'}) + self.marker_symbol = QgsMarkerSymbol.createSimple({ + 'name': 'square', + 'size': 1, + 'color': '#aeaeae', + 'outline_style': 'no' + }) + self.line_symbol = QgsLineSymbol.createSimple({'color': '#ff0000', + 'width': 0.6}) + self.line_symbol.setOpacity(0.5) + self.fill_symbol = QgsFillSymbol.createSimple({ + 'color': '#ff0000', + 'style': 'solid', + 'outline_style': 'no' + }) + self.fill_symbol.setOpacity(0.5) def asFeatures(self, type, feedback): result = [] @@ -72,6 +87,75 @@ def type(self): return "swiss-profile-web-service" def renderResults(self, context: QgsProfileRenderContext): + self.__render_continuous_surface(context) + if INCLUDE_PROFILE_MARKERS: + self.__render_markers(context) + + def __render_continuous_surface(self, context): + painter = context.renderContext().painter() + if not painter: + return + + painter.setBrush(Qt.NoBrush) + painter.setPen(Qt.NoPen) + + min_distance = context.distanceRange().lower() + max_distance = context.distanceRange().upper() + min_z = context.elevationRange().lower() + max_z = context.elevationRange().upper() + + visible_region = QRectF(min_distance, min_z, max_distance - min_distance, max_z - min_z) + clip_path = QPainterPath() + clip_path.addPolygon(context.worldTransform().map(QPolygonF(visible_region))) + painter.setClipPath(clip_path, Qt.ClipOperation.IntersectClip) + + if PROFILE_SYMBOLOGY == Qgis.ProfileSurfaceSymbology.Line: + self.line_symbol.startRender(context.renderContext()) + elif PROFILE_SYMBOLOGY == Qgis.ProfileSurfaceSymbology.FillBelow: + self.fill_symbol.startRender(context.renderContext()) + + def check_line( + current_line: QPolygonF, + context: QgsProfileRenderContext, + min_z: float, + max_z: float, + prev_distance: float, + current_part_start_distance: float + ): + if len(current_line) > 1: + if PROFILE_SYMBOLOGY == Qgis.ProfileSurfaceSymbology.Line: + self.line_symbol.renderPolyline(current_line, None, context.renderContext()) + elif PROFILE_SYMBOLOGY == Qgis.ProfileSurfaceSymbology.FillBelow: + current_line.append(context.worldTransform().map(QPointF(prev_distance, min_z))) + current_line.append(context.worldTransform().map(QPointF(current_part_start_distance, min_z))) + current_line.append(current_line.at(0)) + self.fill_symbol.renderPolygon(current_line, None, None, context.renderContext()) + + current_line = QPolygonF() + prev_distance = None + current_part_start_distance = 0 + for k, v in self.distance_to_height.items(): + if not len(current_line): # new part + if not v: # skip emptiness + continue + + current_part_start_distance = k + + if not v: + check_line(current_line, context, min_z, max_z, prev_distance, current_part_start_distance) + current_line.clear() + else: + current_line.append(context.worldTransform().map(QPointF(k, v))) + prev_distance = k + + check_line(current_line, context, min_z, max_z, prev_distance, current_part_start_distance) + + if PROFILE_SYMBOLOGY == Qgis.ProfileSurfaceSymbology.Line: + self.line_symbol.stopRender(context.renderContext()) + elif PROFILE_SYMBOLOGY == Qgis.ProfileSurfaceSymbology.FillBelow: + self.fill_symbol.stopRender(context.renderContext()) + + def __render_markers(self, context): painter = context.renderContext().painter() if not painter: return From 692e75dad6f4e2fa4f3167ce3f178fe7dd0012b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Carrillo?= Date: Tue, 7 May 2024 14:09:54 +0200 Subject: [PATCH 12/12] Implement snap to profile curve --- .../core/profiles/profile_results.py | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/swiss_locator/core/profiles/profile_results.py b/swiss_locator/core/profiles/profile_results.py index b1f89da..5679415 100644 --- a/swiss_locator/core/profiles/profile_results.py +++ b/swiss_locator/core/profiles/profile_results.py @@ -7,7 +7,9 @@ QgsFillSymbol, QgsLineSymbol, QgsMarkerSymbol, + QgsProfilePoint, QgsProfileRenderContext, + QgsProfileSnapResult ) PROFILE_SYMBOLOGY = Qgis.ProfileSurfaceSymbology.FillBelow @@ -86,6 +88,29 @@ def zRange(self): def type(self): return "swiss-profile-web-service" + def snapPoint(self, point, context): + result = QgsProfileSnapResult() + + prev_distance = float('inf') + prev_elevation = 0 + for k, v in self.distance_to_height.items(): + # find segment which corresponds to the given distance along curve + if k != 0 and prev_distance <= point.distance() <= k: + dx = k - prev_distance + dy = v - prev_elevation + snapped_z = (dy / dx) * (point.distance() - prev_distance) + prev_elevation + + if abs(point.elevation() - snapped_z) > context.maximumSurfaceElevationDelta: + return QgsProfileSnapResult() + + result.snappedPoint = QgsProfilePoint(point.distance(), snapped_z) + break + + prev_distance = k + prev_elevation = v + + return result + def renderResults(self, context: QgsProfileRenderContext): self.__render_continuous_surface(context) if INCLUDE_PROFILE_MARKERS: