From 5b1678ef211fbac9593e91a1a4b4329cc399a5c3 Mon Sep 17 00:00:00 2001 From: uclaros Date: Fri, 15 May 2026 15:59:06 +0300 Subject: [PATCH 1/8] Allow adding a new part to multipart geometries --- app/maptools/recordingmaptool.cpp | 30 ++ app/maptools/recordingmaptool.h | 7 + app/qml/map/MMMapController.qml | 48 +++- app/test/testmaptools.cpp | 448 ++++++++++++++++++++++++++++++ app/test/testmaptools.h | 6 + 5 files changed, 536 insertions(+), 3 deletions(-) diff --git a/app/maptools/recordingmaptool.cpp b/app/maptools/recordingmaptool.cpp index d4e98e9bb..ad10726c0 100644 --- a/app/maptools/recordingmaptool.cpp +++ b/app/maptools/recordingmaptool.cpp @@ -1150,6 +1150,36 @@ void RecordingMapTool::cancelGrab() setActiveVertex( Vertex() ); } +void RecordingMapTool::startDigitizingNewPart() +{ + // if maptool is in GRAB and VIEW state, no part should be added + if ( mState == RecordingMapTool::View || mState == RecordingMapTool::Grab ) + { + return; + } + + QgsAbstractGeometry *geom = mRecordedGeometry.get(); + if ( QgsGeometryCollection *collection = qgsgeometry_cast( geom ) ) + { + switch ( mRecordedGeometry.type() ) + { + case Qgis::GeometryType::Line: + collection->addGeometry( new QgsLineString() ); + setActivePartAndRing( collection->partCount() - 1, 0 ); + break; + case Qgis::GeometryType::Polygon: + collection->addGeometry( new QgsPolygon( new QgsLineString(), QList() ) ); + setActivePartAndRing( collection->partCount() - 1, 0 ); + break; + case Qgis::GeometryType::Point: + // MultiPoints do not need an empty placeholder part, new point part is directly appended when digitizing + case Qgis::GeometryType::Unknown: + case Qgis::GeometryType::Null: + break; + } + } +} + double RecordingMapTool::pixelsToMapUnits( double numPixels ) { QgsRenderContext context = QgsRenderContext::fromMapSettings( mapSettings()->mapSettings() ); diff --git a/app/maptools/recordingmaptool.h b/app/maptools/recordingmaptool.h index 0587fde9c..ce85210d6 100644 --- a/app/maptools/recordingmaptool.h +++ b/app/maptools/recordingmaptool.h @@ -184,6 +184,13 @@ class RecordingMapTool : public AbstractMapTool Q_INVOKABLE void cancelGrab(); + /** + * When this is called on a multipart geometry, a new empty part will be added to the geometry and + * activePart will be set to that new last part. + * For MultiPoints and singlepart geometries no new empty part will be added. + */ + Q_INVOKABLE void startDigitizingNewPart(); + // Getters / setters bool centeredToGPS() const; void setCenteredToGPS( bool newCenteredToGPS ); diff --git a/app/qml/map/MMMapController.qml b/app/qml/map/MMMapController.qml index f3b3601ee..4c1b8c436 100644 --- a/app/qml/map/MMMapController.qml +++ b/app/qml/map/MMMapController.qml @@ -814,17 +814,22 @@ Item { return "invisible" if (internal.splitGeometryButtonVisible) { - if (!internal.redrawGeometryButtonVisible && !internal.streamingModeButtonVisible) + if (!internal.redrawGeometryButtonVisible && !internal.streamingModeButtonVisible && !internal.addPartButtonVisible) return "split" } + if (internal.addPartButtonVisible) { + if (!internal.splitGeometryButtonVisible && !internal.streamingModeButtonVisible && !internal.redrawGeometryButtonVisible) + return "addPart" + } + if (internal.redrawGeometryButtonVisible) { - if (!internal.splitGeometryButtonVisible && !internal.streamingModeButtonVisible) + if (!internal.splitGeometryButtonVisible && !internal.streamingModeButtonVisible && !internal.addPartButtonVisible) return "redraw" } if (internal.streamingModeButtonVisible) { - if (!internal.redrawGeometryButtonVisible && !internal.splitGeometryButtonVisible) + if (!internal.redrawGeometryButtonVisible && !internal.splitGeometryButtonVisible && !internal.addPartButtonVisible) return "stream" } return "menu" @@ -834,6 +839,9 @@ Item { if (actionState === "split") return __style.splitGeometryIcon + if (actionState === "addPart") + return __style.plusIcon + if (actionState === "redraw") return __style.redrawGeometryIcon @@ -847,6 +855,9 @@ Item { if (actionState === "split") return root.toggleSplitting() + if (actionState === "addPart") + return root.toggleAddPart() + if (actionState === "redraw") return root.toggleRedraw() @@ -977,6 +988,18 @@ Item { } } + MMListDelegate { + text: qsTr( "Add part" ) + leftContent: MMIcon { source: __style.plusIcon } + + visible: internal.addPartButtonVisible + + onClicked: { + root.toggleAddPart() + moreToolsMenu.close() + } + } + MMListDelegate { text: qsTr( "Redraw geometry" ) leftContent: MMIcon { source: __style.redrawGeometryIcon } @@ -1262,6 +1285,7 @@ Item { // visibility of buttons in "more" menu property bool splitGeometryButtonVisible: !internal.isPointLayer && !root.isStreaming && root.state === "edit" + property bool addPartButtonVisible: internal.isMultiPartLayer && !root.isStreaming && root.state === "edit" property bool redrawGeometryButtonVisible: root.state === "edit" property bool streamingModeButtonVisible: !internal.isPointLayer || internal.isMultiPartLayer @@ -1323,6 +1347,24 @@ Item { } } + function toggleAddPart() { + addPart( internal.featurePairToEdit ) + } + + function addPart( featurepair) { + __activeProject.setActiveLayer( featurepair.layer ) + root.centerToPair( featurepair ) + root.showInfoTextMessage( qsTr( "Add new part to the geometry" ) ) + + internal.featurePairToEdit = featurepair + + // You should be already in state == "edit" + if ( recordingToolsLoader.active ) { + recordingToolsLoader.item.recordingMapTool.state = MM.RecordingMapTool.Record + recordingToolsLoader.item.recordingMapTool.startDigitizingNewPart() + } + } + function toggleSplitting() { split(internal.featurePairToEdit) } diff --git a/app/test/testmaptools.cpp b/app/test/testmaptools.cpp index d714938a4..bd40183c8 100644 --- a/app/test/testmaptools.cpp +++ b/app/test/testmaptools.cpp @@ -17,6 +17,7 @@ #include "qgslinestring.h" #include "qgspolygon.h" #include "qgsmultipolygon.h" +#include "qgsmultilinestring.h" #include "qgslinestring.h" #include "qgsgeometry.h" @@ -1445,6 +1446,453 @@ void TestMapTools::testAddVertexMultiPolygonLayer() delete polygonLayer; } +void TestMapTools::testAddPartPointLayer() +{ + RecordingMapTool mapTool; + + QgsProject *project = TestUtils::loadPlanesTestProject(); + QVERIFY( project && !project->homePath().isEmpty() ); + + InputMapCanvasMap canvas; + InputMapSettings *ms = canvas.mapSettings(); + setupMapSettings( ms, project, QgsRectangle( -107.54331499504026226, 21.62302175066136556, -72.73224633912816728, 51.49933451998575151 ), QSize( 600, 1096 ) ); + + mapTool.setMapSettings( ms ); + + QCOMPARE( mapTool.recordingType(), RecordingMapTool::Manual ); + + // Create memory layer to work with + QgsVectorLayer *pointLayer = new QgsVectorLayer( "Point?crs=epsg:4326", "pointlayer", "memory" ); + + mapTool.setState( RecordingMapTool::Record ); + + mapTool.setActiveLayer( pointLayer ); + mapTool.setActiveFeature( QgsFeature() ); + + QVector pointsToAdd = + { + { -97.129, 22.602 }, + { -104.923, 24.840 } + }; + + // + // Point layer should only add point when geometry is empty + // + QVERIFY( !mapTool.activeVertex().isValid() ); + QVERIFY( mapTool.state() == RecordingMapTool::Record ); + + mapTool.addPoint( pointsToAdd[0] ); + + QVERIFY( mapTool.hasValidGeometry() ); + QVERIFY( mapTool.recordedGeometry().constGet()->nCoordinates() == 1 ); + QCOMPARE( mapTool.recordedGeometry().vertexAt( 0 ), pointsToAdd[0] ); + + QVERIFY( !mapTool.activeVertex().isValid() ); + QVERIFY( mapTool.state() == RecordingMapTool::Record ); + + // startDigitizingNewPart should not affect singlepart layers + mapTool.startDigitizingNewPart(); + + QCOMPARE( mapTool.recordedGeometry().constGet()->partCount(), 1 ); + + // geometry did not change + QVERIFY( mapTool.hasValidGeometry() ); + QVERIFY( mapTool.recordedGeometry().constGet()->nCoordinates() == 1 ); + QCOMPARE( mapTool.recordedGeometry().vertexAt( 0 ), pointsToAdd[0] ); + + QCOMPARE( mapTool.activePart(), 0 ); + QVERIFY( !mapTool.activeVertex().isValid() ); + QVERIFY( mapTool.state() == RecordingMapTool::Record ); + + QVERIFY( mapTool.recordedGeometry().wkbType() == Qgis::WkbType::Point ); + + delete project; + delete pointLayer; +} + +void TestMapTools::testAddPartMultiPointLayer() +{ + RecordingMapTool mapTool; + + QgsProject *project = TestUtils::loadPlanesTestProject(); + QVERIFY( project && !project->homePath().isEmpty() ); + + InputMapCanvasMap canvas; + InputMapSettings *ms = canvas.mapSettings(); + setupMapSettings( ms, project, QgsRectangle( -107.54331499504026226, 21.62302175066136556, -72.73224633912816728, 51.49933451998575151 ), QSize( 600, 1096 ) ); + + mapTool.setMapSettings( ms ); + + QgsVectorLayer *multiPointLayer = new QgsVectorLayer( "MultiPoint?crs=epsg:4326", "mpointlayer", "memory" ); + + mapTool.setState( RecordingMapTool::Record ); + mapTool.setActiveLayer( multiPointLayer ); + mapTool.setActiveFeature( QgsFeature() ); + + QVector pointsToAdd = + { + { -97.129, 22.602 }, + { -104.923, 24.840 }, + { -108.0, 26.0 }, + }; + + mapTool.addPoint( pointsToAdd[0] ); + mapTool.addPoint( pointsToAdd[1] ); + + QVERIFY( mapTool.hasValidGeometry() ); + QCOMPARE( mapTool.recordedGeometry().constGet()->partCount(), 2 ); + QCOMPARE( mapTool.recordedGeometry().constGet()->nCoordinates(), 2 ); + + // startDigitizingNewPart on MultiPoint does not add an empty placeholder part + mapTool.startDigitizingNewPart(); + + QCOMPARE( mapTool.recordedGeometry().constGet()->partCount(), 2 ); + QCOMPARE( mapTool.recordedGeometry().constGet()->nCoordinates(), 2 ); + QCOMPARE( mapTool.activePart(), 0 ); + + // adding another point still appends a new part + mapTool.addPoint( pointsToAdd[2] ); + + QVERIFY( mapTool.hasValidGeometry() ); + QCOMPARE( mapTool.recordedGeometry().constGet()->partCount(), 3 ); + QCOMPARE( mapTool.recordedGeometry().constGet()->nCoordinates(), 3 ); + QCOMPARE( mapTool.recordedGeometry().vertexAt( 2 ), pointsToAdd[2] ); + QCOMPARE( mapTool.activePart(), 0 ); + + delete project; + delete multiPointLayer; +} + +void TestMapTools::testAddPartLineLayer() +{ + RecordingMapTool mapTool; + + QgsProject *project = TestUtils::loadPlanesTestProject(); + QVERIFY( project && !project->homePath().isEmpty() ); + + InputMapCanvasMap canvas; + InputMapSettings *ms = canvas.mapSettings(); + setupMapSettings( ms, project, QgsRectangle( -107.54331499504026226, 21.62302175066136556, -72.73224633912816728, 51.49933451998575151 ), QSize( 600, 1096 ) ); + + mapTool.setMapSettings( ms ); + + QCOMPARE( mapTool.recordingType(), RecordingMapTool::Manual ); + + // Create memory layer to work with + QgsVectorLayer *lineLayer = new QgsVectorLayer( "LineString?crs=epsg:4326", "linelayer", "memory" ); + + mapTool.setState( RecordingMapTool::Record ); + + mapTool.setActiveLayer( lineLayer ); + mapTool.setActiveFeature( QgsFeature() ); + + // + // ----------- Linestring layer ---------- + // + QVector pointsToAdd = + { + { -97.129, 22.602 }, // added to end + { -104.923, 24.840 }, // added to end + { -108, 26 }, // added to end + { -110, 28 }, // added to end + { -110, 28 }, // Same as previous point should not be recorded + { -95, 20 }, // added to start + { -109, 27 }, // added to middle + }; + + QVERIFY( !mapTool.activeVertex().isValid() ); + QVERIFY( mapTool.state() == RecordingMapTool::Record ); + + mapTool.addPoint( pointsToAdd[0] ); + + QVERIFY( !mapTool.hasValidGeometry() ); + QVERIFY( mapTool.recordedGeometry().constGet()->nCoordinates() == 1 ); + QCOMPARE( mapTool.recordedGeometry().vertexAt( 0 ), pointsToAdd[0] ); + + QVERIFY( !mapTool.activeVertex().isValid() ); + QVERIFY( mapTool.state() == RecordingMapTool::Record ); + + mapTool.addPoint( pointsToAdd[1] ); + + QVERIFY( mapTool.hasValidGeometry() ); + QVERIFY( mapTool.recordedGeometry().constGet()->nCoordinates() == 2 ); + QCOMPARE( mapTool.recordedGeometry().vertexAt( 1 ), pointsToAdd[1] ); + + QVERIFY( !mapTool.activeVertex().isValid() ); + QVERIFY( mapTool.state() == RecordingMapTool::Record ); + + QVERIFY( mapTool.recordedGeometry().wkbType() == Qgis::WkbType::LineString ); + + // startDigitizingNewPart should not affect singlepart layers + mapTool.startDigitizingNewPart(); + + QCOMPARE( mapTool.recordedGeometry().constGet()->partCount(), 1 ); + QCOMPARE( mapTool.activePart(), 0 ); + + mapTool.addPoint( pointsToAdd[2] ); + + QVERIFY( mapTool.hasValidGeometry() ); + QVERIFY( mapTool.recordedGeometry().constGet()->nCoordinates() == 3 ); + QCOMPARE( mapTool.recordedGeometry().vertexAt( 2 ), pointsToAdd[2] ); + + QVERIFY( !mapTool.activeVertex().isValid() ); + QVERIFY( mapTool.state() == RecordingMapTool::Record ); + + delete project; + delete lineLayer; +} + +void TestMapTools::testAddPartMultiLineLayer() +{ + RecordingMapTool mapTool; + + QgsProject *project = TestUtils::loadPlanesTestProject(); + QVERIFY( project && !project->homePath().isEmpty() ); + + InputMapCanvasMap canvas; + InputMapSettings *ms = canvas.mapSettings(); + setupMapSettings( ms, project, QgsRectangle( -107.54331499504026226, 21.62302175066136556, -72.73224633912816728, 51.49933451998575151 ), QSize( 600, 1096 ) ); + + mapTool.setMapSettings( ms ); + + QgsVectorLayer *multiLineLayer = new QgsVectorLayer( "MultiLineString?crs=epsg:4326", "mlinelayer", "memory" ); + + mapTool.setState( RecordingMapTool::Record ); + mapTool.setActiveLayer( multiLineLayer ); + mapTool.setActiveFeature( QgsFeature() ); + + QVector pointsToAdd = + { + { -97.129, 22.602 }, + { -104.923, 24.840 }, + { -108.0, 26.0 }, + { -80.0, 30.0 }, + { -85.0, 35.0 }, + }; + + // build first line part + mapTool.addPoint( pointsToAdd[0] ); + mapTool.addPoint( pointsToAdd[1] ); + mapTool.addPoint( pointsToAdd[2] ); + + QVERIFY( mapTool.hasValidGeometry() ); + QCOMPARE( mapTool.recordedGeometry().constGet()->partCount(), 1 ); + QCOMPARE( mapTool.recordedGeometry().constGet()->nCoordinates(), 3 ); + QCOMPARE( mapTool.activePart(), 0 ); + + // start digitizing a new part + mapTool.startDigitizingNewPart(); + + QCOMPARE( mapTool.recordedGeometry().constGet()->partCount(), 2 ); + QCOMPARE( mapTool.recordedGeometry().constGet()->nCoordinates(), 3 ); + QCOMPARE( mapTool.activePart(), 1 ); + + // new points are added to the new part + mapTool.addPoint( pointsToAdd[3] ); + + // second part has only 1 vertex so the overall geometry is not yet valid + QVERIFY( !mapTool.hasValidGeometry() ); + QCOMPARE( mapTool.recordedGeometry().constGet()->partCount(), 2 ); + QCOMPARE( mapTool.recordedGeometry().constGet()->nCoordinates(), 4 ); + + mapTool.addPoint( pointsToAdd[4] ); + + // second part now has 2 vertices — both parts are valid + QVERIFY( mapTool.hasValidGeometry() ); + QCOMPARE( mapTool.recordedGeometry().constGet()->nCoordinates(), 5 ); + + const QgsMultiLineString *mls = qgsgeometry_cast( mapTool.recordedGeometry().constGet() ); + QVERIFY( mls ); + QCOMPARE( mls->lineStringN( 0 )->numPoints(), 3 ); + QCOMPARE( mls->lineStringN( 1 )->numPoints(), 2 ); + QCOMPARE( mls->lineStringN( 0 )->pointN( 0 ), pointsToAdd[0] ); + QCOMPARE( mls->lineStringN( 0 )->pointN( 2 ), pointsToAdd[2] ); + QCOMPARE( mls->lineStringN( 1 )->pointN( 0 ), pointsToAdd[3] ); + QCOMPARE( mls->lineStringN( 1 )->pointN( 1 ), pointsToAdd[4] ); + + // nothing should be added in VIEW (neither in GRAB) state + mapTool.setState( RecordingMapTool::View ); + + mapTool.startDigitizingNewPart(); + + QCOMPARE( mapTool.recordedGeometry().constGet()->partCount(), 2 ); + QCOMPARE( mapTool.activePart(), 1 ); + + mapTool.setState( RecordingMapTool::Grab ); + + mapTool.startDigitizingNewPart(); + + QCOMPARE( mapTool.recordedGeometry().constGet()->partCount(), 2 ); + QCOMPARE( mapTool.activePart(), 1 ); + + delete project; + delete multiLineLayer; +} + +void TestMapTools::testAddPartPolygonLayer() +{ + RecordingMapTool mapTool; + + QgsProject *project = TestUtils::loadPlanesTestProject(); + QVERIFY( project && !project->homePath().isEmpty() ); + + InputMapCanvasMap canvas; + InputMapSettings *ms = canvas.mapSettings(); + setupMapSettings( ms, project, QgsRectangle( -107.54331499504026226, 21.62302175066136556, -72.73224633912816728, 51.49933451998575151 ), QSize( 600, 1096 ) ); + + mapTool.setMapSettings( ms ); + + QCOMPARE( mapTool.recordingType(), RecordingMapTool::Manual ); + + // Create memory layer to work with + QgsVectorLayer *polygonLayer = new QgsVectorLayer( "Polygon?crs=epsg:4326", "polygonlayer", "memory" ); + + mapTool.setState( RecordingMapTool::Record ); + mapTool.setActiveLayer( polygonLayer ); + mapTool.setActiveFeature( QgsFeature() ); + + // + // ----------- Polygon layer ---------- + // + QVector pointsToAdd = + { + { -95.5, 22.0 }, + { -97.5, 22.0 }, + { -97.5, 26.0 }, + { -95.5, 26.0 }, + { -96.5, 22.0 }, // add between first two + { -97.5, 24.0 }, // add between second & third + { -95.5, 24.0 }, // add between third & last + }; + + QVERIFY( !mapTool.activeVertex().isValid() ); + QVERIFY( mapTool.state() == RecordingMapTool::Record ); + + mapTool.addPoint( pointsToAdd[0] ); + + QVERIFY( !mapTool.hasValidGeometry() ); + + QVERIFY( mapTool.recordedGeometry().constGet()->nCoordinates() == 1 ); + QCOMPARE( mapTool.recordedGeometry().constGet()->nCoordinates(), 1 ); + QCOMPARE( mapTool.recordedGeometry().vertexAt( 0 ), pointsToAdd[0] ); + + QVERIFY( !mapTool.activeVertex().isValid() ); + QVERIFY( mapTool.state() == RecordingMapTool::Record ); + + mapTool.addPoint( pointsToAdd[1] ); + + QVERIFY( !mapTool.hasValidGeometry() ); + // ring will be closed, hance 3 points + QVERIFY( mapTool.recordedGeometry().constGet()->nCoordinates() == 3 ); + QCOMPARE( mapTool.recordedGeometry().vertexAt( 1 ), pointsToAdd[1] ); + + QVERIFY( !mapTool.activeVertex().isValid() ); + QVERIFY( mapTool.state() == RecordingMapTool::Record ); + + QVERIFY( mapTool.recordedGeometry().wkbType() == Qgis::WkbType::Polygon ); + + mapTool.addPoint( pointsToAdd[2] ); + + QVERIFY( mapTool.hasValidGeometry() ); + QVERIFY( mapTool.recordedGeometry().constGet()->nCoordinates() == 4 ); + QCOMPARE( mapTool.recordedGeometry().vertexAt( 2 ), pointsToAdd[2] ); + + QVERIFY( !mapTool.activeVertex().isValid() ); + QVERIFY( mapTool.state() == RecordingMapTool::Record ); + + // startDigitizingNewPart should not affect singlepart layers + mapTool.startDigitizingNewPart(); + + QCOMPARE( mapTool.recordedGeometry().constGet()->partCount(), 1 ); + QCOMPARE( mapTool.activePart(), 0 ); + + delete project; + delete polygonLayer; +} + +void TestMapTools::testAddPartMultiPolygonLayer() +{ + RecordingMapTool mapTool; + + QgsProject *project = TestUtils::loadPlanesTestProject(); + QVERIFY( project && !project->homePath().isEmpty() ); + + InputMapCanvasMap canvas; + InputMapSettings *ms = canvas.mapSettings(); + setupMapSettings( ms, project, QgsRectangle( -107.54331499504026226, 21.62302175066136556, -72.73224633912816728, 51.49933451998575151 ), QSize( 600, 1096 ) ); + + mapTool.setMapSettings( ms ); + + QgsVectorLayer *multiPolygonLayer = new QgsVectorLayer( "MultiPolygon?crs=epsg:4326", "mpolylayer", "memory" ); + + mapTool.setState( RecordingMapTool::Record ); + mapTool.setActiveLayer( multiPolygonLayer ); + mapTool.setActiveFeature( QgsFeature() ); + + QVector pointsToAdd = + { + { -95.5, 22.0 }, + { -97.5, 22.0 }, + { -97.5, 26.0 }, + { -80.0, 30.0 }, + { -82.0, 30.0 }, + { -82.0, 34.0 }, + }; + + // build first polygon part (3 unique vertices make a valid polygon) + mapTool.addPoint( pointsToAdd[0] ); + mapTool.addPoint( pointsToAdd[1] ); + mapTool.addPoint( pointsToAdd[2] ); + + QVERIFY( mapTool.hasValidGeometry() ); + QCOMPARE( mapTool.recordedGeometry().constGet()->partCount(), 1 ); + QCOMPARE( mapTool.activePart(), 0 ); + QCOMPARE( mapTool.recordedGeometry().wkbType(), Qgis::WkbType::MultiPolygon ); + + // start digitizing a new part + mapTool.startDigitizingNewPart(); + + QCOMPARE( mapTool.recordedGeometry().constGet()->partCount(), 2 ); + QCOMPARE( mapTool.activePart(), 1 ); + + // new points are added to the new polygon part + mapTool.addPoint( pointsToAdd[3] ); + mapTool.addPoint( pointsToAdd[4] ); + mapTool.addPoint( pointsToAdd[5] ); + + QVERIFY( mapTool.hasValidGeometry() ); + QCOMPARE( mapTool.recordedGeometry().constGet()->partCount(), 2 ); + + const QgsMultiPolygon *mp = qgsgeometry_cast( mapTool.recordedGeometry().constGet() ); + QVERIFY( mp ); + // each polygon exterior ring has 4 vertices (3 unique + closing vertex) + QCOMPARE( mp->polygonN( 0 )->exteriorRing()->numPoints(), 4 ); + QCOMPARE( mp->polygonN( 1 )->exteriorRing()->numPoints(), 4 ); + const QgsLineString *ring1 = qgsgeometry_cast( mp->polygonN( 1 )->exteriorRing() ); + QVERIFY( ring1 ); + QCOMPARE( ring1->pointN( 0 ), pointsToAdd[3] ); + QCOMPARE( ring1->pointN( 1 ), pointsToAdd[4] ); + QCOMPARE( ring1->pointN( 2 ), pointsToAdd[5] ); + + // nothing should be added in VIEW (neither in GRAB) state + mapTool.setState( RecordingMapTool::View ); + + mapTool.startDigitizingNewPart(); + + QCOMPARE( mapTool.recordedGeometry().constGet()->partCount(), 2 ); + QCOMPARE( mapTool.activePart(), 1 ); + + mapTool.setState( RecordingMapTool::Grab ); + + mapTool.startDigitizingNewPart(); + + QCOMPARE( mapTool.recordedGeometry().constGet()->partCount(), 2 ); + QCOMPARE( mapTool.activePart(), 1 ); + + delete project; + delete multiPolygonLayer; +} + void TestMapTools::testUpdateVertex() { RecordingMapTool mapTool; diff --git a/app/test/testmaptools.h b/app/test/testmaptools.h index f2d37c1dc..e570d0620 100644 --- a/app/test/testmaptools.h +++ b/app/test/testmaptools.h @@ -44,6 +44,12 @@ class TestMapTools : public QObject void testAddVertexMultiLineLayer(); void testAddVertexPolygonLayer(); void testAddVertexMultiPolygonLayer(); + void testAddPartPointLayer(); + void testAddPartLineLayer(); + void testAddPartPolygonLayer(); + void testAddPartMultiPointLayer(); + void testAddPartMultiLineLayer(); + void testAddPartMultiPolygonLayer(); void testUpdateVertex(); void testRemoveVertex(); void testVerticesStructure(); From 6b734760f6068f6ed841ab7ab664998d447bbfb4 Mon Sep 17 00:00:00 2001 From: uclaros Date: Mon, 15 Jun 2026 16:38:49 +0300 Subject: [PATCH 2/8] Automatically set state when calling startDigitizingNewPart() --- app/maptools/recordingmaptool.cpp | 9 ++++---- app/qml/map/MMMapController.qml | 1 - app/test/testmaptools.cpp | 36 ++++--------------------------- 3 files changed, 8 insertions(+), 38 deletions(-) diff --git a/app/maptools/recordingmaptool.cpp b/app/maptools/recordingmaptool.cpp index 9a421e8d2..652a70ea6 100644 --- a/app/maptools/recordingmaptool.cpp +++ b/app/maptools/recordingmaptool.cpp @@ -1152,11 +1152,10 @@ void RecordingMapTool::cancelGrab() void RecordingMapTool::startDigitizingNewPart() { - // if maptool is in GRAB and VIEW state, no part should be added - if ( mState == RecordingMapTool::View || mState == RecordingMapTool::Grab ) - { - return; - } + // cancel grab and switch to record + // we'll add a new empty part at the end (unless there's one already) and set it active + setActiveVertex( Vertex() ); + setState( RecordingMapTool::MapToolState::Record ); QgsAbstractGeometry *geom = mRecordedGeometry.get(); if ( QgsGeometryCollection *collection = qgsgeometry_cast( geom ) ) diff --git a/app/qml/map/MMMapController.qml b/app/qml/map/MMMapController.qml index 0463d23af..2f6df525d 100644 --- a/app/qml/map/MMMapController.qml +++ b/app/qml/map/MMMapController.qml @@ -1360,7 +1360,6 @@ Item { // You should be already in state == "edit" if ( recordingToolsLoader.active ) { - recordingToolsLoader.item.recordingMapTool.state = MM.RecordingMapTool.Record recordingToolsLoader.item.recordingMapTool.startDigitizingNewPart() } } diff --git a/app/test/testmaptools.cpp b/app/test/testmaptools.cpp index 609232fa9..2b7176a5c 100644 --- a/app/test/testmaptools.cpp +++ b/app/test/testmaptools.cpp @@ -1488,10 +1488,10 @@ void TestMapTools::testAddPartPointLayer() QCOMPARE( mapTool.recordedGeometry().vertexAt( 0 ), pointsToAdd[0] ); QVERIFY( !mapTool.activeVertex().isValid() ); - QVERIFY( mapTool.state() == RecordingMapTool::Record ); // startDigitizingNewPart should not affect singlepart layers mapTool.startDigitizingNewPart(); + QVERIFY( mapTool.state() == RecordingMapTool::Record ); QCOMPARE( mapTool.recordedGeometry().constGet()->partCount(), 1 ); @@ -1545,6 +1545,7 @@ void TestMapTools::testAddPartMultiPointLayer() // startDigitizingNewPart on MultiPoint does not add an empty placeholder part mapTool.startDigitizingNewPart(); + QVERIFY( mapTool.state() == RecordingMapTool::Record ); QCOMPARE( mapTool.recordedGeometry().constGet()->partCount(), 2 ); QCOMPARE( mapTool.recordedGeometry().constGet()->nCoordinates(), 2 ); @@ -1619,12 +1620,12 @@ void TestMapTools::testAddPartLineLayer() QCOMPARE( mapTool.recordedGeometry().vertexAt( 1 ), pointsToAdd[1] ); QVERIFY( !mapTool.activeVertex().isValid() ); - QVERIFY( mapTool.state() == RecordingMapTool::Record ); QVERIFY( mapTool.recordedGeometry().wkbType() == Qgis::WkbType::LineString ); // startDigitizingNewPart should not affect singlepart layers mapTool.startDigitizingNewPart(); + QVERIFY( mapTool.state() == RecordingMapTool::Record ); QCOMPARE( mapTool.recordedGeometry().constGet()->partCount(), 1 ); QCOMPARE( mapTool.activePart(), 0 ); @@ -1682,6 +1683,7 @@ void TestMapTools::testAddPartMultiLineLayer() // start digitizing a new part mapTool.startDigitizingNewPart(); + QVERIFY( mapTool.state() == RecordingMapTool::Record ); QCOMPARE( mapTool.recordedGeometry().constGet()->partCount(), 2 ); QCOMPARE( mapTool.recordedGeometry().constGet()->nCoordinates(), 3 ); @@ -1710,21 +1712,6 @@ void TestMapTools::testAddPartMultiLineLayer() QCOMPARE( mls->lineStringN( 1 )->pointN( 0 ), pointsToAdd[3] ); QCOMPARE( mls->lineStringN( 1 )->pointN( 1 ), pointsToAdd[4] ); - // nothing should be added in VIEW (neither in GRAB) state - mapTool.setState( RecordingMapTool::View ); - - mapTool.startDigitizingNewPart(); - - QCOMPARE( mapTool.recordedGeometry().constGet()->partCount(), 2 ); - QCOMPARE( mapTool.activePart(), 1 ); - - mapTool.setState( RecordingMapTool::Grab ); - - mapTool.startDigitizingNewPart(); - - QCOMPARE( mapTool.recordedGeometry().constGet()->partCount(), 2 ); - QCOMPARE( mapTool.activePart(), 1 ); - delete project; delete multiLineLayer; } @@ -1874,21 +1861,6 @@ void TestMapTools::testAddPartMultiPolygonLayer() QCOMPARE( ring1->pointN( 1 ), pointsToAdd[4] ); QCOMPARE( ring1->pointN( 2 ), pointsToAdd[5] ); - // nothing should be added in VIEW (neither in GRAB) state - mapTool.setState( RecordingMapTool::View ); - - mapTool.startDigitizingNewPart(); - - QCOMPARE( mapTool.recordedGeometry().constGet()->partCount(), 2 ); - QCOMPARE( mapTool.activePart(), 1 ); - - mapTool.setState( RecordingMapTool::Grab ); - - mapTool.startDigitizingNewPart(); - - QCOMPARE( mapTool.recordedGeometry().constGet()->partCount(), 2 ); - QCOMPARE( mapTool.activePart(), 1 ); - delete project; delete multiPolygonLayer; } From 62ae38806aa1e633bcf07d3c55279a598734691b Mon Sep 17 00:00:00 2001 From: uclaros Date: Mon, 15 Jun 2026 16:41:14 +0300 Subject: [PATCH 3/8] Add function to check if last part of a geometry is empty --- app/inpututils.cpp | 16 ++++++++++++++++ app/inpututils.h | 5 +++++ 2 files changed, 21 insertions(+) diff --git a/app/inpututils.cpp b/app/inpututils.cpp index 3fb7a2564..08e08a04a 100644 --- a/app/inpututils.cpp +++ b/app/inpututils.cpp @@ -831,6 +831,22 @@ bool InputUtils::isEmptyGeometry( const QgsGeometry &geometry ) return geometry.isEmpty(); } +bool InputUtils::isLastPartEmpty( const QgsGeometry &geometry ) +{ + if ( geometry.isEmpty() ) + return true; + + const QgsAbstractGeometry *geom = geometry.constGet(); + if ( const QgsGeometryCollection *collection = qgsgeometry_cast( geom ) ) + { + const int parts = collection->partCount(); + const QgsAbstractGeometry *lastPart = collection->geometryN( parts - 1 ); + return lastPart->isEmpty(); + } + + return false; +} + QgsPoint InputUtils::coordinateToPoint( const QGeoCoordinate &coor ) { return QgsPoint( coor.longitude(), coor.latitude(), coor.altitude() ); diff --git a/app/inpututils.h b/app/inpututils.h index d94e1803e..e4915c9c8 100644 --- a/app/inpututils.h +++ b/app/inpututils.h @@ -282,6 +282,11 @@ class InputUtils: public QObject */ Q_INVOKABLE static bool isEmptyGeometry( const QgsGeometry &geometry ); + /** + * Returns true when the last part of the geometry is empty, also implicitly for null or empty geometry + */ + Q_INVOKABLE static bool isLastPartEmpty( const QgsGeometry &geometry ); + /** * Converts QGeoCoordinate to QgsPoint */ From 65a91b72c8be9b88d6c5574c14b3cea6dd6b8d6e Mon Sep 17 00:00:00 2001 From: uclaros Date: Mon, 15 Jun 2026 16:41:58 +0300 Subject: [PATCH 4/8] Hide Remove button when adding a new part and no point is added yet --- app/qml/map/MMRecordingTools.qml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/qml/map/MMRecordingTools.qml b/app/qml/map/MMRecordingTools.qml index 22ffd1012..f8d90c45e 100644 --- a/app/qml/map/MMRecordingTools.qml +++ b/app/qml/map/MMRecordingTools.qml @@ -215,7 +215,7 @@ Item { enabled: { if ( mapTool.recordingType !== MM.RecordingMapTool.Manual ) return false; if ( mapTool.state === MM.RecordingMapTool.View ) return false; - if ( __inputUtils.isEmptyGeometry( mapTool.recordedGeometry ) ) return false; + if ( __inputUtils.isLastPartEmpty( mapTool.recordedGeometry ) ) return false; return true; } From 655b353c83589ee76732be8f97bf53c2d0a11cb9 Mon Sep 17 00:00:00 2001 From: uclaros Date: Mon, 15 Jun 2026 16:42:53 +0300 Subject: [PATCH 5/8] Avoid adding a new empty part if geometry's last part is already empty --- app/maptools/recordingmaptool.cpp | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/app/maptools/recordingmaptool.cpp b/app/maptools/recordingmaptool.cpp index 652a70ea6..e66f4a949 100644 --- a/app/maptools/recordingmaptool.cpp +++ b/app/maptools/recordingmaptool.cpp @@ -1163,11 +1163,19 @@ void RecordingMapTool::startDigitizingNewPart() switch ( mRecordedGeometry.type() ) { case Qgis::GeometryType::Line: - collection->addGeometry( new QgsLineString() ); + if ( !InputUtils::isLastPartEmpty( mRecordedGeometry ) ) + { + collection->addGeometry( new QgsLineString() ); + emit recordedGeometryChanged( mRecordedGeometry ); + } setActivePartAndRing( collection->partCount() - 1, 0 ); break; case Qgis::GeometryType::Polygon: - collection->addGeometry( new QgsPolygon( new QgsLineString(), QList() ) ); + if ( !InputUtils::isLastPartEmpty( mRecordedGeometry ) ) + { + collection->addGeometry( new QgsPolygon( new QgsLineString(), QList() ) ); + emit recordedGeometryChanged( mRecordedGeometry ); + } setActivePartAndRing( collection->partCount() - 1, 0 ); break; case Qgis::GeometryType::Point: From 461c94c9d6f3fa8885c047c4f03d07f0dc364715 Mon Sep 17 00:00:00 2001 From: uclaros Date: Mon, 15 Jun 2026 16:44:24 +0300 Subject: [PATCH 6/8] Avoid having an empty last part if user grabbed another vertex to modify --- app/maptools/recordingmaptool.cpp | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/app/maptools/recordingmaptool.cpp b/app/maptools/recordingmaptool.cpp index e66f4a949..b31d973c6 100644 --- a/app/maptools/recordingmaptool.cpp +++ b/app/maptools/recordingmaptool.cpp @@ -926,10 +926,23 @@ void RecordingMapTool::lookForVertex( const QPointF &clickedPoint, double search } } - // Update the previously grabbed point's position - if ( mState == MapToolState::Grab ) + switch ( mState ) { - updateVertex( mActiveVertex, mRecordPoint ); + case MapToolState::Grab: + // Update the previously grabbed point's position + updateVertex( mActiveVertex, mRecordPoint ); + break; + case MapToolState::Record: + // If we were adding a line or poly part but did not add any points yet, + // we need to drop the added empty part + if ( InputUtils::isLastPartEmpty( mRecordedGeometry ) ) + { + mRecordedGeometry.deletePart( mActivePart ); + emit recordedGeometryChanged( mRecordedGeometry ); + } + break; + case MapToolState::View: + break; } if ( idx >= 0 ) From 652740bde625b832327b1ea171e71330dc6ca490 Mon Sep 17 00:00:00 2001 From: uclaros Date: Mon, 15 Jun 2026 17:08:47 +0300 Subject: [PATCH 7/8] Don't recenter map when adding a new part --- app/qml/map/MMMapController.qml | 1 - 1 file changed, 1 deletion(-) diff --git a/app/qml/map/MMMapController.qml b/app/qml/map/MMMapController.qml index 2f6df525d..936849265 100644 --- a/app/qml/map/MMMapController.qml +++ b/app/qml/map/MMMapController.qml @@ -1353,7 +1353,6 @@ Item { function addPart( featurepair) { __activeProject.setActiveLayer( featurepair.layer ) - root.centerToPair( featurepair ) root.showInfoTextMessage( qsTr( "Add new part to the geometry" ) ) internal.featurePairToEdit = featurepair From 8b7e6864ac78a3ed9687dfb6c5353c6d8798914e Mon Sep 17 00:00:00 2001 From: uclaros Date: Mon, 15 Jun 2026 17:38:06 +0300 Subject: [PATCH 8/8] astyle --- app/maptools/recordingmaptool.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/maptools/recordingmaptool.cpp b/app/maptools/recordingmaptool.cpp index b31d973c6..8bd2b7071 100644 --- a/app/maptools/recordingmaptool.cpp +++ b/app/maptools/recordingmaptool.cpp @@ -1186,7 +1186,7 @@ void RecordingMapTool::startDigitizingNewPart() case Qgis::GeometryType::Polygon: if ( !InputUtils::isLastPartEmpty( mRecordedGeometry ) ) { - collection->addGeometry( new QgsPolygon( new QgsLineString(), QList() ) ); + collection->addGeometry( new QgsPolygon( new QgsLineString(), QList() ) ); emit recordedGeometryChanged( mRecordedGeometry ); } setActivePartAndRing( collection->partCount() - 1, 0 );