diff --git a/CHANGELOG.md b/CHANGELOG.md index 3472e248..34403dfd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,7 @@ RELEASING: --> ## [Unreleased] +- Make vertex marker on map drag and droppable, add live preview ([#204](https://github.com/GIScience/orstools-qgis-plugin/issues/204)) ## [1.10.0] - 2024-11-21 diff --git a/ORStools/__init__.py b/ORStools/__init__.py index 5e82c330..b48c5b24 100644 --- a/ORStools/__init__.py +++ b/ORStools/__init__.py @@ -48,6 +48,7 @@ def classFactory(iface): # pylint: disable=invalid-name # Define plugin wide constants PLUGIN_NAME = "ORS Tools" DEFAULT_COLOR = "#a8b1f5" +ROUTE_COLOR = "#c62828" BASE_DIR = os.path.dirname(os.path.abspath(__file__)) RESOURCE_PREFIX = ":plugins/ORStools/img/" diff --git a/ORStools/common/client.py b/ORStools/common/client.py index fa8356fa..ac4db807 100644 --- a/ORStools/common/client.py +++ b/ORStools/common/client.py @@ -35,6 +35,8 @@ from urllib.parse import urlencode from qgis.PyQt.QtCore import QObject, pyqtSignal +from qgis.utils import iface +from qgis.core import Qgis from requests.utils import unquote_unreserved from ORStools import __version__ @@ -180,6 +182,10 @@ def request( self.overQueryLimit.emit() logger.log(f"{e.__class__.__name__}: {str(e)}", 1) + iface.messageBar().pushMessage( + "ORSTools", "Rate limit exceeded, retrying...", level=Qgis.Warning, duration=2 + ) + return self.request(url, params, first_request_time, retry_counter + 1, post_json) except exceptions.ApiError as e: diff --git a/ORStools/gui/ORStoolsDialog.py b/ORStools/gui/ORStoolsDialog.py index b3f30fff..847690be 100644 --- a/ORStools/gui/ORStoolsDialog.py +++ b/ORStools/gui/ORStoolsDialog.py @@ -27,10 +27,13 @@ ***************************************************************************/ """ -import json import os from typing import Optional +from PyQt5.QtWidgets import QCheckBox + +from ..utils.router import route_as_layer + try: import processing except ModuleNotFoundError: @@ -38,8 +41,6 @@ import webbrowser -from qgis._core import Qgis, QgsAnnotation -from qgis._gui import QgisInterface from qgis.core import ( QgsProject, QgsVectorLayer, @@ -50,9 +51,11 @@ QgsGeometry, QgsCoordinateReferenceSystem, QgsSettings, + Qgis, + QgsAnnotation, + QgsCoordinateTransform, ) -from qgis.gui import QgsMapCanvasAnnotationItem - +from qgis.gui import QgsMapCanvasAnnotationItem, QgsCollapsibleGroupBox, QgisInterface from qgis.PyQt.QtCore import QSizeF, QPointF, QCoreApplication from qgis.PyQt.QtGui import QIcon, QTextDocument, QColor from qgis.PyQt.QtWidgets import ( @@ -63,6 +66,7 @@ QMessageBox, QDialogButtonBox, QWidget, + QRadioButton, ) from ORStools import ( @@ -75,13 +79,10 @@ __help__, ) from ORStools.common import ( - client, - directions_core, PROFILES, PREFERENCES, ) -from ORStools.gui import directions_gui -from ORStools.utils import exceptions, maptools, logger, configmanager, transform +from ORStools.utils import maptools, configmanager, transform from .ORStoolsDialogConfig import ORStoolsDialogConfigMain from .ORStoolsDialogUI import Ui_ORStoolsDialogBase @@ -257,11 +258,6 @@ def _init_gui_control(self) -> None: def run_gui_control(self) -> None: """Slot function for OK button of main dialog.""" - - layer_out = QgsVectorLayer("LineString?crs=EPSG:4326", "Route_ORS", "memory") - layer_out.dataProvider().addAttributes(directions_core.get_fields()) - layer_out.updateFields() - basepath = os.path.dirname(__file__) # add ors svg path @@ -271,11 +267,6 @@ def run_gui_control(self) -> None: svg_paths.append(my_new_path) QgsSettings().setValue("svg/searchPathsForSVG", svg_paths) - # style output layer - qml_path = os.path.join(basepath, "linestyle.qml") - layer_out.loadNamedStyle(qml_path, True) - layer_out.triggerRepaint() - # Associate annotations with map layer, so they get deleted when layer is deleted for annotation in self.dlg.annotations: # Has the potential to be pretty cool: instead of deleting, associate with mapLayer @@ -285,170 +276,19 @@ def run_gui_control(self) -> None: # annotation.setMapLayer(layer_out) self.project.annotationManager().removeAnnotation(annotation) self.dlg.annotations = [] + self.dlg.rubber_band.reset() - provider_id = self.dlg.provider_combo.currentIndex() - provider = configmanager.read_config()["providers"][provider_id] - - # if there are no coordinates, throw an error message - if not self.dlg.routing_fromline_list.count(): - QMessageBox.critical( - self.dlg, - self.tr("Missing Waypoints"), - self.tr( - """ - Did you forget to set routing waypoints?

- - Use the 'Add Waypoint' button to add up to 50 waypoints. - """ - ), - ) - return - - # if no API key is present, when ORS is selected, throw an error message - if not provider["key"] and provider["base_url"].startswith( - "https://api.openrouteservice.org" - ): - QMessageBox.critical( - self.dlg, - self.tr("Missing API key"), - self.tr( - """ - Did you forget to set an API key for openrouteservice?

- - If you don't have an API key, please visit https://openrouteservice.org/sign-up to get one.

- Then enter the API key for openrouteservice provider in Web ► ORS Tools ► Provider Settings or the - settings symbol in the main ORS Tools GUI, next to the provider dropdown.""" - ), - ) - return - - agent = "QGIS_ORStoolsDialog" - clnt = client.Client(provider, agent) - clnt_msg = "" + layer_out = route_as_layer(self.dlg) - directions = directions_gui.Directions(self.dlg) - params = None - try: - params = directions.get_parameters() - if self.dlg.optimization_group.isChecked(): - # check for duplicate points - points = [ - self.dlg.routing_fromline_list.item(x).text() - for x in range(self.dlg.routing_fromline_list.count()) - ] - if len(points) != len(set(points)): - QMessageBox.warning( - self.dlg, - self.tr("Duplicates"), - self.tr( - """ - There are duplicate points in the input layer. Traveling Salesman Optimization does not allow this. - Either remove the duplicates or deselect Traveling Salesman. - """ - ), - ) - msg = self.tr("The request has been aborted!") - logger.log(msg, 0) - self.dlg.debug_text.setText(msg) - return - - if len(params["jobs"]) <= 1: # Start/end locations don't count as job - QMessageBox.critical( - self.dlg, - self.tr("Wrong number of waypoints"), - self.tr(""" - At least 3 or 4 waypoints are needed to perform routing optimization. - -Remember, the first and last location are not part of the optimization. - """), - ) - return - response = clnt.request("/optimization", {}, post_json=params) - - if self.dlg.export_jobs_order.isChecked(): - items = list() - for route in response["routes"]: - for i, step in enumerate(route["steps"]): - location = step["location"] - items.append(location) - - point_layer = QgsVectorLayer( - "point?crs=epsg:4326&field=ID:integer", "Steps", "memory" - ) - - point_layer.updateFields() - for idx, coords in enumerate(items): - x, y = coords - feature = QgsFeature() - feature.setGeometry(QgsGeometry.fromPointXY(QgsPointXY(x, y))) - feature.setAttributes([idx]) - - point_layer.dataProvider().addFeature(feature) - QgsProject.instance().addMapLayer(point_layer) - self.dlg._iface.mapCanvas().refresh() - - feat = directions_core.get_output_features_optimization( - response, params["vehicles"][0]["profile"] - ) - else: - params["coordinates"] = directions.get_request_line_feature() - profile = self.dlg.routing_travel_combo.currentText() - # abort on empty avoid polygons layer - if ( - "options" in params - and "avoid_polygons" in params["options"] - and params["options"]["avoid_polygons"] == {} - ): - QMessageBox.warning( - self.dlg, - self.tr("Empty layer"), - self.tr( - """ -The specified avoid polygon(s) layer does not contain any features. -Please add polygons to the layer or uncheck avoid polygons. - """ - ), - ) - msg = self.tr("The request has been aborted!") - logger.log(msg, 0) - self.dlg.debug_text.setText(msg) - return - response = clnt.request( - "/v2/directions/" + profile + "/geojson", {}, post_json=params - ) - feat = directions_core.get_output_feature_directions( - response, profile, params["preference"], directions.options - ) - - layer_out.dataProvider().addFeature(feat) - - layer_out.updateExtents() - self.project.addMapLayer(layer_out) - - # Update quota; handled in client module after successful request - # if provider.get('ENV_VARS'): - # self.dlg.quota_text.setText(self.get_quota(provider) + ' calls') - except exceptions.Timeout: - msg = self.tr("The connection has timed out!") - logger.log(msg, 2) - self.dlg.debug_text.setText(msg) - return - - except (exceptions.ApiError, exceptions.InvalidKey, exceptions.GenericServerError) as e: - logger.log(f"{e.__class__.__name__}: {str(e)}", 2) - clnt_msg += f"{e.__class__.__name__}: ({str(e)})
" - raise + # style output layer + qml_path = os.path.join(basepath, "linestyle.qml") + layer_out.loadNamedStyle(qml_path, True) + layer_out.triggerRepaint() - except Exception as e: - logger.log(f"{e.__class__.__name__}: {str(e)}", 2) - clnt_msg += f"{e.__class__.__name__}: {str(e)}
" - raise + self.project.addMapLayer(layer_out) - finally: - # Set URL in debug window - if params: - clnt_msg += f'{clnt.url}
Parameters:
{json.dumps(params, indent=2)}' - self.dlg.debug_text.setHtml(clnt_msg) + self.dlg._clear_listwidget() + self.dlg.line_tool = maptools.LineTool(self.dlg) def tr(self, string: str) -> str: return QCoreApplication.translate(str(self.__class__.__name__), string) @@ -473,7 +313,8 @@ def __init__(self, iface: QgisInterface, parent=None) -> None: # Set things around the custom map tool self.line_tool = None - self.last_maptool = self._iface.mapCanvas().mapTool() + self.canvas = self._iface.mapCanvas() + self.last_maptool = self.canvas.mapTool() self.annotations = [] # Set up env variables for remaining quota @@ -498,7 +339,7 @@ def __init__(self, iface: QgisInterface, parent=None) -> None: # Routing tab self.routing_fromline_map.clicked.connect(self._on_linetool_init) - self.routing_fromline_clear.clicked.connect(self._on_clear_listwidget_click) + self.routing_fromline_clear.clicked.connect(self._clear_listwidget) self.save_vertices.clicked.connect(self._save_vertices_to_layer) # Batch @@ -533,7 +374,16 @@ def __init__(self, iface: QgisInterface, parent=None) -> None: lambda: self.color_duplicate_items(self.routing_fromline_list) ) - self.annotation_canvas = self._iface.mapCanvas() + advanced_boxes = self.advances_group.findChildren(QgsCollapsibleGroupBox) + for box in advanced_boxes: + box.collapsedStateChanged.connect(self.reload_rubber_band) + for child in box.findChildren((QRadioButton, QCheckBox)): + if isinstance(child, QCheckBox) and not child.objectName() == "export_jobs_order": + child.stateChanged.connect(self.reload_rubber_band) + elif isinstance(child, QRadioButton): + child.toggled.connect(self.reload_rubber_band) + + self.rubber_band = None def _save_vertices_to_layer(self) -> None: """Saves the vertices list to a temp layer""" @@ -556,7 +406,7 @@ def _save_vertices_to_layer(self) -> None: point_layer.dataProvider().addFeature(feature) QgsProject.instance().addMapLayer(point_layer) - self._iface.mapCanvas().refresh() + self.canvas.refresh() self._iface.messageBar().pushMessage( self.tr("Success"), self.tr("Vertices saved to layer."), level=Qgis.MessageLevel.Success @@ -570,7 +420,7 @@ def _on_prov_refresh_click(self) -> None: for provider in providers: self.provider_combo.addItem(provider["name"], provider) - def _on_clear_listwidget_click(self) -> None: + def _clear_listwidget(self) -> None: """Clears the contents of the QgsListWidget and the annotations.""" items = self.routing_fromline_list.selectedItems() if items: @@ -580,20 +430,23 @@ def _on_clear_listwidget_click(self) -> None: if self.annotations: self.project.annotationManager().removeAnnotation(self.annotations.pop(row)) self.routing_fromline_list.takeItem(row) + self.line_tool.error_idxs += 1 else: # else clear all items and annotations self.routing_fromline_list.clear() self._clear_annotations() - - # Remove blue lines (rubber band) - if self.line_tool and hasattr(self.line_tool, "rubberBand"): - self.line_tool.canvas.scene().removeItem(self.line_tool.rubberBand) + QApplication.restoreOverrideCursor() + self.canvas.setMapTool(self.last_maptool) + # Remove blue lines (rubber band) + if self.rubber_band: + self.rubber_band.reset() + self.line_tool = maptools.LineTool(self) def _linetool_annotate_point( self, point: QgsPointXY, idx: int, crs: Optional[QgsCoordinateReferenceSystem] = None ) -> QgsAnnotation: if not crs: - crs = self._iface.mapCanvas().mapSettings().destinationCrs() + crs = QgsProject.instance().crs() annotation = QgsTextAnnotation() @@ -605,46 +458,41 @@ def _linetool_annotate_point( annotation.setFrameSizeMm(QSizeF(8, 5)) annotation.setFrameOffsetFromReferencePointMm(QPointF(1.3, 1.3)) - annotation.setMapPosition(point) annotation.setMapPositionCrs(crs) + annotation.setMapPosition(point) - return QgsMapCanvasAnnotationItem(annotation, self.annotation_canvas).annotation() + return QgsMapCanvasAnnotationItem(annotation, self.canvas).annotation() def _clear_annotations(self) -> None: """Clears annotations""" - for annotation_item in self.annotation_canvas.annotationItems(): + for annotation_item in self.canvas.annotationItems(): annotation = annotation_item.annotation() if annotation in self.project.annotationManager().annotations(): self.project.annotationManager().removeAnnotation(annotation) self.annotations = [] + if self.rubber_band: + self.rubber_band.reset() def _on_linetool_init(self) -> None: """Hides GUI dialog, inits line maptool and add items to line list box.""" - # Remove blue lines (rubber band) - if self.line_tool: - self.line_tool.canvas.scene().removeItem(self.line_tool.rubberBand) - self.hide() - self.routing_fromline_list.clear() - # Remove all annotations which were added (if any) - self._clear_annotations() - - self.line_tool = maptools.LineTool(self._iface.mapCanvas()) - self._iface.mapCanvas().setMapTool(self.line_tool) - self.line_tool.pointDrawn.connect( - lambda point, idx: self._on_linetool_map_click(point, idx) - ) - self.line_tool.digitizationEnded.connect(self._on_linetool_digitization_ended) + if self.line_tool: + self.canvas.setMapTool(self.line_tool) + else: + self.line_tool = maptools.LineTool(self) + self.canvas.setMapTool(self.line_tool) - def _on_linetool_map_click(self, point: QgsPointXY, idx: int) -> None: + def create_vertex(self, point, idx): """Adds an item to QgsListWidget and annotates the point in the map canvas""" - map_crs = self._iface.mapCanvas().mapSettings().destinationCrs() + map_crs = self.canvas.mapSettings().destinationCrs() transformer = transform.transformToWGS(map_crs) point_wgs = transformer.transform(point) self.routing_fromline_list.addItem(f"Point {idx}: {point_wgs.x():.6f}, {point_wgs.y():.6f}") - annotation = self._linetool_annotate_point(point, idx) + crs = self.canvas.mapSettings().destinationCrs() + annotation = self._linetool_annotate_point(point, idx, crs) + self.annotations.append(annotation) self.project.annotationManager().addAnnotation(annotation) def _reindex_list_items(self) -> None: @@ -656,6 +504,7 @@ def _reindex_list_items(self) -> None: self.routing_fromline_list.clear() self._clear_annotations() crs = QgsCoordinateReferenceSystem(f"EPSG:{4326}") + project_crs = self.canvas.mapSettings().destinationCrs() for idx, x in enumerate(items): coords = x.split(":")[1] item = f"Point {idx}:{coords}" @@ -663,21 +512,18 @@ def _reindex_list_items(self) -> None: point = QgsPointXY(x, y) self.routing_fromline_list.addItem(item) - annotation = self._linetool_annotate_point(point, idx, crs) + transform = QgsCoordinateTransform(crs, project_crs, QgsProject.instance()) + point = transform.transform(point) + annotation = self._linetool_annotate_point(point, idx) + self.annotations.append(annotation) self.project.annotationManager().addAnnotation(annotation) - - def _on_linetool_digitization_ended(self) -> None: - """ - Populate line list widget with coordinates, end line drawing and show dialog again. - """ - - self.line_tool.pointDrawn.disconnect() - self.line_tool.digitizationEnded.disconnect() - self.line_tool = None - - QApplication.restoreOverrideCursor() - self._iface.mapCanvas().setMapTool(self.last_maptool) - self.show() + try: + self.line_tool.create_rubber_band() + except Exception as e: + if "Connection refused" in str(e): + self.api_key_message_bar() + else: + raise e def color_duplicate_items(self, list_widget): item_dict = {} @@ -694,3 +540,8 @@ def color_duplicate_items(self, list_widget): for index in indices: item = list_widget.item(index) item.setBackground(QColor("lightsalmon")) + + def reload_rubber_band(self) -> None: + """Reloads the rubber band of the linetool.""" + if self.line_tool is not None: + self.line_tool.create_rubber_band() diff --git a/ORStools/gui/ORStoolsDialogUI.py b/ORStools/gui/ORStoolsDialogUI.py index 3b6beb81..72d7412b 100644 --- a/ORStools/gui/ORStoolsDialogUI.py +++ b/ORStools/gui/ORStoolsDialogUI.py @@ -209,6 +209,9 @@ def setupUi(self, ORStoolsDialogBase): self.save_vertices.setIcon(icon4) self.save_vertices.setObjectName("save_vertices") self.gridLayout.addWidget(self.save_vertices, 2, 0, 1, 1) + self.toggle_preview = QtWidgets.QCheckBox(self.widget) + self.toggle_preview.setObjectName("toggle_preview") + self.gridLayout.addWidget(self.toggle_preview, 3, 0, 1, 1) self.verticalLayout_7.addWidget(self.widget) self.advances_group = QgsCollapsibleGroupBox(self.qwidget) sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Fixed) @@ -219,8 +222,8 @@ def setupUi(self, ORStoolsDialogBase): self.advances_group.setMaximumSize(QtCore.QSize(16777215, 20)) self.advances_group.setCheckable(False) self.advances_group.setChecked(False) - self.advances_group.setCollapsed(True) - self.advances_group.setSaveCollapsedState(False) + self.advances_group.setProperty("collapsed", True) + self.advances_group.setProperty("saveCollapsedState", False) self.advances_group.setObjectName("advances_group") self.verticalLayout_3 = QtWidgets.QVBoxLayout(self.advances_group) self.verticalLayout_3.setSizeConstraint(QtWidgets.QLayout.SetDefaultConstraint) @@ -235,8 +238,8 @@ def setupUi(self, ORStoolsDialogBase): self.optimization_group.setMaximumSize(QtCore.QSize(16777215, 20)) self.optimization_group.setCheckable(True) self.optimization_group.setChecked(False) - self.optimization_group.setCollapsed(True) - self.optimization_group.setSaveCollapsedState(False) + self.optimization_group.setProperty("collapsed", True) + self.optimization_group.setProperty("saveCollapsedState", False) self.optimization_group.setObjectName("optimization_group") self.gridLayout_2 = QtWidgets.QGridLayout(self.optimization_group) self.gridLayout_2.setSizeConstraint(QtWidgets.QLayout.SetDefaultConstraint) @@ -280,8 +283,8 @@ def setupUi(self, ORStoolsDialogBase): self.routing_avoid_tags_group.setSizePolicy(sizePolicy) self.routing_avoid_tags_group.setCheckable(True) self.routing_avoid_tags_group.setChecked(False) - self.routing_avoid_tags_group.setCollapsed(True) - self.routing_avoid_tags_group.setSaveCollapsedState(False) + self.routing_avoid_tags_group.setProperty("collapsed", True) + self.routing_avoid_tags_group.setProperty("saveCollapsedState", False) self.routing_avoid_tags_group.setObjectName("routing_avoid_tags_group") self.gridLayout_4 = QtWidgets.QGridLayout(self.routing_avoid_tags_group) self.gridLayout_4.setSizeConstraint(QtWidgets.QLayout.SetDefaultConstraint) @@ -311,8 +314,8 @@ def setupUi(self, ORStoolsDialogBase): self.routing_avoid_countries_group.setMaximumSize(QtCore.QSize(16777215, 20)) self.routing_avoid_countries_group.setCheckable(True) self.routing_avoid_countries_group.setChecked(False) - self.routing_avoid_countries_group.setCollapsed(True) - self.routing_avoid_countries_group.setSaveCollapsedState(False) + self.routing_avoid_countries_group.setProperty("collapsed", True) + self.routing_avoid_countries_group.setProperty("saveCollapsedState", False) self.routing_avoid_countries_group.setObjectName("routing_avoid_countries_group") self.verticalLayout_4 = QtWidgets.QVBoxLayout(self.routing_avoid_countries_group) self.verticalLayout_4.setObjectName("verticalLayout_4") @@ -330,13 +333,13 @@ def setupUi(self, ORStoolsDialogBase): self.avoidpolygon_group.setMaximumSize(QtCore.QSize(16777215, 20)) self.avoidpolygon_group.setCheckable(True) self.avoidpolygon_group.setChecked(False) - self.avoidpolygon_group.setCollapsed(True) - self.avoidpolygon_group.setSaveCollapsedState(False) + self.avoidpolygon_group.setProperty("collapsed", True) + self.avoidpolygon_group.setProperty("saveCollapsedState", False) self.avoidpolygon_group.setObjectName("avoidpolygon_group") self.verticalLayout_6 = QtWidgets.QVBoxLayout(self.avoidpolygon_group) self.verticalLayout_6.setObjectName("verticalLayout_6") self.avoidpolygon_dropdown = QgsMapLayerComboBox(self.avoidpolygon_group) - self.avoidpolygon_dropdown.setShowCrs(False) + self.avoidpolygon_dropdown.setProperty("showCrs", False) self.avoidpolygon_dropdown.setObjectName("avoidpolygon_dropdown") self.verticalLayout_6.addWidget(self.avoidpolygon_dropdown) self.verticalLayout_3.addWidget(self.avoidpolygon_group) @@ -407,8 +410,8 @@ def setupUi(self, ORStoolsDialogBase): self.ors_log_group.setMinimumSize(QtCore.QSize(0, 0)) self.ors_log_group.setMaximumSize(QtCore.QSize(16777215, 20)) self.ors_log_group.setFlat(True) - self.ors_log_group.setCollapsed(True) - self.ors_log_group.setSaveCollapsedState(False) + self.ors_log_group.setProperty("collapsed", True) + self.ors_log_group.setProperty("saveCollapsedState", False) self.ors_log_group.setObjectName("ors_log_group") self.verticalLayout_2 = QtWidgets.QVBoxLayout(self.ors_log_group) self.verticalLayout_2.setSizeConstraint(QtWidgets.QLayout.SetDefaultConstraint) @@ -484,10 +487,11 @@ def retranslateUi(self, ORStoolsDialogBase): self.routing_travel_label.setText(_translate("ORStoolsDialogBase", "Go with")) self.routing_travel_combo.setToolTip(_translate("ORStoolsDialogBase", "Mode of travel")) self.routing_preference_combo.setToolTip(_translate("ORStoolsDialogBase", "Preference")) - self.routing_fromline_map.setToolTip(_translate("ORStoolsDialogBase", "

Add wayoints interactively from the map canvas.

The Escape key, right- or double-click will terminate waypoint selection.

")) + self.routing_fromline_map.setToolTip(_translate("ORStoolsDialogBase", "

Add waypoints interactively from the map canvas.

Right- or double-click will pause waypoint selection, drag and drop will still be enabled. Another click on the green + button will continue the selection process. The ESC-button will terminate it and delete all waypoints.

")) self.routing_fromline_clear.setToolTip(_translate("ORStoolsDialogBase", "

If waypoints are selected in the list, only these will be deleted. Else all waypoints will be deleted.

")) self.routing_fromline_list.setToolTip(_translate("ORStoolsDialogBase", "Select waypoints from the map!")) self.save_vertices.setToolTip(_translate("ORStoolsDialogBase", "

Save points in list to layer. Use the processing algorithms (batch jobs) to work with points from layers.

")) + self.toggle_preview.setText(_translate("ORStoolsDialogBase", "LivePreview")) self.advances_group.setTitle(_translate("ORStoolsDialogBase", "Advanced Configuration")) self.optimization_group.setToolTip(_translate("ORStoolsDialogBase", "

Enabling Traveling Salesman will omit all other advanced configuration and assume the preference to be fastest.

")) self.optimization_group.setTitle(_translate("ORStoolsDialogBase", "Traveling Salesman")) @@ -538,4 +542,4 @@ def retranslateUi(self, ORStoolsDialogBase): from qgscollapsiblegroupbox import QgsCollapsibleGroupBox from qgsfilterlineedit import QgsFilterLineEdit from qgsmaplayercombobox import QgsMapLayerComboBox -from . import resources_rc +# import resources_rc diff --git a/ORStools/gui/ORStoolsDialogUI.ui b/ORStools/gui/ORStoolsDialogUI.ui index f8f15482..7f450323 100644 --- a/ORStools/gui/ORStoolsDialogUI.ui +++ b/ORStools/gui/ORStoolsDialogUI.ui @@ -6,7 +6,7 @@ 0 0 - 412 + 439 868 @@ -24,7 +24,7 @@ - QLayout::SetMinAndMaxSize + QLayout::SizeConstraint::SetMinAndMaxSize @@ -36,7 +36,6 @@ - 75 true @@ -44,17 +43,16 @@ Resources - Qt::AlignCenter + Qt::AlignmentFlag::AlignCenter false - + - 50 false @@ -66,11 +64,10 @@ - + - 50 false @@ -82,11 +79,10 @@ - + - 50 false @@ -208,13 +204,13 @@ - QLayout::SetDefaultConstraint + QLayout::SizeConstraint::SetDefaultConstraint - QLayout::SetDefaultConstraint + QLayout::SizeConstraint::SetDefaultConstraint @@ -262,7 +258,7 @@ - QLayout::SetDefaultConstraint + QLayout::SizeConstraint::SetDefaultConstraint @@ -273,7 +269,7 @@ - <html><head/><body><p>Add wayoints interactively from the map canvas.</p><p>The Escape key, right- or double-click will terminate waypoint selection.</p></body></html> + <html><head/><body><p>Add waypoints interactively from the map canvas.</p><p>Right- or double-click will pause waypoint selection, drag and drop will still be enabled. Another click on the green + button will continue the selection process. The ESC-button will terminate it and delete all waypoints.</p></body></html> @@ -304,8 +300,35 @@ + + + + + 0 + 0 + + + + <html><head/><body><p>Save points in list to layer. Use the processing algorithms (batch jobs) to work with points from layers.</p></body></html> + + + + + + + :/plugins/ORStools/img/icon_save.png:/plugins/ORStools/img/icon_save.png + + + + + + + LivePreview + + + - + 0 @@ -328,36 +351,13 @@ Select waypoints from the map! - QFrame::Sunken + QFrame::Shadow::Sunken - QAbstractItemView::InternalMove + QAbstractItemView::DragDropMode::InternalMove - QAbstractItemView::MultiSelection - - - QListView::Fixed - - - - - - - - 0 - 0 - - - - <html><head/><body><p>Save points in list to layer. Use the processing algorithms (batch jobs) to work with points from layers.</p></body></html> - - - - - - - :/plugins/ORStools/img/icon_save.png:/plugins/ORStools/img/icon_save.png + QAbstractItemView::SelectionMode::MultiSelection @@ -387,15 +387,15 @@ false - + true - + false - QLayout::SetDefaultConstraint + QLayout::SizeConstraint::SetDefaultConstraint @@ -429,15 +429,15 @@ false - + true - + false - QLayout::SetDefaultConstraint + QLayout::SizeConstraint::SetDefaultConstraint @@ -469,7 +469,7 @@ p, li { white-space: pre-wrap; } - + <html><head/><body><p><span style=" font-weight:600;">Other Options</span></p></body></html> @@ -551,15 +551,15 @@ p, li { white-space: pre-wrap; } false - + true - + false - QLayout::SetDefaultConstraint + QLayout::SizeConstraint::SetDefaultConstraint @@ -625,10 +625,10 @@ p, li { white-space: pre-wrap; } false - + true - + false @@ -637,7 +637,7 @@ p, li { white-space: pre-wrap; } <html><head/><body><p>Avoid countries based on ISO 3166 Alpha-2 or Alpha-3 codes.</p></body></html> - + @@ -671,10 +671,10 @@ p, li { white-space: pre-wrap; } false - + true - + false @@ -683,7 +683,7 @@ p, li { white-space: pre-wrap; } <html><head/><body><p>Avoid areas by specifying a (Multi-)Polygon layer. </p><p>Does <span style=" font-weight:600;">not work</span> for memory (scratch) Polygon layers!</p><p><span style=" font-weight:600;">Note</span>, only the first feature of the layer will be respected.</p></body></html> - + false @@ -791,9 +791,9 @@ p, li { white-space: pre-wrap; } - Qt::Vertical + Qt::Orientation::Vertical - + 20 40 @@ -831,15 +831,15 @@ p, li { white-space: pre-wrap; } true - + true - + false - QLayout::SetDefaultConstraint + QLayout::SizeConstraint::SetDefaultConstraint @@ -862,10 +862,7 @@ p, li { white-space: pre-wrap; } - QTextEdit::AutoBulletList - - - 80 + QTextEdit::AutoFormattingFlag::AutoBulletList Queries and errors will be printed here. @@ -873,6 +870,9 @@ p, li { white-space: pre-wrap; } true + + 80 + @@ -918,10 +918,10 @@ p, li { white-space: pre-wrap; } - Qt::Horizontal + Qt::Orientation::Horizontal - QDialogButtonBox::Cancel|QDialogButtonBox::Ok + QDialogButtonBox::StandardButton::Cancel|QDialogButtonBox::StandardButton::Ok diff --git a/ORStools/i18n/orstools_de.ts b/ORStools/i18n/orstools_de.ts index 2c24ecf9..b975171c 100644 --- a/ORStools/i18n/orstools_de.ts +++ b/ORStools/i18n/orstools_de.ts @@ -428,7 +428,7 @@ Duplikate entfernen oder Wegpunktoptimierung abwählen. - <html><head/><body><p>Add wayoints interactively from the map canvas.</p><p>The Escape key, right- or double-click will terminate waypoint selection.</p></body></html> + <html><head/><body><p>Add waypoints interactively from the map canvas.</p><p>The Escape key, right- or double-click will terminate waypoint selection.</p></body></html> <html><head/><body><p>Wegpunkte aus Kartenansicht hinzufügen</p><p>Escape, Rechts- oder Doppelklick beendeen die Wegpunkt-Auswahl.</p></body></html> diff --git a/ORStools/utils/maptools.py b/ORStools/utils/maptools.py index 46011f98..3cba5498 100644 --- a/ORStools/utils/maptools.py +++ b/ORStools/utils/maptools.py @@ -27,84 +27,333 @@ ***************************************************************************/ """ -from qgis.core import QgsWkbTypes -from qgis.gui import QgsMapToolEmitPoint, QgsRubberBand +import json +import math -from qgis.PyQt.QtCore import pyqtSignal, Qt -from qgis.PyQt.QtGui import QColor +from qgis.gui import QgsMapToolEmitPoint, QgsRubberBand +from qgis.core import ( + QgsProject, + QgsPointXY, + QgsCoordinateReferenceSystem, + Qgis, + QgsCoordinateTransform, + QgsWkbTypes, + QgsAnnotation, + QgsMarkerSymbol, +) +from qgis.PyQt.QtCore import Qt, pyqtSignal, QEvent +from qgis.PyQt.QtGui import QColor, QMouseEvent +from qgis.PyQt.QtWidgets import ( + QApplication, +) -from ORStools import DEFAULT_COLOR +from ORStools import ROUTE_COLOR +from ORStools.utils import transform, router +from ORStools.utils.exceptions import ApiError class LineTool(QgsMapToolEmitPoint): """Line Map tool to capture mapped lines.""" - def __init__(self, canvas): + def __init__(self, dlg): """ :param canvas: current map canvas :type canvas: QgsMapCanvas """ - self.canvas = canvas - QgsMapToolEmitPoint.__init__(self, self.canvas) - - self.rubberBand = QgsRubberBand( - mapCanvas=self.canvas, geometryType=QgsWkbTypes.GeometryType.LineGeometry - ) - self.rubberBand.setStrokeColor(QColor(DEFAULT_COLOR)) - self.rubberBand.setWidth(3) + self.dlg = dlg + QgsMapToolEmitPoint.__init__(self, self.dlg.canvas) - self.crsSrc = self.canvas.mapSettings().destinationCrs() + self.crsSrc = self.dlg.canvas.mapSettings().destinationCrs() self.previous_point = None self.points = [] + self.last_point = None self.reset() + # connect live preview button to reload rubber band + self.dlg.toggle_preview.toggled.connect(self._toggle_preview) + + # connect profile enums to reload rubber band + self.dlg.routing_preference_combo.currentIndexChanged.connect(self._toggle_preview) + self.dlg.routing_travel_combo.currentIndexChanged.connect(self._toggle_preview) + + self.pointPressed.connect(lambda point: self._on_movetool_map_press(point)) + self.pointReleased.connect(lambda event, idx: self._on_movetool_map_release(event, idx)) + self.mouseMoved.connect(lambda pos: self.change_cursor_on_hover(pos)) + + self.last_click = "single-click" + self.moving = None + self.moved_idxs = 0 + self.error_idxs = 0 + self.click_dist = 25 + self.move_i = 0 + self.idx = 0 + def reset(self): """reset rubber band and captured points.""" - + self.last_point = None self.points = [] - self.rubberBand.reset(geometryType=QgsWkbTypes.GeometryType.LineGeometry) - pointDrawn = pyqtSignal(["QgsPointXY", "int"]) + pointReleased = pyqtSignal(["QEvent", "int"]) - def keyPressEvent(self, event) -> None: - # Check if the pressed key is the Escape key - if event.key() == Qt.Key_Escape: - self.end_digitization() - - def canvasReleaseEvent(self, e): - """Add marker to canvas and shows line.""" - if e.button() == Qt.RightButton: - self.end_digitization() - else: - new_point = self.toMapCoordinates(e.pos()) - self.points.append(new_point) - - # noinspection PyUnresolvedReferences - self.pointDrawn.emit(new_point, self.points.index(new_point)) - self.showLine() - - def showLine(self): - """Builds rubber band from all points and adds it to the map canvas.""" - self.rubberBand.reset(geometryType=QgsWkbTypes.GeometryType.LineGeometry) - for point in self.points: - if point == self.points[-1]: - self.rubberBand.addPoint(point, True) - self.rubberBand.addPoint(point, False) - self.rubberBand.show() - - digitizationEnded = pyqtSignal() - - # noinspection PyUnusedLocal - def canvasDoubleClickEvent(self, e): - """Ends line drawing and deletes rubber band and markers from map canvas.""" - # noinspection PyUnresolvedReferences - self.end_digitization() + doubleClicked = pyqtSignal() def deactivate(self): super(LineTool, self).deactivate() self.deactivated.emit() - def end_digitization(self): - self.digitizationEnded.emit() - self.canvas.scene().removeItem(self.rubberBand) - del self.rubberBand + pointPressed = pyqtSignal(["QPoint"]) + + mouseMoved = pyqtSignal(["QPoint"]) + + def canvasMoveEvent(self, e: QEvent) -> None: + hovering = self.check_annotation_hover(e.pos()) + if hovering: + QApplication.setOverrideCursor(Qt.OpenHandCursor) + else: + if not self.moving: + QApplication.restoreOverrideCursor() + + def check_annotation_hover(self, pos: QMouseEvent) -> int: + click = [pos.x(), pos.y()] + dists = {} + for i, anno in enumerate(self.dlg.annotations): + x, y = anno.mapPosition() + point = self.dlg.canvas.getCoordinateTransform().transform(x, y) # die ist es + p = [point.x(), point.y()] + + distance = 0.0 + for j in range(len(click)): + distance += (click[j] - p[j]) ** 2 + distance = math.sqrt(distance) + + if distance > 0: + dists[distance] = anno + if dists and min(dists) < self.click_dist: + idx = dists[min(dists)] + return idx + + def keyPressEvent(self, event: QEvent) -> None: + if event.key() == Qt.Key_Escape: + self.dlg._clear_listwidget() + elif event.key() == Qt.Key_D: + if self.last_point: + index = int(self.last_point["annotation"].document().toPlainText()) + if self.dlg.annotations: + self.dlg.project.annotationManager().removeAnnotation( + self.dlg.annotations.pop(index) + ) + self.dlg.routing_fromline_list.takeItem(index) + self.dlg._reindex_list_items() + self.last_point = None + self.error_idxs += 1 + if self.dlg.annotations and self.points: + self.save_last_point( + self.points[index - 1], self.dlg.annotations[index - 1] + ) + if self.dlg.routing_fromline_list.count() < 1: + self.dlg._clear_listwidget() + + def canvasPressEvent(self, event: QEvent) -> None: + hovering = self.check_annotation_hover(event.pos()) + if hovering: + self.mouseMoved.disconnect() + QApplication.setOverrideCursor(Qt.ClosedHandCursor) + if self.dlg.rubber_band: + self.dlg.rubber_band.reset() + self.move_i = self.dlg.annotations.index(hovering) + self.dlg.project.annotationManager().removeAnnotation( + self.dlg.annotations.pop(self.move_i) + ) + self.moving = True + + def canvasReleaseEvent(self, event: QEvent) -> None: + if event.button() == Qt.RightButton: + self.dlg.show() + return + + point = self.toMapCoordinates(event.pos()) + self.points.append(point) + + if self.last_click == "single-click": + if self.moving: + try: + self.moving = False + QApplication.restoreOverrideCursor() + crs = self.dlg.canvas.mapSettings().destinationCrs() + + annotation = self.dlg._linetool_annotate_point(point, self.move_i, crs=crs) + self.dlg.annotations.insert(self.move_i, annotation) + self.dlg.project.annotationManager().addAnnotation(annotation) + + transformer = transform.transformToWGS(crs) + point_wgs = transformer.transform(point) + + items = [ + self.dlg.routing_fromline_list.item(x).text() + for x in range(self.dlg.routing_fromline_list.count()) + ] + backup = items.copy() + items[self.move_i] = ( + f"Point {self.move_i}: {point_wgs.x():.6f}, {point_wgs.y():.6f}" + ) + + self.dlg.routing_fromline_list.clear() + for i, x in enumerate(items): + coords = x.split(":")[1] + item = f"Point {i}:{coords}" + self.dlg.routing_fromline_list.addItem(item) + self.create_rubber_band() + self.save_last_point(point, annotation) + self.mouseMoved.connect(lambda pos: self.change_cursor_on_hover(pos)) + + except ApiError as e: + if self.get_error_code(e) == 2010: + self.moving = False + self.dlg.routing_fromline_list.clear() + for i, x in enumerate(backup): + coords = x.split(":")[1] + item = f"Point {i}:{coords}" + self.dlg.routing_fromline_list.addItem(item) + self.dlg._reindex_list_items() + self.radius_message_box() + self.mouseMoved.connect(lambda pos: self.change_cursor_on_hover(pos)) + else: + raise e + except Exception as e: + if "Connection refused" in str(e): + self.api_key_message_bar() + else: + raise e + # Not moving release + else: + try: + if not self.dlg.isVisible(): + self.idx -= self.error_idxs + self.dlg.create_vertex(point, self.idx) + self.idx += 1 + self.error_idxs = 0 + + if self.dlg.routing_fromline_list.count() > 1: + self.create_rubber_band() + self.moving = False + except ApiError as e: + if self.get_error_code(e) == 2010: + self.error_idxs += 1 + num = len(self.dlg.routing_fromline_list) - 1 + + if num < 2: + self.dlg.routing_fromline_list.clear() + self.dlg._clear_annotations() + else: + self.dlg.routing_fromline_list.takeItem(num) + self.dlg._reindex_list_items() + self.create_rubber_band() + + self.radius_message_box() + else: + raise e + except Exception as e: + if "Connection refused" in str(e): + self.api_key_message_bar() + else: + raise e + + self.last_click = "single-click" + + def canvasDoubleClickEvent(self, e: QEvent) -> None: + """ + Populate line list widget with coordinates, end point moving and show dialog again. + """ + self.dlg.show() + self.last_click = "double-click" + + def create_rubber_band(self) -> None: + if self.dlg.rubber_band: + self.dlg.rubber_band.reset() + else: + self.dlg.rubber_band = QgsRubberBand(self.dlg.canvas, QgsWkbTypes.LineGeometry) + color = QColor(ROUTE_COLOR) + color.setAlpha(100) + self.dlg.rubber_band.setStrokeColor(color) + self.dlg.rubber_band.setWidth(5) + if self.dlg.toggle_preview.isChecked() and self.dlg.routing_fromline_list.count() > 1: + route_layer = router.route_as_layer(self.dlg) + if route_layer: + feature = next(route_layer.getFeatures()) + self.dlg.rubber_band.addGeometry(feature.geometry(), route_layer) + self.dlg.rubber_band.show() + else: + self.dlg._clear_annotations() + else: + dest_crs = self.dlg.canvas.mapSettings().destinationCrs() + original_crs = QgsCoordinateReferenceSystem("EPSG:4326") + transform = QgsCoordinateTransform(original_crs, dest_crs, QgsProject.instance()) + items = [ + self.dlg.routing_fromline_list.item(x).text() + for x in range(self.dlg.routing_fromline_list.count()) + ] + split = [x.split(":")[1] for x in items] + coords = [tuple(map(float, coord.split(", "))) for coord in split] + points_xy = [QgsPointXY(x, y) for x, y in coords] + reprojected_point = [transform.transform(point) for point in points_xy] + for point in reprojected_point: + if point == reprojected_point[-1]: + self.dlg.rubber_band.addPoint(point, True) + else: + self.dlg.rubber_band.addPoint(point, False) + self.dlg.rubber_band.show() + + def get_error_code(self, e: QEvent) -> int: + json_start_index = e.message.find("{") + json_end_index = e.message.rfind("}") + 1 + json_str = e.message[json_start_index:json_end_index] + error_dict = json.loads(json_str) + return error_dict["error"]["code"] + + def radius_message_box(self) -> None: + self.dlg._iface.messageBar().pushMessage( + self.tr("Please use a different point"), + self.tr("""Could not find routable point within a radius of 350.0 meters of specified coordinate. + Use a different point closer to a road."""), + level=Qgis.MessageLevel.Warning, + duration=3, + ) + + def api_key_message_bar(self) -> None: + self.dlg._iface.messageBar().pushMessage( + self.tr("Connection refused"), + self.tr("""Are your provider settings correct and the provider ready?"""), + level=Qgis.MessageLevel.Warning, + duration=3, + ) + + def _toggle_preview(self) -> None: + if self.dlg.routing_fromline_list.count() > 0: + state = not self.dlg.toggle_preview.isChecked() + try: + self.create_rubber_band() + except ApiError as e: + self.dlg.toggle_preview.setChecked(state) + if self.get_error_code(e) == 2010: + self.radius_message_box() + else: + raise e + except Exception as e: + self.toggle_preview.setChecked(state) + if "Connection refused" in str(e): + self.api_key_message_bar() + else: + raise e + + def save_last_point(self, point: QgsPointXY, annotation: QgsAnnotation) -> None: + """Saves tha last point and makes it deletable.""" + self.last_point = {"point": point, "annotation": annotation} + + for old_annotation in self.dlg.annotations: + color = old_annotation.markerSymbol().symbolLayer(0).color().name() + if color == "#ffff00": + symbol = QgsMarkerSymbol.createSimple({"color": "red"}) + old_annotation.setMarkerSymbol(symbol) + + symbol = QgsMarkerSymbol.createSimple({"color": "yellow"}) + annotation.setMarkerSymbol(symbol) diff --git a/ORStools/utils/router.py b/ORStools/utils/router.py new file mode 100644 index 00000000..5f578d9c --- /dev/null +++ b/ORStools/utils/router.py @@ -0,0 +1,124 @@ +import json + + +from qgis.core import ( + QgsVectorLayer, +) + +from PyQt5.QtWidgets import QMessageBox + +from ORStools.common import ( + client, + directions_core, +) +from ORStools.gui import directions_gui +from ORStools.utils import exceptions, logger, configmanager + +from qgis.PyQt.QtCore import QCoreApplication + + +def route_as_layer(dlg): + layer_out = QgsVectorLayer("LineString?crs=EPSG:4326", "Route_ORS", "memory") + layer_out.dataProvider().addAttributes(directions_core.get_fields()) + layer_out.updateFields() + + provider_id = dlg.provider_combo.currentIndex() + provider = configmanager.read_config()["providers"][provider_id] + + # if no API key is present, when ORS is selected, throw an error message + if not provider["key"] and provider["base_url"].startswith("https://api.openrouteservice.org"): + QMessageBox.critical( + dlg, + tr("Missing API key"), + tr(""" + Did you forget to set an API key for openrouteservice?

+ + If you don't have an API key, please visit https://openrouteservice.org/sign-up to get one.

+ Then enter the API key for openrouteservice provider in Web ► ORS Tools ► Provider Settings or the + settings symbol in the main ORS Tools GUI, next to the provider dropdown."""), + ) + return False + + agent = "QGIS_ORStoolsDialog" + clnt = client.Client(provider, agent) + clnt_msg = "" + + directions = directions_gui.Directions(dlg) + params = None + try: + params = directions.get_parameters() + if dlg.optimization_group.isChecked(): + if len(params["jobs"]) <= 1: # Start/end locations don't count as job + QMessageBox.critical( + dlg, + tr("Wrong number of waypoints"), + tr("""At least 3 or 4 waypoints are needed to perform routing optimization. + +Remember, the first and last location are not part of the optimization. + """), + ) + return + response = clnt.request("/optimization", {}, post_json=params) + feat = directions_core.get_output_features_optimization( + response, params["vehicles"][0]["profile"] + ) + else: + params["coordinates"] = directions.get_request_line_feature() + profile = dlg.routing_travel_combo.currentText() + # abort on empty avoid polygons layer + if ( + "options" in params + and "avoid_polygons" in params["options"] + and params["options"]["avoid_polygons"] == {} + ): + QMessageBox.warning( + dlg, + tr("Empty layer"), + tr(""" +The specified avoid polygon(s) layer does not contain any features. +Please add polygons to the layer or uncheck avoid polygons. + """), + ) + msg = "The request has been aborted!" + logger.log(msg, 0) + dlg.debug_text.setText(msg) + return + response = clnt.request("/v2/directions/" + profile + "/geojson", {}, post_json=params) + feat = directions_core.get_output_feature_directions( + response, profile, params["preference"], directions.options + ) + + layer_out.dataProvider().addFeature(feat) + + layer_out.updateExtents() + + return layer_out + + # Update quota; handled in client module after successful request + # if provider.get('ENV_VARS'): + # self.dlg.quota_text.setText(self.get_quota(provider) + ' calls') + except exceptions.Timeout: + msg = "The connection has timed out!" + logger.log(msg, 2) + dlg.debug_text.setText(msg) + return + + except (exceptions.ApiError, exceptions.InvalidKey, exceptions.GenericServerError) as e: + logger.log(f"{e.__class__.__name__}: {str(e)}", 2) + clnt_msg += f"{e.__class__.__name__}: ({str(e)})
" + raise + + except Exception as e: + logger.log(f"{e.__class__.__name__}: {str(e)}", 2) + clnt_msg += f"{e.__class__.__name__}: {str(e)}
" + raise + + finally: + # Set URL in debug window + if params: + clnt_msg += f'{clnt.url}
Parameters:
{json.dumps(params, indent=2)}' + dlg.debug_text.setHtml(clnt_msg) + + +def tr(self, string: str) -> str: + return QCoreApplication.translate(str(self.__class__.__name__), string) diff --git a/tests/test_gui.py b/tests/test_gui.py index 4986b812..d59d4fde 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -3,7 +3,7 @@ from qgis.PyQt.QtTest import QTest from qgis.PyQt.QtCore import Qt, QEvent, QPoint from qgis.PyQt.QtWidgets import QPushButton -from qgis.gui import QgsMapCanvas, QgsMapMouseEvent +from qgis.gui import QgsMapCanvas, QgsMapMouseEvent, QgsRubberBand from qgis.core import ( QgsCoordinateReferenceSystem, QgsRectangle, @@ -18,7 +18,7 @@ @pytest.mark.filterwarnings("ignore:.*imp module is deprecated.*") class TestGui(unittest.TestCase): - def test_ORStoolsDialog(self): + def test_without_live_preview(self): from ORStools.gui.ORStoolsDialog import ORStoolsDialog from ORStools.utils import maptools @@ -35,32 +35,213 @@ def test_ORStoolsDialog(self): self.assertTrue(dlg.isVisible()) map_button: QPushButton = dlg.routing_fromline_map - # click 'routing_fromline_map' + + # click green add vertices button QTest.mouseClick(map_button, Qt.LeftButton) self.assertFalse(dlg.isVisible()) self.assertIsInstance(CANVAS.mapTool(), maptools.LineTool) - map_dclick = QgsMapMouseEvent( + # click on canvas at [0, 0] + dlg.line_tool.canvasReleaseEvent(self.map_release(0, 0, Qt.LeftButton)) + + dlg.line_tool.canvasReleaseEvent(self.map_release(5, 5, Qt.LeftButton)) + + self.assertEqual(dlg.routing_fromline_list.count(), 2) + + len_rubber_band = len(dlg.rubber_band.asGeometry().asPolyline()) + print(dlg.rubber_band.asGeometry().asPolyline()) + self.assertEqual(len_rubber_band, 2) + + # doubleclick on canvas at [5, 5] + dlg.line_tool.canvasDoubleClickEvent(self.map_dclick(5, 5, Qt.LeftButton)) + self.assertTrue(dlg.isVisible()) + + # Check first item of list widget + self.assertEqual(dlg.routing_fromline_list.item(0).text(), "Point 0: -0.187575, 56.516620") + + # Check rubber band has only 2 vertices + self.assertEqual(dlg.routing_fromline_list.count(), 2) + self.assertEqual(type(dlg.rubber_band), QgsRubberBand) + len_rubber_band = len(dlg.rubber_band.asGeometry().asPolyline()) + self.assertEqual(len_rubber_band, 2) + + def test_with_live_preview(self): + """ + Tests basic adding and removing of points to the QListWidget and associated rubber bands. + """ + from ORStools.gui.ORStoolsDialog import ORStoolsDialog + from ORStools.utils import maptools + + CRS = QgsCoordinateReferenceSystem.fromEpsgId(3857) + CANVAS.setExtent(QgsRectangle(-13732628.1, 6181790.0, -13728426.7, 6179205.3)) + CANVAS.setDestinationCrs(CRS) + CANVAS.setFrameStyle(0) + CANVAS.resize(600, 400) + self.assertEqual(CANVAS.width(), 600) + self.assertEqual(CANVAS.height(), 400) + + dlg = ORStoolsDialog(IFACE) + dlg.open() + self.assertTrue(dlg.isVisible()) + + # Toggle live preview + dlg.toggle_preview.toggle() + self.assertTrue(dlg.toggle_preview.isChecked()) + + # click 'routing_fromline_map' + QTest.mouseClick(dlg.routing_fromline_map, Qt.LeftButton) + self.assertFalse(dlg.isVisible()) + self.assertIsInstance(CANVAS.mapTool(), maptools.LineTool) + + # click on canvas at [0, 0] + dlg.line_tool.canvasReleaseEvent(self.map_release(0, 0, Qt.LeftButton)) + # click on canvas at [5, 5] + dlg.line_tool.canvasReleaseEvent(self.map_release(5, 5, Qt.LeftButton)) + dlg.line_tool.canvasReleaseEvent(self.map_release(5, 0, Qt.LeftButton)) + dlg.line_tool.canvasReleaseEvent(self.map_release(0, 5, Qt.LeftButton)) + dlg.line_tool.canvasReleaseEvent(self.map_release(10, 0, Qt.LeftButton)) + + self.assertEqual( + dlg.routing_fromline_list.item(0).text(), "Point 0: -123.384059, 48.448463" + ) + + # Check that the live preview rubber band has more than two vertices + self.assertEqual(type(dlg.rubber_band), QgsRubberBand) + len_rubber_band = len(dlg.rubber_band.asGeometry().asPolyline()) + self.assertTrue(len_rubber_band > 2) + + # Right click and thus show dlg + dlg.line_tool.canvasReleaseEvent(self.map_release(0, 5, Qt.RightButton)) + self.assertTrue(dlg.isVisible()) + # Test that right click doesn't create a point + self.assertEqual(dlg.routing_fromline_list.count(), 5) + + # click on canvas at [10, 10] + # Check that the click with an open dlg doesn't create an entry + dlg.line_tool.canvasReleaseEvent(self.map_release(10, 10, Qt.LeftButton)) + self.assertEqual(dlg.routing_fromline_list.count(), 5) + + # test whether point order remains valid when selected points are deleted from QListWidget + dlg.routing_fromline_list.setCurrentRow(1) + dlg.routing_fromline_clear.clicked.emit() + + # click again after deletion + QTest.mouseClick(dlg.routing_fromline_map, Qt.LeftButton) + self.assertFalse(dlg.isVisible()) + dlg.line_tool.canvasReleaseEvent(self.map_release(10, 10, Qt.LeftButton)) + + # Right click and thus show dlg + dlg.line_tool.canvasReleaseEvent(self.map_release(0, 5, Qt.RightButton)) + self.assertTrue(dlg.isVisible()) + + self.assertEqual(dlg.routing_fromline_list.count(), 5) + numbers = [int(i.document().toPlainText()) for i in dlg.annotations] + self.assertTrue(numbers == list(range(numbers[0], numbers[0] + len(numbers)))) + + # Disable live preview + dlg.toggle_preview.toggle() + self.assertFalse(dlg.toggle_preview.isChecked()) + + # Check rubber band has only 5 vertices + self.assertEqual(dlg.routing_fromline_list.count(), 5) + self.assertEqual(type(dlg.rubber_band), QgsRubberBand) + len_rubber_band = len(dlg.rubber_band.asGeometry().asPolyline()) + self.assertEqual(len_rubber_band, 5) + + # Click Add Vertices again + QTest.mouseClick(dlg.routing_fromline_map, Qt.LeftButton) + self.assertFalse(dlg.isVisible()) + + # continue digitization + # click on canvas at [10, 5] + dlg.line_tool.canvasReleaseEvent(self.map_release(10, 5, Qt.LeftButton)) + self.assertEqual(dlg.routing_fromline_list.count(), 6) + + # Double click and thus show dlg + dlg.line_tool.canvasDoubleClickEvent(self.map_dclick(0, 5, Qt.LeftButton)) + self.assertTrue(dlg.isVisible()) + + # clear list widget and check that it's empty + QTest.mouseClick(dlg.routing_fromline_clear, Qt.LeftButton) + self.assertEqual(dlg.routing_fromline_list.count(), 0) + # Check that the rubber band is empty + self.assertEqual(type(dlg.rubber_band), QgsRubberBand) + self.assertTrue(dlg.rubber_band.asGeometry().isNull()) + + def test_drag_drop_with_live_preview(self): + from ORStools.gui.ORStoolsDialog import ORStoolsDialog + from ORStools.utils import maptools + + CRS = QgsCoordinateReferenceSystem.fromEpsgId(3857) + CANVAS.setExtent(QgsRectangle(-13732628.1, 6181790.0, -13728426.7, 6179205.3)) + CANVAS.setDestinationCrs(CRS) + CANVAS.setFrameStyle(0) + CANVAS.resize(600, 400) + self.assertEqual(CANVAS.width(), 600) + self.assertEqual(CANVAS.height(), 400) + + dlg = ORStoolsDialog(IFACE) + dlg.open() + self.assertTrue(dlg.isVisible()) + + # click 'routing_fromline_map' + QTest.mouseClick(dlg.routing_fromline_map, Qt.LeftButton) + self.assertFalse(dlg.isVisible()) + self.assertIsInstance(CANVAS.mapTool(), maptools.LineTool) + + # Add some points to the list + dlg.line_tool.canvasReleaseEvent(self.map_release(100, 5, Qt.LeftButton)) + dlg.line_tool.canvasReleaseEvent(self.map_release(10, 50, Qt.LeftButton)) + dlg.line_tool.canvasReleaseEvent(self.map_release(100, 50, Qt.LeftButton)) + + # Add point to be dragged + dlg.line_tool.canvasReleaseEvent(self.map_release(10, 5, Qt.LeftButton)) + self.assertEqual(dlg.routing_fromline_list.count(), 4) + self.assertEqual( + dlg.routing_fromline_list.item(3).text(), "Point 3: -123.375767, 48.445713" + ) + + # Press at previous position + dlg.line_tool.canvasPressEvent(self.map_press(11, 5, Qt.LeftButton)) + + # Release somewhere else + dlg.line_tool.canvasReleaseEvent(self.map_release(50, 10, Qt.LeftButton)) + self.assertEqual(dlg.routing_fromline_list.count(), 4) + # Check that the coordinates of the point at the same position in the list has changed + self.assertEqual( + dlg.routing_fromline_list.item(3).text(), "Point 3: -123.342597, 48.442962" + ) + + # Check that the rubber band is not empty + self.assertEqual(type(dlg.rubber_band), QgsRubberBand) + self.assertFalse(dlg.rubber_band.asGeometry().isNull()) + + def map_release(self, x, y, side): + return QgsMapMouseEvent( CANVAS, - QEvent.MouseButtonDblClick, - QPoint(5, 5), # Relative to the canvas' dimensions - Qt.LeftButton, - Qt.LeftButton, + QEvent.MouseButtonRelease, + QPoint(x, y), # Relative to the canvas' dimensions + side, + side, Qt.NoModifier, ) - map_click = QgsMapMouseEvent( + def map_press(self, x, y, side): + return QgsMapMouseEvent( CANVAS, - QEvent.MouseButtonRelease, - QPoint(0, 0), # Relative to the canvas' dimensions - Qt.LeftButton, - Qt.LeftButton, + QEvent.MouseButtonPress, + QPoint(x, y), # Relative to the canvas' dimensions + side, + side, Qt.NoModifier, ) - # click on canvas at [0, 0] - dlg.line_tool.canvasReleaseEvent(map_click) - # doubleclick on canvas at [5, 5] - dlg.line_tool.canvasDoubleClickEvent(map_dclick) - self.assertTrue(dlg.isVisible()) - self.assertEqual(dlg.routing_fromline_list.item(0).text(), "Point 0: -0.187575, 56.516620") + def map_dclick(self, x, y, side): + return QgsMapMouseEvent( + CANVAS, + QEvent.MouseButtonDblClick, + QPoint(x, y), # Relative to the canvas' dimensions + side, + side, + Qt.NoModifier, + )