diff --git a/plugin/teksi_wastewater/gui/twwreachsplitter.py b/plugin/teksi_wastewater/gui/twwreachsplitter.py new file mode 100644 index 000000000..c68b4b86a --- /dev/null +++ b/plugin/teksi_wastewater/gui/twwreachsplitter.py @@ -0,0 +1,121 @@ +# ----------------------------------------------------------- +# +# TEKSI Wastewater +# Copyright (C) 2014 Matthias Kuhn +# ----------------------------------------------------------- +# +# licensed under the terms of GNU GPL 2 +# +# 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. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, print to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# +# --------------------------------------------------------------------- + +import logging + +from qgis.gui import QgsMessageBar +from qgis.PyQt.QtCore import pyqtSlot +from qgis.PyQt.QtWidgets import QDockWidget + +from ..tools.twwsplitreach import TwwMapToolSplitReachWithNode +from ..utils import get_ui_class +from ..utils.twwlayermanager import TwwLayerManager + +DOCK_WIDGET = get_ui_class("twwreachsplitter.ui") + + +class TwwReachSplitter(QDockWidget, DOCK_WIDGET): + logger = logging.getLogger(__name__) + + def __init__(self, parent, iface): + QDockWidget.__init__(self, parent) + self.setupUi(self) + self.stateButton.clicked.connect(self.stateChanged) + self.iface = iface + self.mapToolSplitReachWithWN = TwwMapToolSplitReachWithNode( + self.iface, + TwwLayerManager.layer("vw_wastewater_node"), + self.mCbChannelSplitMode.isChecked(), + ) + self.mapToolSplitReachWithWS = TwwMapToolSplitReachWithNode( + self.iface, TwwLayerManager.layer("vw_tww_wastewater_structure") + ) + self.mapToolSplitReachWithNoNode = TwwMapToolSplitReachWithNode( + self.iface, None, self.mCbChannelSplitMode.isChecked() + ) + self.layerComboBox.insertItem( + self.layerComboBox.count(), + self.tr("Wastewater Structure"), + "wastewater_structure", + ) + self.layerComboBox.insertItem( + self.layerComboBox.count(), self.tr("Wastewater node"), "wastewater_node" + ) + self.layerComboBox.insertItem(self.layerComboBox.count(), self.tr("No node"), "no node") + self.layerComboBox.setCurrentIndex(1) + self.stateButton.setProperty("state", "inactive") + self.msgtitle = self.tr( + f"Split reach with {self.layerComboBox.itemData(self.layerComboBox.currentIndex())}" + ) + self.msg = "Left Click to digitize" + self.messageBarItem = QgsMessageBar.createMessage(self.msgtitle, self.msg) + self.layerComboBox.currentIndexChanged.connect(self.layerChanged) + + @pyqtSlot(int) + def layerChanged(self, index): + try: + self.iface.messageBar().popWidget(self.messageBarItem) + except Exception: + pass + if ( + self.layerComboBox.itemData(self.layerComboBox.currentIndex()) + == "wastewater_structure" + ): + lyr = TwwLayerManager.layer("vw_tww_wastewater_structure") + lyr.startEditing() + self.iface.mapCanvas().setMapTool(self.mapToolSplitReachWithWS) + + elif self.layerComboBox.itemData(self.layerComboBox.currentIndex()) == "wastewater_node": + lyr = TwwLayerManager.layer("vw_wastewater_node") + lyr.startEditing() + self.iface.mapCanvas().setMapTool(self.mapToolSplitReachWithWN) + else: # current index is not initialized + # set current index for messageBar + self.layerComboBox.setCurrentIndex(1) + + if self.stateButton.property("state") == "active": + self.msgtitle = self.tr( + f"Split reach with {self.layerComboBox.itemData(self.layerComboBox.currentIndex())}" + ) + self.messageBarItem = QgsMessageBar.createMessage(self.msgtitle, self.msg) + self.iface.messageBar().pushItem(self.messageBarItem) + + @pyqtSlot() + def stateChanged(self): + try: + self.iface.messageBar().popWidget(self.messageBarItem) + except Exception: + pass + + if self.stateButton.property("state") != "active": + self.layerComboBox.setEnabled(True) + self.layerChanged(0) + self.stateButton.setText(self.tr("Stop Splitting Reaches")) + self.stateButton.setProperty("state", "active") + self.messageBarItem = QgsMessageBar.createMessage(self.msgtitle, self.msg) + self.iface.messageBar().pushItem(self.messageBarItem) + else: + self.layerComboBox.setEnabled(False) + self.stateButton.setText(self.tr("Start Splitting Reaches")) + self.stateButton.setProperty("state", "inactive") diff --git a/plugin/teksi_wastewater/icons/reachsplitter.svg b/plugin/teksi_wastewater/icons/reachsplitter.svg new file mode 100644 index 000000000..3e9fcf9a5 --- /dev/null +++ b/plugin/teksi_wastewater/icons/reachsplitter.svg @@ -0,0 +1,179 @@ + + + +image/svg+xml diff --git a/plugin/teksi_wastewater/teksi_wastewater_plugin.py b/plugin/teksi_wastewater/teksi_wastewater_plugin.py index c1ea38341..26110ba0b 100644 --- a/plugin/teksi_wastewater/teksi_wastewater_plugin.py +++ b/plugin/teksi_wastewater/teksi_wastewater_plugin.py @@ -38,6 +38,7 @@ except ImportError: TwwPlotSVGWidget = None from .gui.twwprofiledockwidget import TwwProfileDockWidget +from .gui.twwreachsplitter import TwwReachSplitter from .gui.twwsettingsdialog import TwwSettingsDialog from .gui.twwwizard import TwwWizard from .interlis import config @@ -72,6 +73,9 @@ class TeksiWastewaterPlugin: # Wizard wizarddock = None + # Reach splitter + reachsplitterdock = None + # The layer ids the plugin will need edgeLayer = None nodeLayer = None @@ -196,6 +200,16 @@ def initGui(self): self.wizardAction.setCheckable(True) self.wizardAction.triggered.connect(self.wizard) + self.reachsplitterAction = QAction( + QIcon(os.path.join(plugin_root_path(), "icons/reachsplitter.svg")), + "Reach Splitter", + self.iface.mainWindow(), + ) + self.reachsplitterAction.setWhatsThis(self.tr("Split Reaches with nodes")) + self.reachsplitterAction.setEnabled(False) + self.reachsplitterAction.setCheckable(True) + self.reachsplitterAction.triggered.connect(self.reachsplitter) + self.connectNetworkElementsAction = QAction( QIcon(os.path.join(plugin_root_path(), "icons/link-wastewater-networkelement.svg")), QApplication.translate("teksi_wastewater", "Connect wastewater networkelements"), @@ -250,6 +264,7 @@ def initGui(self): self.toolbar.addAction(self.upstreamAction) self.toolbar.addAction(self.downstreamAction) self.toolbar.addAction(self.wizardAction) + self.toolbar.addAction(self.reachsplitterAction) self.toolbar.addAction(self.refreshNetworkTopologyAction) self.toolbar.addAction(self.connectNetworkElementsAction) @@ -270,6 +285,7 @@ def initGui(self): self.toolbarButtons.append(self.upstreamAction) self.toolbarButtons.append(self.downstreamAction) self.toolbarButtons.append(self.wizardAction) + self.toolbarButtons.append(self.reachsplitterAction) self.toolbarButtons.append(self.refreshNetworkTopologyAction) self.toolbarButtons.append(self.importAction) self.toolbarButtons.append(self.exportAction) @@ -318,6 +334,7 @@ def unload(self): self.toolbar.removeAction(self.upstreamAction) self.toolbar.removeAction(self.downstreamAction) self.toolbar.removeAction(self.wizardAction) + self.toolbar.removeAction(self.reachsplitterAction) self.toolbar.removeAction(self.refreshNetworkTopologyAction) self.toolbar.removeAction(self.connectNetworkElementsAction) @@ -381,6 +398,14 @@ def wizard(self): self.iface.addDockWidget(Qt.LeftDockWidgetArea, self.wizarddock) self.wizarddock.show() + def reachsplitter(self): + """""" + if not self.reachsplitterdock: + self.reachsplitterdock = TwwReachSplitter(self.iface.mainWindow(), self.iface) + self.logger.debug("Opening Reach Splitter") + self.iface.addDockWidget(Qt.LeftDockWidgetArea, self.reachsplitterdock) + self.reachsplitterdock.show() + def connectNetworkElements(self, checked): self.iface.mapCanvas().setMapTool(self.maptool_connect_networkelements) diff --git a/plugin/teksi_wastewater/tools/twwsplitreach.py b/plugin/teksi_wastewater/tools/twwsplitreach.py new file mode 100644 index 000000000..278e5c3f0 --- /dev/null +++ b/plugin/teksi_wastewater/tools/twwsplitreach.py @@ -0,0 +1,363 @@ +# ----------------------------------------------------------- +# +# Profile +# Copyright (C) 2024 TEKSI +# ----------------------------------------------------------- +# +# licensed under the terms of GNU GPL 2 +# +# 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. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this progsram; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# +# --------------------------------------------------------------------- + +""" +This module provides functions to split TWW reaches. +""" + + +from qgis.core import ( + NULL, + QgsFeature, + QgsFeatureRequest, + QgsPoint, + QgsPointXY, + QgsSnappingConfig, + QgsTolerance, +) +from qgis.gui import ( + QgisInterface, + QgsAttributeEditorContext, + QgsMapCanvasSnappingUtils, + QgsMapToolAdvancedDigitizing, + QgsMessageBar, + QgsVertexMarker, +) +from qgis.PyQt.QtCore import Qt +from qgis.PyQt.QtGui import QColor, QCursor + +from ..utils.twwlayermanager import TwwLayerManager + + +class TwwMapToolSplitReachWithNode(QgsMapToolAdvancedDigitizing): + """ + This is used to split a reach feature by creating a node. + + It lets you create a node and then uses this node to split the reach layer + and add a wastewater node/strucrure + + + """ + + def __init__(self, iface: QgisInterface, layer, split_channels=True): + # TwwMapToolAddFeature __init__ without rubberband + QgsMapToolAdvancedDigitizing.__init__(self, iface.mapCanvas(), iface.cadDockWidget()) + self.iface = iface + self.canvas = iface.mapCanvas() + self.layer = layer + self.split_channels = split_channels + + # see TwwMapToolAddReach __init__ + self.snapping_marker = None + self.node_layer = layer + self.reach_layer = TwwLayerManager.layer("vw_tww_reach") + assert self.reach_layer is not None + self.setAdvancedDigitizingAllowed(True) + self.setAutoSnapEnabled(True) + + layer_snapping_configs = [ + {"layer": self.reach_layer, "mode": QgsSnappingConfig.VertexAndSegment}, + ] + self.snapping_configs = [] + self.snapping_utils = QgsMapCanvasSnappingUtils(self.iface.mapCanvas()) + + for lsc in layer_snapping_configs: + config = QgsSnappingConfig() + config.setMode(QgsSnappingConfig.AdvancedConfiguration) + config.setEnabled(True) + settings = QgsSnappingConfig.IndividualLayerSettings( + True, lsc["mode"], 10, QgsTolerance.ProjectUnits + ) + config.setIndividualLayerSettings(lsc["layer"], settings) + self.snapping_configs.append(config) + + # prepare messageBarItem + self.msgtitle = self.tr( + f"Split reach with {self.node_layer.name() if self.node_layer else 'no wastewater node'}" + ) + msg = None + self.messageBarItem = QgsMessageBar.createMessage(self.msgtitle, msg) + + def activate(self): + """ + Map tool is activated + """ + QgsMapToolAdvancedDigitizing.activate(self) + self.canvas.setCursor(QCursor(Qt.CrossCursor)) + + def deactivate(self): + """ + Map tool is deactivated + """ + try: + self.iface.messageBar().popWidget(self.messageBarItem) + except Exception: + pass + QgsMapToolAdvancedDigitizing.deactivate(self) + self.canvas.unsetCursor() + + def isZoomTool(self): + """ + This is no zoom tool + """ + return False + + # =========================================================================== + # Events + # =========================================================================== + + def cadCanvasMoveEvent(self, event): + """ + Mouse is moved: Update snap + :param event: coordinates etc. + """ + _, match, _ = self.snap(event) + # snap indicator + if not match.isValid(): + if self.snapping_marker is not None: + self.iface.mapCanvas().scene().removeItem(self.snapping_marker) + self.snapping_marker = None + return + + # TODO QGIS 3: see if vertices can be removed + + # we have a valid match + if self.snapping_marker is None: + self.snapping_marker = QgsVertexMarker(self.iface.mapCanvas()) + self.snapping_marker.setPenWidth(3) + self.snapping_marker.setColor(QColor(Qt.magenta)) + + if match.hasVertex(): + if match.layer(): + icon_type = QgsVertexMarker.ICON_BOX # vertex snap + else: + icon_type = QgsVertexMarker.ICON_X # intersection snap + elif match.hasEdge(): # more robust than a simple else clause for further usage + icon_type = QgsVertexMarker.ICON_DOUBLE_TRIANGLE + else: # debug only + icon_type = QgsVertexMarker.CIRCLE + self.snapping_marker.setIconType(icon_type) + self.snapping_marker.setCenter(match.point()) + + def cadCanvasReleaseEvent(self, event): + if event.button() == Qt.RightButton: + self.right_clicked() + if event.button() == Qt.LeftButton: + self.left_clicked(event) + + def mouse_move(self, event): + _, match, _ = self.snap(event) + # snap indicator + if not match.isValid(): + if self.snapping_marker is not None: + self.iface.mapCanvas().scene().removeItem(self.snapping_marker) + self.snapping_marker = None + return + + # TODO QGIS 3: see if vertices can be removed + + # we have a valid match + if self.snapping_marker is None: + self.snapping_marker = QgsVertexMarker(self.iface.mapCanvas()) + self.snapping_marker.setPenWidth(3) + self.snapping_marker.setColor(QColor(Qt.magenta)) + + if match.hasVertex(): + if match.layer(): + icon_type = QgsVertexMarker.ICON_BOX # vertex snap + else: + icon_type = QgsVertexMarker.ICON_X # intersection snap + else: + icon_type = QgsVertexMarker.ICON_DOUBLE_TRIANGLE # must be segment snap + self.snapping_marker.setIconType(icon_type) + self.snapping_marker.setCenter(match.point()) + + # =========================================================================== + # Actions + # =========================================================================== + + def left_clicked(self, event): + """ + When the canvas is left clicked we add a new point. + :type event: QMouseEvent + """ + self.finishEditing(event) + + def right_clicked(self, _): + """ + On a right click nothing happens + """ + + def snap(self, event): + """ + Snap to nearby points on the reach layer which may be used as connection + points for this reach. + :param event: The mouse event + :return: The snapped position in map coordinates, match and snapped vertex id + + """ + + for config in self.snapping_configs: + self.snapping_utils.setConfig(config) # only snap to reaches + match = self.snapping_utils.snapToMap(QgsPointXY(event.originalMapPoint())) + if match.isValid(): + if match.layer(): + req = QgsFeatureRequest(match.featureId()) + f = next(match.layer().getFeatures(req)) + assert f.isValid() + (ok, vertex_id) = f.geometry().vertexIdFromVertexNr(match.vertexIndex()) + assert ok + + if match.hasVertex(): + point = f.geometry().constGet().vertexAt(vertex_id) + assert isinstance(point, QgsPoint) + return point, match, vertex_id + else: + return QgsPoint(match.point()), match, None + + if match.hasEdge(): + point = match.interpolatedPoint(match.layer().sourceCrs()) + assert isinstance(point, QgsPoint) + return point, match, vertex_id + else: + return QgsPoint(match.point()), match, None + + return QgsPoint(event.originalMapPoint()), match, None + + def finishEditing(self, event): + # snap + point3d, match, vertex_id = self.snap(event) + if self.snapping_marker is not None: + self.iface.mapCanvas().scene().removeItem(self.snapping_marker) + self.snapping_marker = None + + # create point feature + if self.point_layer: + fields = self.node_layer.fields() + f = QgsFeature(fields) + for idx, _ in enumerate(fields): + v = self.node_layer.defaultValue(idx, f) + if v != NULL: + f.setAttribute(idx, v) + else: + f.setAttribute(idx, self.reach_layer.dataProvider().defaultValue(idx)) + # alter geometry and bottom level + f.setGeometry(point3d) + if self.node_layer.id() == "vw_tww_wastewater_structure": + prefix = "wn_" + else: + prefix = "" + lvl_field = fields.indexFromName(f"{prefix}bottom_level") + if point3d.z() == point3d.z(): # check for nan + f.setAttribute(lvl_field, point3d.z()) + dlg = self.iface.getFeatureForm(self.node_layer, f) + dlg.setMode(QgsAttributeEditorContext.AddFeatureMode) + dlg.exec_() + oid_idx = self.node_layer.fields().indexFromName(f"{prefix}obj_id") + if dlg.feature().attributes()[lvl_field]: + point3d.setZ( + dlg.feature().attributes()[lvl_field] + ) # update if level was altered in dlg + pt_oid = dlg.feature().attributes()[oid_idx] + + # split reach + req = QgsFeatureRequest(match.featureId()) + f_old = next(match.layer().getFeatures(req)) + assert f_old.isValid() + + # split using Point instead of PointXY is documented but fails with 3.28.11 + split_line = [QgsPointXY(point3d), QgsPointXY(point3d)] + result, new_geometries, _ = f_old.geometry().splitGeometry(split_line, True, True) + assert len(new_geometries) == 2 + re_oid_field = self.reach_layer.fields().indexFromName("obj_id") + re_oid_to = self.reach_layer.dataProvider().defaultValue(re_oid_field) + re_oid_from = self.reach_layer.dataProvider().defaultValue(re_oid_field) + for geoms in new_geometries: + assert geoms + fields = self.reach_layer.fields() + if point3d.equals(geoms.vertexAt(0)): + dest = "from" + else: + dest = "to" + + f = QgsFeature(fields) + if self.split_channels: + keep_fields = [ + "ws_status", + "ws_year_of_construction", + "ws_fk_owner", + "ws_fk_operator", + "ch_usage_current", + "ch_function_hierarchic", + "ch_function_hydraulic", + ] + else: + # keep all wastewater structure and channel fields + keep_fields = [field for field in fields if field[0:2] in ["ch", "ws"]] + # now add the other fields you always want to keep + keep_fields.extend( + [ + "clear_height", + "material", + "horizontal_positioning", + "inside_coating", + "fk_pipe_profile", + "remark", + "rp_from_obj_id", + "rp_to_obj_id", + ] + ) + keep_fields.remove(f"rp_{dest}_obj_id") + for idx, field in enumerate(fields): + if field in keep_fields: + f.setAttribute(idx, f_old.attributes()[idx]) + elif field == re_oid_field: + if dest == "from": + f.setAttribute(idx, re_oid_from) + else: + f.setAttribute(idx, re_oid_to) + else: + # try client side default value first + v = self.reach_layer.defaultValue(idx, f) + if v != NULL: + f.setAttribute(idx, v) + else: + f.setAttribute(idx, self.reach_layer.dataProvider().defaultValue(idx)) + + f.setGeometry(geoms) + ne = self.reach_layer.fields().indexFromName(f"rp_{dest}_fk_wastewater_networkelement") + if self.point_layer: + f.setAttribute(ne, pt_oid) + else: + if dest == "from": + f.setAttribute(ne, re_oid_to) + else: + f.setAttribute(ne, re_oid_from) + + lvl = self.reach_layer.fields().indexFromName(f"rp_{dest}_level") + f.setAttribute(lvl, point3d.z()) + ne = self.reach_layer.fields().indexFromName(f"rp_{dest}_fk_wastewater_networkelement") + self.reach_layer.dataProvider().addFeatures([f]) + + self.reach_layer.deleteFeature(f_old.id()) diff --git a/plugin/teksi_wastewater/ui/twwreachsplitter.ui b/plugin/teksi_wastewater/ui/twwreachsplitter.ui new file mode 100644 index 000000000..746bfb099 --- /dev/null +++ b/plugin/teksi_wastewater/ui/twwreachsplitter.ui @@ -0,0 +1,70 @@ + + + TwwDockWidget + + + + 0 + 0 + 327 + 136 + + + + TWW Reach Splitter + + + + + + + Create + + + + + + + Start Splitting Reaches + + + + + + + false + + + true + + + + + + + Split the channel too. This field is ignored when splitting a reach with a manhole + + + Split channel + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + +