Build a Measure Tool with QgsDistanceArea in PyQGIS
QGIS has a measure tool, but plugins often need their own: measuring along a route and writing the length into a form, checking a setback distance with a pass/fail answer, measuring the area of a proposed plot and comparing it with a minimum. Building one is a good exercise in the map tool API — clicks, mouse moves, rubber bands — and in getting measurement right, because a measure tool that uses planar distances in a geographic or Web Mercator project gives answers that are wrong by tens of percent.
This recipe belongs to Custom Map Tools & Canvas Interaction. It builds a map tool that collects vertices, previews them with a rubber band, measures ellipsoidally with QgsDistanceArea in any CRS, updates a live readout, converts units, snaps to features and reports results.
Prerequisites
- QGIS 3.34 LTR or newer, or the QGIS 4 series, inside QGIS Desktop.
- Familiarity with custom map tools from creating a custom map tool.
Set up ellipsoidal measurement
QgsDistanceArea measures lengths and areas on an ellipsoid when given the source CRS and an ellipsoid, regardless of the map's projection. Configuring it from the project gives the same results as QGIS's own tools.
from qgis.core import QgsDistanceArea, QgsProject, QgsUnitTypes
def make_measurer(canvas):
da = QgsDistanceArea()
project = QgsProject.instance()
da.setSourceCrs(canvas.mapSettings().destinationCrs(), project.transformContext())
da.setEllipsoid(project.ellipsoid() or "EPSG:7030")
return da
from qgis.utils import iface
da = make_measurer(iface.mapCanvas())
print("ellipsoidal:", da.willUseEllipsoid(), "| ellipsoid:", da.ellipsoid())
Breakdown: The source CRS is the canvas CRS, because clicked points arrive in canvas coordinates. The project ellipsoid — set in Project Properties — is what QGIS's measure tool and $area use; falling back to WGS 84 (EPSG:7030) covers projects with none set. willUseEllipsoid() confirms ellipsoidal measurement is active. In Web Mercator, planar distances are inflated by the scale factor — about 1.6 times at 50° latitude — which is exactly the error ellipsoidal measurement avoids.
Collect vertices with a rubber band preview
The map tool records clicked points and draws them with a rubber band; a second, temporary point follows the mouse so the user sees the next segment before clicking.
from qgis.gui import QgsMapTool, QgsRubberBand
from qgis.core import QgsWkbTypes, QgsPointXY
from qgis.PyQt.QtGui import QColor
from qgis.PyQt.QtCore import Qt
class MeasureTool(QgsMapTool):
def __init__(self, canvas, area=False, on_result=None):
super().__init__(canvas)
self.canvas = canvas
self.area = area
self.on_result = on_result
gtype = QgsWkbTypes.PolygonGeometry if area else QgsWkbTypes.LineGeometry
self.band = QgsRubberBand(canvas, gtype)
self.band.setColor(QColor(37, 99, 235, 90 if area else 255))
self.band.setStrokeColor(QColor("#2563eb"))
self.band.setWidth(2)
self.points = []
self.setCursor(Qt.CrossCursor)
def canvasReleaseEvent(self, event):
point = self.toMapCoordinates(event.pos())
if event.button() == Qt.LeftButton:
self.points.append(point)
self.band.addPoint(point, True)
self.band.addPoint(point, True) # moving tip
elif event.button() == Qt.RightButton and self.points:
self.finish()
def canvasMoveEvent(self, event):
if not self.points:
return
self.band.movePoint(self.toMapCoordinates(event.pos()))
self.update_readout(self.points + [self.toMapCoordinates(event.pos())])
Breakdown: A line rubber band for distances and a polygon rubber band for areas give the right preview in each mode. Each click adds the point twice: once as a fixed vertex and once as the moving tip that movePoint drags on mouse moves, so the preview segment stretches to the cursor. Right click finishes. toMapCoordinates converts the mouse position to canvas CRS coordinates — the CRS the measurer was set up with. The readout is computed from the fixed points plus the cursor position.
Measure and format results
The measurement itself is one call; presenting it well — sensible units and precision — is what makes the tool pleasant.
def update_readout(self, pts, final=False):
da = make_measurer(self.canvas)
if self.area and len(pts) >= 3:
from qgis.core import QgsGeometry
value = da.measureArea(QgsGeometry.fromPolygonXY([pts + [pts[0]]]))
text = da.formatArea(value, 2, QgsUnitTypes.AreaSquareMeters, True)
elif not self.area and len(pts) >= 2:
value = da.measureLine(pts)
text = da.formatDistance(value, 2, QgsUnitTypes.DistanceMeters, False)
else:
return
iface.statusBarIface().showMessage(("Final: " if final else "") + text)
return value
def finish(self):
value = self.update_readout(self.points, final=True)
if self.on_result and value is not None:
self.on_result(value, list(self.points))
self.reset()
def reset(self):
self.points = []
self.band.reset(QgsWkbTypes.PolygonGeometry if self.area else QgsWkbTypes.LineGeometry)
def deactivate(self):
self.reset()
super().deactivate()
Breakdown: measureLine and measureArea return metres and square metres on the ellipsoid. formatDistance and formatArea produce readable strings with units, switching to kilometres or hectares when the last argument allows automatic unit selection. The status bar is an unobtrusive place for the live value; the final value goes to a callback so the plugin decides what to do with it — fill a form field, compare with a threshold, log it. Resetting in deactivate clears the rubber band when the user switches tools.
Show segment lengths on the canvas
For routes with several legs — a pipe run from valve to valve, a walking route between stops — users want each segment's length, not just the total. Small text items placed at segment midpoints show them without cluttering a dialog.
from qgis.gui import QgsMapCanvasItem
from qgis.PyQt.QtCore import QRectF, QPointF
from qgis.PyQt.QtGui import QFont, QPen
class SegmentLabel(QgsMapCanvasItem):
def __init__(self, canvas, map_point, text):
super().__init__(canvas)
self.map_point, self.text = map_point, text
self.updatePosition()
def updatePosition(self):
self.prepareGeometryChange()
self.setPos(self.toCanvasCoordinates(self.map_point))
def boundingRect(self):
return QRectF(-40, -18, 80, 16)
def paint(self, painter, option=None, widget=None):
painter.setFont(QFont("Sans", 8))
painter.setPen(QPen(QColor("#17211d")))
painter.drawText(QRectF(-40, -18, 80, 16), Qt.AlignCenter, self.text)
def label_segments(canvas, pts, da):
items = []
for a, b in zip(pts, pts[1:]):
mid = QgsPointXY((a.x() + b.x()) / 2, (a.y() + b.y()) / 2)
length = da.measureLine(a, b)
items.append(SegmentLabel(canvas, mid, da.formatDistance(length, 1, QgsUnitTypes.DistanceMeters, False)))
return items
Breakdown: Each label is a small canvas item anchored at a segment's midpoint in map coordinates, so it stays in place as the map pans and zooms. Measuring each pair of consecutive points with the same QgsDistanceArea keeps segment lengths consistent with the total. Store the returned items on the tool and remove them from the scene in reset() and deactivate(), or they linger after the measurement ends; drawing a custom map canvas item covers the canvas item lifecycle.
Snap to features while measuring
Measuring "from this building corner to that hydrant" needs snapping. The canvas's snapping utilities snap mouse positions to features according to the project's snapping settings.
def snapped(self, event):
match = self.canvas.snappingUtils().snapToMap(event.pos())
return match.point() if match.isValid() else self.toMapCoordinates(event.pos())
Breakdown: Replacing self.toMapCoordinates(event.pos()) with self.snapped(event) in both event handlers makes clicks and the preview snap to vertices and segments when snapping is enabled in the project. Using the project's snapping configuration means users control it with the familiar snapping toolbar. Snapping to features with QgsSnappingUtils covers custom snapping configurations and visual snap indicators.
Check a rule with the measurement
Many plugins measure to decide something: is a proposed building at least 3 m from the boundary, is a plot above the minimum size. A callback turns the measure tool into a checker.
MIN_SETBACK = 3.0
def check_setback(distance, points):
if distance >= MIN_SETBACK:
iface.messageBar().pushSuccess("Setback", f"{distance:.2f} m — meets the {MIN_SETBACK} m minimum")
else:
iface.messageBar().pushCritical("Setback", f"{distance:.2f} m — below the {MIN_SETBACK} m minimum")
tool = MeasureTool(iface.mapCanvas(), area=False, on_result=check_setback)
iface.mapCanvas().setMapTool(tool)
Breakdown: The callback receives the final distance and the clicked points, so it can both judge and record. The message bar gives immediate, colour-coded feedback; for an audit trail, write the distance and verdict into an attribute of the selected feature through the edit buffer. The same structure handles area rules with area=True. Wiring the tool to a toolbar button, as in adding a toolbar button to a plugin, makes it a one-click check.
QGIS version compatibility
QgsMapTool, QgsRubberBand, QgsDistanceArea and snapping utilities work on QGIS 3.34 LTR, 3.40 LTR and QGIS 4. On QGIS 4, unit enums are Qgis.DistanceUnit.Meters and Qgis.AreaUnit.SquareMeters, geometry types Qgis.GeometryType.Line and Polygon, and Qt enums are scoped (Qt.MouseButton.LeftButton, Qt.CursorShape.CrossCursor).
Troubleshooting
- Distances are too long in Web Mercator. Planar measurement was used; configure
QgsDistanceAreawith source CRS and ellipsoid. - The preview does not follow the mouse. The moving tip point was not added after each click.
- The rubber band stays after switching tools. Reset it in
deactivate. - Snapping does not work. Snapping is disabled in the project, or the tool uses unsnapped coordinates.
Conclusion
Configure QgsDistanceArea with the canvas CRS and project ellipsoid, collect clicks into a rubber band with a moving tip for preview, measure with measureLine or measureArea and format with automatic units, snap with the canvas's snapping utilities, report through a callback that can check rules and record results, and clean up when the tool is deactivated.
Frequently Asked Questions
Can I measure along existing features instead of clicking?
Yes — measure the selected feature's geometry with the same QgsDistanceArea.
How do I show segment lengths? Measure each consecutive pair and display them in a list or as canvas labels.
Does the tool work in a 3D view? No; 2D canvas map tools only.
Why does my result differ slightly from QGIS's measure tool? Different ellipsoid or CRS settings; use the project ellipsoid and the canvas CRS as shown, and both tools agree.
Can I measure in feet?
Pass QgsUnitTypes.DistanceFeet to formatDistance or convert with QgsUnitTypes.fromUnitToUnitFactor.