Digitize Features with a Custom Map Tool in PyQGIS
QGIS's own digitizing tools are excellent for general editing. Plugins need their own when the capture has rules: a pipe that must start and end on a valve, a survey transect with a fixed number of vertices, a boundary that snaps only to one reference layer, a line whose attributes are filled from what it touches. A custom capture tool gives you every click, so you can enforce those rules as the user draws rather than validating afterwards.
This recipe belongs to Custom Map Tools and Canvas Interaction. It builds a line and polygon capture tool with a live preview, snapping, backspace to remove the last vertex, and a commit step that adds the feature through the layer's edit buffer with defaults and the attribute form — so it behaves like a native tool.
Prerequisites
- QGIS 3.40 LTR or newer, or the QGIS 4 series.
- A plugin or console session with
iface, and an editable line or polygon layer. - The map tool basics from creating a custom map tool and rubber bands from highlighting a feature with a rubber band.
The capture tool
The tool keeps a list of vertices in map canvas coordinates, draws them with a rubber band, and uses the canvas's snapping utilities for every position.
from qgis.PyQt.QtCore import Qt
from qgis.PyQt.QtGui import QColor
from qgis.core import Qgis, QgsGeometry, QgsPointLocator, QgsPointXY
from qgis.gui import QgsMapTool, QgsRubberBand, QgsSnapIndicator
class CaptureTool(QgsMapTool):
def __init__(self, canvas, layer, on_finished):
super().__init__(canvas)
self.layer = layer
self.on_finished = on_finished
self.is_polygon = layer.geometryType() == Qgis.GeometryType.Polygon
self.points = []
self.band = QgsRubberBand(
canvas, Qgis.GeometryType.Polygon if self.is_polygon else Qgis.GeometryType.Line)
self.band.setColor(QColor(185, 28, 28, 200))
self.band.setFillColor(QColor(185, 28, 28, 40))
self.band.setWidth(2)
self.snap_indicator = QgsSnapIndicator(canvas)
self.setCursor(Qt.CursorShape.CrossCursor)
def _snapped(self, event):
match = self.canvas().snappingUtils().snapToMap(event.pos())
self.snap_indicator.setMatch(match)
return QgsPointXY(match.point()) if match.isValid() else event.mapPoint()
def _redraw(self, cursor=None):
pts = self.points + ([cursor] if cursor else [])
geometry_type = Qgis.GeometryType.Polygon if self.is_polygon else Qgis.GeometryType.Line
self.band.reset(geometry_type)
for i, p in enumerate(pts):
self.band.addPoint(p, i == len(pts) - 1)
def canvasMoveEvent(self, event):
cursor = self._snapped(event)
if self.points:
self._redraw(cursor)
def canvasReleaseEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton:
self.points.append(self._snapped(event))
self._redraw()
elif event.button() == Qt.MouseButton.RightButton:
self.finish()
def keyPressEvent(self, event):
if event.key() == Qt.Key.Key_Backspace and self.points:
self.points.pop()
self._redraw()
event.ignore()
elif event.key() == Qt.Key.Key_Escape:
self.clear()
def clear(self):
self.points = []
self.band.reset()
self.snap_indicator.setMatch(QgsPointLocator.Match())
def deactivate(self):
self.clear()
super().deactivate()
Breakdown: snapToMap uses the project's snapping configuration — the same layers, modes and tolerance the user set for native tools — which is what makes a custom tool feel consistent; configuring snapping programmatically is covered in snapping to features with QgsSnappingUtils. QgsSnapIndicator draws the same snap marker the built-in tools show. The rubber band is rebuilt on each move with the cursor as a temporary last point, which gives the trailing preview segment; passing True only for the final point avoids redrawing the canvas for every vertex added. event.ignore() after backspace stops QGIS also treating the key as "delete selected features". An empty QgsPointLocator.Match() hides the snap marker when the capture is cleared.
Finish: validate, transform and commit
Right click hands the vertices to finish, which checks there are enough of them, converts from canvas CRS to layer CRS, and commits through the edit buffer so the addition can be undone.
from qgis.core import QgsPointLocator, QgsVectorLayerUtils
from qgis.utils import iface
def finish(self):
minimum = 3 if self.is_polygon else 2
if len(self.points) < minimum:
iface.messageBar().pushWarning("Capture", f"Need at least {minimum} vertices")
return
layer_points = [self.toLayerCoordinates(self.layer, p) for p in self.points]
if self.is_polygon:
geometry = QgsGeometry.fromPolygonXY([layer_points + [layer_points[0]]])
else:
geometry = QgsGeometry.fromPolylineXY(layer_points)
if not geometry.isGeosValid():
iface.messageBar().pushWarning("Capture", "Geometry is invalid; not added")
self.clear()
return
if not self.layer.isEditable():
self.layer.startEditing()
feature = QgsVectorLayerUtils.createFeature(
self.layer, geometry, {}, self.layer.createExpressionContext())
self.layer.beginEditCommand("Capture feature")
if iface.openFeatureForm(self.layer, feature, False, True):
self.layer.endEditCommand()
self.on_finished(feature)
else:
self.layer.destroyEditCommand()
self.clear()
self.canvas().refresh()
CaptureTool.finish = finish
Breakdown: toLayerCoordinates converts from the canvas CRS to the layer CRS using the project's transform context, so a tool drawing on a Web Mercator canvas writes correct coordinates into a British National Grid layer. Polygon rings are closed explicitly by repeating the first point. Validating before touching the layer means an invalid shape never enters the edit buffer. createFeature evaluates default value expressions, as described in setting default values and field constraints. openFeatureForm with updateFeatureOnly=False shows the layer's configured form and adds the feature when the user clicks OK; cancelling returns false, and destroyEditCommand removes the empty undo step. The whole capture is one entry in the undo stack, labelled so the user sees what they would undo.
Enforce a capture rule
The reason to write a custom tool is usually a rule. Here the first and last vertices of a pipe must snap to a valve.
from qgis.core import QgsProject, QgsPointLocator
valves = QgsProject.instance().mapLayersByName("valves")[0]
def _snap_to_valve(self, event):
locator = self.canvas().snappingUtils().locatorForLayer(valves)
tolerance = self.canvas().mapUnitsPerPixel() * 12
point = self.toLayerCoordinates(valves, event.mapPoint())
match = locator.nearestVertex(point, tolerance)
return match
def canvasReleaseEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton:
if not self.points:
match = self._snap_to_valve(event)
if not match.isValid():
iface.messageBar().pushInfo("Pipe", "Start the pipe on a valve")
return
self.points.append(self.toMapCoordinates(valves, match.point()))
else:
self.points.append(self._snapped(event))
self._redraw()
elif event.button() == Qt.MouseButton.RightButton:
last_valve = self._snap_to_valve(event)
if not last_valve.isValid():
iface.messageBar().pushInfo("Pipe", "Finish the pipe on a valve")
return
self.points.append(self.toMapCoordinates(valves, last_valve.point()))
self.finish()
CaptureTool._snap_to_valve = _snap_to_valve
CaptureTool.canvasReleaseEvent = canvasReleaseEvent
Breakdown: locatorForLayer returns the point locator that the snapping utilities maintain for a layer, so the valve lookup reuses an existing index rather than building one. The tolerance is twelve pixels converted to map units, which keeps the feel constant across zoom levels. Refusing the click with a message is friendlier than letting the user draw a whole pipe and rejecting it at the end. Converting the valve's layer coordinates back to map coordinates keeps the internal vertex list in one CRS. Monkey-patching methods onto the class is only for presenting the rule separately here; in a plugin, write them directly in the class.
Wire it into a plugin
A capture tool belongs behind a checkable toolbar action, tied to the layer that was active when it was switched on.
from qgis.PyQt.QtWidgets import QAction
from qgis.core import Qgis
def init_capture_action(plugin):
plugin.capture_action = QAction("Capture pipe", plugin.iface.mainWindow())
plugin.capture_action.setCheckable(True)
plugin.capture_action.triggered.connect(lambda checked: toggle_capture(plugin, checked))
plugin.iface.addToolBarIcon(plugin.capture_action)
plugin.iface.currentLayerChanged.connect(lambda _layer: stop_capture(plugin))
def toggle_capture(plugin, checked):
canvas = plugin.iface.mapCanvas()
layer = plugin.iface.activeLayer()
if not checked:
stop_capture(plugin)
return
if layer is None or layer.type() != Qgis.LayerType.Vector:
plugin.capture_action.setChecked(False)
return
plugin.capture_tool = CaptureTool(canvas, layer, on_finished=lambda f: None)
plugin.capture_tool.setAction(plugin.capture_action)
canvas.setMapTool(plugin.capture_tool)
def stop_capture(plugin):
tool = getattr(plugin, "capture_tool", None)
if tool is not None and plugin.iface.mapCanvas().mapTool() is tool:
plugin.iface.mapCanvas().unsetMapTool(tool)
plugin.capture_tool = None
Breakdown: setAction links the tool to its button so QGIS unchecks the button when another tool takes over. Stopping capture when the active layer changes prevents the classic bug where a user switches layers mid-session and digitises into the wrong one. Keeping the tool on the plugin object keeps it alive; a tool held only by a local variable is garbage-collected and the canvas is left pointing at nothing. Remove the action and disconnect the signal in the plugin's unload, as covered in adding a toolbar button to a QGIS plugin.
QGIS version compatibility
QgsMapTool, QgsRubberBand, QgsSnapIndicator and openFeatureForm are available across QGIS 3.x. Qgis.GeometryType replaced QgsWkbTypes.GeometryType in 3.30; on the QGIS 4 series only the scoped enums work, including Qt's — Qt.MouseButton.LeftButton, Qt.Key.Key_Backspace, Qt.CursorShape.CrossCursor. QGIS also offers QgsMapToolDigitizeFeature, which integrates the advanced digitizing panel; it suits tools that need CAD constraints and is less flexible for custom rules.
Troubleshooting
- Vertices land in the wrong place. Points were not converted with
toLayerCoordinatesbefore building the geometry. - Backspace deletes selected features. The key event was not ignored.
- Snapping never happens. Snapping is off in the project, or the target layer is not in the snapping configuration.
- Nothing appears after OK in the form. The canvas was not refreshed, or the layer was not in edit mode.
- The tool stops responding. It was garbage-collected; keep a reference on the plugin.
Conclusion
Keep captured vertices in canvas coordinates, snap every position through the canvas's snapping utilities, preview with a rubber band, and support backspace and escape. On finish, convert to layer coordinates, validate, create the feature with defaults, open the form, and add it inside a named edit command. Enforce capture rules while the user draws, and wire the tool to a checkable action that stops when the active layer changes.
Frequently Asked Questions
Can the tool capture curves?
Yes, by building a QgsCompoundCurve from arc segments, but it needs extra UI. QgsMapToolDigitizeFeature handles curves natively.
How do I prefill attributes from what the line touches?
Before openFeatureForm, query the relevant layers with the geometry and set attributes on the feature; the form shows them for confirmation.
Can I capture without showing the form?
Call layer.addFeature(feature) inside the edit command instead of opening the form, or suppress the form in the layer's form configuration.
Does this work with the advanced digitizing panel?
Not automatically. Subclass QgsMapToolAdvancedDigitizing if users need angle and distance constraints.