diff --git a/CHANGELOG.md b/CHANGELOG.md index 01e8c0d2..721e8387 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,8 @@ RELEASING: --> ## [Unreleased] +### Added +- Support for Snap endpoint ([#262](https://github.com/GIScience/orstools-qgis-plugin/issues/262)) - Make vertex marker on map drag and droppable, add live preview ([#204](https://github.com/GIScience/orstools-qgis-plugin/issues/204)) ### Added diff --git a/ORStools/help/snap_from_point.help b/ORStools/help/snap_from_point.help new file mode 100644 index 00000000..c3727950 --- /dev/null +++ b/ORStools/help/snap_from_point.help @@ -0,0 +1,11 @@ +The snapping endpoint can be used to snap points which are not on a road to the road network for a specific means of transportation. + +You need to have a valid API key ('Web' menu > 'Configuration') or sign up at https://openrouteservice.org/sign-up/. + +Travel Mode: determines the profile used. + +Input layer: select an input point from the canvas. + +Radius: Radius in which to search. + +Current restriction limits for the openrouteservice API apply. diff --git a/ORStools/help/snap_from_point_de.help b/ORStools/help/snap_from_point_de.help new file mode 100644 index 00000000..b1062770 --- /dev/null +++ b/ORStools/help/snap_from_point_de.help @@ -0,0 +1,11 @@ +Mit dem Snapping-Endpunkt können Punkte, die sich nicht auf einer Straße befinden, für ein bestimmtes Verkehrsmittel auf dem Straßennetz eingerastet werden. + +Ein gültiger API-Key ('Web'-Menü > 'Dienst-Einstellungen') oder Registrierung unter https://openrouteservice.org/sign-up/ wird benötigt. + +Verkehrsmittel: bestimmt das genutzte Reise-Profil + +Eingabelayer: Eingabepunkt + +Radius: Radius in welchem gesucht wird. + +Es gelten die Restriktionen der openrouteservice-API. diff --git a/ORStools/help/snap_from_point_layer.help b/ORStools/help/snap_from_point_layer.help new file mode 100644 index 00000000..2d3af935 --- /dev/null +++ b/ORStools/help/snap_from_point_layer.help @@ -0,0 +1,11 @@ +The snapping endpoint can be used to snap points to the edges of the street network for a specific means of transportation. + +You need to have a valid API key ('Web' menu > 'Configuration') or sign up at https://openrouteservice.org/sign-up/. + +Travel Mode: determines the profile used. + +Input layer: only Point layers are allowed. + +Radius: Radius in which to search. + +Current restriction limits for the openrouteservice API apply. diff --git a/ORStools/help/snap_from_point_layer_de.help b/ORStools/help/snap_from_point_layer_de.help new file mode 100644 index 00000000..0e3ebf57 --- /dev/null +++ b/ORStools/help/snap_from_point_layer_de.help @@ -0,0 +1,11 @@ +Mit dem Snapping-Endpunkt können Punkte, die sich nicht auf einer Straße befinden, für ein bestimmtes Verkehrsmittel auf dem Straßennetz eingerastet werden. + +Ein gültiger API-Key ('Web'-Menü > 'Dienst-Einstellungen') oder Registrierung unter https://openrouteservice.org/sign-up/ wird benötigt. + +Verkehrsmittel: bestimmt das genutzte Reise-Profil + +Eingabelayer: nur Punkt-Layer zugelassen. + +Radius: Radius in welchem gesucht wird. + +Es gelten die Restriktionen der openrouteservice-API. diff --git a/ORStools/proc/base_processing_algorithm.py b/ORStools/proc/base_processing_algorithm.py index f9274f4c..05092610 100644 --- a/ORStools/proc/base_processing_algorithm.py +++ b/ORStools/proc/base_processing_algorithm.py @@ -232,7 +232,11 @@ def initAlgorithm(self, configuration: Dict) -> None: Combines default and algorithm parameters and adds them in order to the algorithm dialog window. """ - if self.ALGO_NAME not in ["export_network_from_map"]: + if self.ALGO_NAME not in [ + "snap_from_point_layer", + "snap_from_point", + "export_network_from_map", + ]: parameters = ( [self.provider_parameter(), self.profile_parameter()] + self.PARAMETERS diff --git a/ORStools/proc/provider.py b/ORStools/proc/provider.py index e39abb91..05902dec 100644 --- a/ORStools/proc/provider.py +++ b/ORStools/proc/provider.py @@ -39,6 +39,8 @@ from .isochrones_layer_proc import ORSIsochronesLayerAlgo from .isochrones_point_proc import ORSIsochronesPointAlgo from .matrix_proc import ORSMatrixAlgo +from .snap_layer_proc import ORSSnapLayerAlgo +from .snap_point_proc import ORSSnapPointAlgo class ORStoolsProvider(QgsProcessingProvider): @@ -65,6 +67,8 @@ def loadAlgorithms(self) -> None: self.addAlgorithm(ORSIsochronesPointAlgo()) self.addAlgorithm(ORSMatrixAlgo()) self.addAlgorithm(ORSExportAlgo()) + self.addAlgorithm(ORSSnapLayerAlgo()) + self.addAlgorithm(ORSSnapPointAlgo()) @staticmethod def icon() -> QIcon: diff --git a/ORStools/proc/snap_layer_proc.py b/ORStools/proc/snap_layer_proc.py new file mode 100644 index 00000000..968ae59e --- /dev/null +++ b/ORStools/proc/snap_layer_proc.py @@ -0,0 +1,141 @@ +# -*- coding: utf-8 -*- +""" +/*************************************************************************** + ORStools + A QGIS plugin + QGIS client to query openrouteservice + ------------------- + begin : 2017-02-01 + git sha : $Format:%H$ + copyright : (C) 2021 by HeiGIT gGmbH + email : support@openrouteservice.heigit.org + ***************************************************************************/ + + This plugin provides access to openrouteservice API functionalities + (https://openrouteservice.org), developed and + maintained by the openrouteservice team of HeiGIT gGmbH, Germany. By using + this plugin you agree to the ORS terms of service + (https://openrouteservice.org/terms-of-service/). + +/*************************************************************************** + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + ***************************************************************************/ +""" + +from typing import Dict + +from qgis.PyQt.QtCore import QVariant +from qgis.core import ( + QgsProcessingParameterFeatureSource, + QgsProcessing, + QgsProcessingParameterNumber, + QgsProcessingContext, + QgsProcessingFeedback, + QgsWkbTypes, + QgsFields, + QgsCoordinateReferenceSystem, + QgsField, +) + +from ORStools.common import PROFILES +from ORStools.utils.processing import get_snapped_point_features +from ORStools.proc.base_processing_algorithm import ORSBaseProcessingAlgorithm +from ORStools.utils import exceptions, logger, transform + + +# noinspection PyPep8Naming +class ORSSnapLayerAlgo(ORSBaseProcessingAlgorithm): + def __init__(self) -> None: + super().__init__() + self.ALGO_NAME: str = "snap_from_point_layer" + self.GROUP: str = "Snap" + self.IN_POINTS: str = "IN_POINTS" + self.RADIUS: str = "RADIUS" + self.PARAMETERS: list = [ + QgsProcessingParameterFeatureSource( + name=self.IN_POINTS, + description=self.tr("Input Point Layer"), + types=[QgsProcessing.SourceType.TypeVectorPoint], + ), + QgsProcessingParameterNumber( + name=self.RADIUS, + description=self.tr("Search Radius [m]"), + defaultValue=300, + ), + ] + + def processAlgorithm( + self, parameters: dict, context: QgsProcessingContext, feedback: QgsProcessingFeedback + ) -> Dict[str, str]: + ors_client = self._get_ors_client_from_provider(parameters[self.IN_PROVIDER], feedback) + + # Get profile value + profile = dict(enumerate(PROFILES))[parameters[self.IN_PROFILE]] + + # Get parameter values + source = self.parameterAsSource(parameters, self.IN_POINTS, context) + radius = self.parameterAsDouble(parameters, self.RADIUS, context) + + sources_features = list(source.getFeatures()) + + x_former = transform.transformToWGS(source.sourceCrs()) + sources_features_x_formed = [ + x_former.transform(feat.geometry().asPoint()) for feat in sources_features + ] + + params = { + "locations": [[point.x(), point.y()] for point in sources_features_x_formed], + "radius": radius, + "id": None, + } + + sink_fields = QgsFields() + sink_fields.append(QgsField("NAME", QVariant.String)) + sink_fields.append(QgsField("SNAPPED_DISTANCE", QVariant.Double)) + + source_fields = [field for field in source.fields()] + + for field in source_fields: + if field.name() in ["SNAPPED_DISTANCE", "SNAPPED_NAME"]: + raise Exception( + self.tr( + 'Source layer may not contain field names "SNAPPED_DISTANCE" or "SNAPPED_NAME"' + ) + ) + sink_fields.append(field) + + (sink, dest_id) = self.parameterAsSink( + parameters, + self.OUT, + context, + sink_fields, + QgsWkbTypes.Type.Point, + QgsCoordinateReferenceSystem.fromEpsgId(4326), + ) + + # Make request and catch ApiError + try: + response = ors_client.request("/v2/snap/" + profile, {}, post_json=params) + point_features = get_snapped_point_features(response, sources_features, feedback) + + for feat in point_features: + sink.addFeature(feat) + + except (exceptions.ApiError, exceptions.InvalidKey, exceptions.GenericServerError) as e: + msg = f"{e.__class__.__name__}: {str(e)}" + feedback.reportError(msg) + logger.log(msg) + + return {self.OUT: dest_id} + + def displayName(self) -> str: + """ + Algorithm name shown in QGIS toolbox + :return: + """ + return self.tr("Snap from Point Layer") diff --git a/ORStools/proc/snap_point_proc.py b/ORStools/proc/snap_point_proc.py new file mode 100644 index 00000000..c05c7591 --- /dev/null +++ b/ORStools/proc/snap_point_proc.py @@ -0,0 +1,126 @@ +# -*- coding: utf-8 -*- +""" +/*************************************************************************** + ORStools + A QGIS plugin + QGIS client to query openrouteservice + ------------------- + begin : 2017-02-01 + git sha : $Format:%H$ + copyright : (C) 2021 by HeiGIT gGmbH + email : support@openrouteservice.heigit.org + ***************************************************************************/ + + This plugin provides access to openrouteservice API functionalities + (https://openrouteservice.org), developed and + maintained by the openrouteservice team of HeiGIT gGmbH, Germany. By using + this plugin you agree to the ORS terms of service + (https://openrouteservice.org/terms-of-service/). + +/*************************************************************************** + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + ***************************************************************************/ +""" + +from typing import Dict + +from qgis.PyQt.QtCore import QVariant +from qgis.core import ( + QgsProcessingParameterPoint, + QgsProcessingParameterNumber, + QgsProcessingContext, + QgsProcessingFeedback, + QgsWkbTypes, + QgsFields, + QgsCoordinateReferenceSystem, + QgsField, +) + +from ORStools.common import PROFILES +from ORStools.utils.processing import get_snapped_point_features +from ORStools.proc.base_processing_algorithm import ORSBaseProcessingAlgorithm +from ORStools.utils import exceptions, logger + + +# noinspection PyPep8Naming +class ORSSnapPointAlgo(ORSBaseProcessingAlgorithm): + def __init__(self) -> None: + super().__init__() + self.ALGO_NAME: str = "snap_from_point" + self.GROUP: str = "Snap" + self.IN_POINT: str = "IN_POINT" + self.RADIUS: str = "RADIUS" + self.PARAMETERS: list = [ + QgsProcessingParameterPoint( + name=self.IN_POINT, + description=self.tr( + "Input Point from map canvas (mutually exclusive with layer option)" + ), + optional=True, + ), + QgsProcessingParameterNumber( + name=self.RADIUS, + description=self.tr("Search Radius [m]"), + defaultValue=300, + ), + ] + + crs_out = QgsCoordinateReferenceSystem.fromEpsgId(4326) + + def processAlgorithm( + self, parameters: dict, context: QgsProcessingContext, feedback: QgsProcessingFeedback + ) -> Dict[str, str]: + ors_client = self._get_ors_client_from_provider(parameters[self.IN_PROVIDER], feedback) + + # Get profile value + profile = dict(enumerate(PROFILES))[parameters[self.IN_PROFILE]] + + # Get parameter values + point = self.parameterAsPoint(parameters, self.IN_POINT, context, self.crs_out) + radius = self.parameterAsDouble(parameters, self.RADIUS, context) + + params = { + "locations": [[round(point.x(), 6), round(point.y(), 6)]], + "radius": radius, + "id": None, + } + + sink_fields = QgsFields() + sink_fields.append(QgsField("SNAPPED_NAME", QVariant.String)) + sink_fields.append(QgsField("SNAPPED_DISTANCE", QVariant.Double)) + + (sink, dest_id) = self.parameterAsSink( + parameters, + self.OUT, + context, + sink_fields, + QgsWkbTypes.Type.Point, + QgsCoordinateReferenceSystem.fromEpsgId(4326), + ) + + # Make request and catch ApiError + try: + response = ors_client.request("/v2/snap/" + profile, {}, post_json=params) + point_features = get_snapped_point_features(response, feedback=feedback) + + for feat in point_features: + sink.addFeature(feat) + + except (exceptions.ApiError, exceptions.InvalidKey, exceptions.GenericServerError) as e: + msg = f"{e.__class__.__name__}: {str(e)}" + feedback.reportError(msg) + logger.log(msg) + + return {self.OUT: dest_id} + + def displayName(self) -> str: + """ + Algorithm name shown in QGIS toolbox + :return: + """ + return self.tr("Snap from Point") diff --git a/ORStools/utils/processing.py b/ORStools/utils/processing.py index 5d627811..337685e1 100644 --- a/ORStools/utils/processing.py +++ b/ORStools/utils/processing.py @@ -28,13 +28,15 @@ """ import os -from qgis.core import QgsPointXY from typing import List from ORStools import BASE_DIR from ORStools.common import OPTIMIZATION_MODES +from qgis.core import QgsFeature, QgsPointXY, QgsGeometry +from qgis.PyQt.QtCore import QCoreApplication + def get_params_optimize(point_list: List[QgsPointXY], ors_profile: str, mode: int) -> dict: """ @@ -92,3 +94,35 @@ def read_help_file(algorithm: str, locale: str = ""): with open(file, encoding="utf-8") as help_file: msg = help_file.read() return msg + + +def get_snapped_point_features(response: dict, og_features=None, feedback=None) -> list: + locations = response.get("locations", []) + feats = [] + for i, location in enumerate(locations): + if location: + feat = QgsFeature() + coords = location["location"] + name = location.get("name", "") + snapped_distance = location.get("snapped_distance", 0) + og_attributes = og_features[i].attributes() if og_features else [] + + feat.setAttributes([name, snapped_distance] + og_attributes) + feat.setGeometry(QgsGeometry.fromPointXY(QgsPointXY(coords[0], coords[1]))) + feats.append(feat) + + else: + f = og_features[i] + x, y = f.geometry().asPoint().x(), f.geometry().asPoint().y() + feedback.pushWarning( + tr( + f"Point {i + 1}: ({x}, {y}) could not be snapped and will be ignored in the output." + ) + ) + + return feats + + +def tr(self, string: str, context=None) -> str: + context = context or self.__class__.__name__ + return QCoreApplication.translate(context, string) diff --git a/tests/test_proc.py b/tests/test_proc.py index 6ec06e0d..371a5238 100644 --- a/tests/test_proc.py +++ b/tests/test_proc.py @@ -7,8 +7,10 @@ QgsFeature, QgsGeometry, QgsRectangle, + QgsField, ) from qgis.testing import unittest +from qgis.PyQt.QtCore import QVariant from ORStools.proc.directions_lines_proc import ORSDirectionsLinesAlgo from ORStools.proc.directions_points_layer_proc import ORSDirectionsPointsLayerAlgo @@ -16,6 +18,8 @@ from ORStools.proc.isochrones_layer_proc import ORSIsochronesLayerAlgo from ORStools.proc.isochrones_point_proc import ORSIsochronesPointAlgo from ORStools.proc.matrix_proc import ORSMatrixAlgo +from ORStools.proc.snap_layer_proc import ORSSnapLayerAlgo +from ORStools.proc.snap_point_proc import ORSSnapPointAlgo class TestProc(unittest.TestCase): @@ -63,6 +67,8 @@ def test_directions_lines(self): "INPUT_PREFERENCE": 0, "INPUT_PROFILE": 0, "INPUT_PROVIDER": 0, + "INPUT_METRIC": 0, + "LOCATION_TYPE": 0, "OUTPUT": "TEMPORARY_OUTPUT", } @@ -227,3 +233,47 @@ def test_matrix(self): # self.assertTrue(feat_point.hasGeometry()) # feat_line = next(processed_nodes.getFeatures()) # self.assertTrue(feat_line.hasGeometry()) + + def test_snapping(self): + parameters = { + "INPUT_PROFILE": 0, + "INPUT_PROVIDER": 0, + "IN_POINT": "-11867882.765490,4161830.530990 [EPSG:3857]", + "OUTPUT": "TEMPORARY_OUTPUT", + "RADIUS": 300, + } + + snap_point = ORSSnapPointAlgo().create() + dest_id = snap_point.processAlgorithm(parameters, self.context, self.feedback) + processed_layer = QgsProcessingUtils.mapLayerFromString(dest_id["OUTPUT"], self.context) + new_feat = next(processed_layer.getFeatures()) + self.assertEqual( + new_feat.geometry().asWkt(), "Point (-106.61225600000000213 34.98548300000000211)" + ) + + parameters = { + "INPUT_PROFILE": 0, + "INPUT_PROVIDER": 0, + "IN_POINTS": self.point_layer_2, + "OUTPUT": "TEMPORARY_OUTPUT", + "RADIUS": 300, + } + + snap_points = ORSSnapLayerAlgo().create() + dest_id = snap_points.processAlgorithm(parameters, self.context, self.feedback) + processed_layer = QgsProcessingUtils.mapLayerFromString(dest_id["OUTPUT"], self.context) + new_feat = next(processed_layer.getFeatures()) + + self.assertEqual( + new_feat.geometry().asWkt(), "Point (8.46554599999999979 49.48699799999999982)" + ) + self.assertEqual(len([i for i in processed_layer.getFeatures()]), 2) + + # test with "SNAPPED_NAME" being present in layer fields + new_field = QgsField("SNAPPED_NAME", QVariant.String) + self.point_layer_2.dataProvider().addAttributes([new_field]) + self.point_layer_2.updateFields() + + self.assertRaises( + Exception, lambda: snap_points.processAlgorithm(parameters, self.context, self.feedback) + )