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.

Clicks become a geometryA line being digitized with three committed vertices and a dashed segment following the cursor. Left click adds the snapped position as a vertex. Mouse move updates the trailing segment and the snap indicator. Backspace removes the most recent vertex. Right click finishes: the geometry is transformed to the layer CRS, a feature is created with defaults, the attribute form opens, and the feature is added inside an edit command. Escape clears everything.Five events drive the whole tool3 vertices + preview to a snapped cursorleft clickadd snapped vertexmouse moveupdate preview, snap markerbackspaceremove last vertexright clickfinish and commitescapecancel the capture

Prerequisites

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 rubber band to an undoable featureSix steps. One: at least two vertices for a line or three for a polygon. Two: toLayerCoordinates converts each point from the canvas CRS to the layer CRS. Three: build the geometry and check isGeosValid. Four: QgsVectorLayerUtils.createFeature fills default values. Five: iface.openFeatureForm lets the user complete attributes, and cancelling abandons the feature. Six: beginEditCommand, addFeature and endEditCommand record one undo step.Six steps between right click and the undo stack1 count≥2 or ≥32 transformcanvas → layer3 validateisGeosValid4 defaultscreateFeature5 formuser completes6 addedit commandany failure clears the capture and tells the user whynothing is written until step 6

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 the tool to a checkable actionA plugin toolbar button, checkable, sets the capture tool on the map canvas when checked. The tool's action is linked with setAction so the button unchecks automatically when the user switches to another tool. When the active layer changes or stops being editable, the plugin unsets the tool so it never writes into the wrong layer.Behave like a native tool in the toolbartoolbar actioncheckablecanvas.setMapTooltool.setAction(action)guardlayer changed? unsetthe button unchecks itself when the user picks another tool

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 toLayerCoordinates before 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.