PyQGIS – Modifying Specific Vertices of MultiLineString in PyQGIS

multipartpyqgis

First of, this question is related to this one: Snapping lines' start_point/end_points to the closest point features to them. I am trying to modify my answer to make it work with MultiLineStrings.


Basically I have two inputs:

  1. MultiLineString Layer
  2. MultiPoint Layer

I am now trying to snap the start and end points of each part of the MultiLines to the closest Point. Therefore the MultiPoints are converted to SinglePoints and the nearest points geometry is beeing read. Now the issue is, that it seems like I am not able to modify the Parts of the MultiLineString by using

  • part.setXAt()
  • part.setYAt()
  • part.moveVertex()

Link to the QGIS-Docs. Debugging all this using print() statements actually return the correct results. Start and endpoints seem to be modified and the correctly modified LineString is printed.

However, there are no changes made in the actual geometries. The input lines just stay as they were before running the script, no error, no nothing. So my question is: why doesnt my script commit the changes? And in case setXAt() and setYAt() are not meant to change things, what are they for and what could I use instead?

Here is the entire code:

points = QgsProject.instance().mapLayersByName('points')[0]
lines = QgsProject.instance().mapLayersByName('lines')[0]
tolerance = 10000 # snapping tolerance in CRS units

if QgsWkbTypes.isMultiType(points.wkbType()): # if point-input is of type multigeometry
    res = processing.run("native:multiparttosingleparts",{'INPUT':points,'OUTPUT':'TEMPORARY_OUTPUT'}) # convert to singlepoint
    singlepoints = res['OUTPUT']
else:
    singlepoints = points

# creat the spatial index of the pointlayer
points_idx = QgsSpatialIndex(singlepoints.getFeatures())

with edit(lines):
    for i, feat in enumerate(lines.getFeatures()):
        for j, part in enumerate(geom.parts()):
            # get start and end point of line
            line_startgeom = QgsPointXY(part.startPoint())
            line_endgeom = QgsPointXY(part.endPoint())
            print('\n')
            print('Feat: ' + str(i) + ' Part: ' + str(j) + ' OriginalLine: ' + str(part))

            # find nearest point of point layer to start and endpoint of line
            nearestneighbor_start = points_idx.nearestNeighbor(line_startgeom, neighbors=1, maxDistance=tolerance)
            nearestneighbor_end = points_idx.nearestNeighbor(line_endgeom, neighbors=1, maxDistance=tolerance)
            
            if len(nearestneighbor_start) > 0:# only adjust the startpoint if there is a nearest point within the maxdistance
                nearpoint_startgeom = singlepoints.getFeature(nearestneighbor_start[0]).geometry() # get the geometry of the nearest point in pointlayer
                part.setXAt(0, nearpoint_startgeom.asPoint().x())
                part.setYAt(0, nearpoint_startgeom.asPoint().y())

            if len(nearestneighbor_end) > 0:
                nearpoint_endgeom = singlepoints.getFeature(nearestneighbor_end[0]).geometry()
                part.setXAt(-1, nearpoint_endgeom.asPoint().x())
                part.setYAt(-1, nearpoint_endgeom.asPoint().y())
                #part.moveVertex(QgsVertexId(-1,-1,-1),QgsPoint(nearpoint_endgeom.asPoint())) # honestly, QgsVertexId is not well documented, so I have no idea how this is supposed to work...
                #print(str(part.xAt(-1))+ '\n' + str(nearpoint_endgeom.asPoint())+'\n')
            
            print('Feat: ' + str(i) + ' Part: ' + str(j) + ' ModifiedLine: ' + str(part))
            lines.updateFeature(feat)
lines.commitChanges()

And an example output from the print statement:

Feat: 2 Part: 3 OriginalLine: <QgsLineString: LineString (689753.69139794236980379 5338632.41991709917783737, 697525.0708597571356222 5339841.7065342990681529, 695706.26743028790224344 5337553.34959724545478821)>
Feat: 2 Part: 3 ModifiedLine: <QgsLineString: LineString (688633.31491136807017028 5338762.2247987687587738, 697525.0708597571356222 5339841.7065342990681529, 695385.03714443475473672 5337152.7669952092692256)>

As you can see it prints different line strings, but does not change geometries.

Best Answer

About why it does not modify the actual vertices, here a quote from the docs --> its a read only copy:

vertices(self) → QgsVertexIterator

Returns a read-only, Java-style iterator for traversal of vertices of all the geometry, including all geometry parts and rings.

The iterator returns a copy of individual vertices, and accordingly geometries cannot be modified using the iterator. See transformVertices() for a safe method to modify vertices “in-place”.

I guess, even thoug I do not use vertices(), part.setXAt(), part.setYAt() and part.moveVertex() apply to it.


What you can do instead:

I guess there is a more intuitive way to do this, but for now I am stepping back and make use of QgsVectorLayerEditUtils(layer).moveVertex() again. Luckily Python has dictionarys which can be used for a lot of stuff, just like this:

So basically I am iterating over each vertex of each part of each feature and check wheter the current vertex is a start- or an endpoint of the current part. If so, I'll search for the nearest point, read its geometry and store this geometry as value in the dictionary and the index of the current vertex as key. So I get a dictionary of vertexindizes I want to move (keys) and points where these vertices should be moved to (values). When iterating over this dictionary, I can go back using QgsVectorLayerEditUtils(layer).moveVertex(value.x(),value.y(),feat.id(),key) instead of the not working part.moveVertex(), part.setXAt() or part.setYAt() methods. Simple as that; here is the complete code for reference with comments as further explanations:

points = QgsProject.instance().mapLayersByName('points')[0]
lines = QgsProject.instance().mapLayersByName('lines')[0]
tolerance = 10000 # snapping tolerance in CRS units

if QgsWkbTypes.isMultiType(points.wkbType()): # if point-input is of type multigeometry
    res = processing.run("native:multiparttosingleparts",{'INPUT':points,'OUTPUT':'TEMPORARY_OUTPUT'}) # convert to singlepoint
    singlepoints = res['OUTPUT']
else: # if point-input is of type singlegeometry 
    singlepoints = points # leave the inputlayer as it is

# create the spatial index of the pointlayer
points_idx = QgsSpatialIndex(singlepoints.getFeatures())

with edit(lines):
    for feat in lines.getFeatures():
        geom = feat.geometry()
        vert = 0 # reset vertices-count on every new feature
        vert_dict = {} # clear vertices dictionary on every new feature
        for part in geom.parts(): # iterate through all parts of each feature
            for vertex in part.vertices(): # iterate through all vertices of each part
                if part.startPoint() == geom.vertexAt(vert): # if the current vertex is a startpoint, then:
                    nearestneighbor_start = points_idx.nearestNeighbor(QgsPointXY(part.startPoint()), neighbors=1, maxDistance=tolerance) # find the closest point to the current startpoint
                    if len(nearestneighbor_start) > 0:# only adjust the startpoint if there is a nearest point within the maxdistance
                        nearpoint_startgeom = singlepoints.getFeature(nearestneighbor_start[0]).geometry() # get the geometry of the nearest point in pointlayer
                        vert_dict[vert] = nearpoint_startgeom.asPoint() # add the index of the current vertex as key and the geometry of the closest point as value to the dictionary
                elif part.endPoint() == geom.vertexAt(vert):
                    nearestneighbor_end = points_idx.nearestNeighbor(QgsPointXY(part.endPoint()), neighbors=1, maxDistance=tolerance)
                    if len(nearestneighbor_end) > 0:
                        nearpoint_endgeom = singlepoints.getFeature(nearestneighbor_end[0]).geometry()
                        vert_dict[vert] = nearpoint_endgeom.asPoint()
                else:
                    pass # if current vertex is not a start or end point skip it...
                vert += 1 # increase vertices-counter
        for vertindex, newpoint in vert_dict.items(): # for every feature iterate over the just created dictionary (vertindex (=dict key) is the start or endpoint we want to move and newpoint (=dict value) the position we want to move it to)
            QgsVectorLayerEditUtils(lines).moveVertex(newpoint.x(),newpoint.y(),feat.id(),vertindex) # use QgsVectorLayerEditUtils to edit the vertex, for more details see https://qgis.org/pyqgis/3.4/core/QgsVectorLayerEditUtils.html#qgis.core.QgsVectorLayerEditUtils
Related Question