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)
+ )