Repository navigation
[Feature]: Label placement as ArcGIS Pro's Standard and Maplex engines do it: candidate positions, conflicts, obstacles and fitting #995
Description
Before you start
- I searched the existing issues and the roadmap and did not find a duplicate request.
What problem does this solve?
RustGIS places one label per feature and class at one fixed position. If that position collides, the label is dropped and no other position is tried (place_labels, crates/render/src/lib.rs). On a busy map, labels that would fit a little to the side, further along a road or river, or elsewhere inside a polygon are left out.
- Points: the chosen position only (Above Right, say), never the next best.
- Lines: Parallel and Curved labels sit at the middle of the longest part on screen (
line_labels.rs), so a road whose middle is crowded gets no label even where it runs free. - Polygons: the centroid of the largest polygon, moved inside when it falls out (
Geometry::label_point). In C-shaped or long polygons the label ends up against an edge. - Only labels collide with labels. A label can cover a point, a road or a boundary of another layer, and there is no way to say which layers it should keep off.
- There is no buffer around labels, no weight between classes besides their order, no removal of duplicate labels, and nothing to try when a label doesn't fit (wrapping, a smaller size).
ArcGIS Pro covers this with its Standard engine (place as many labels as fit, without overlaps) and its Maplex engine (position, fitting strategy and conflict resolution settings). QGIS's PAL engine does much of the same. #77 lists it under "Label engine depth".
Proposed solution
One engine with options, not two. In ArcGIS Pro, switching from Maplex back to Standard loses the advanced settings. Here the defaults would be the Standard behaviour, and Maplex-like control the same engine with more options set, on one model and one placement in crates/render, which already serves the CPU and GPU maps, turned and tilted views and export.
The core change: candidates with a cost. Each label gets a short list of candidate positions in order of preference, each with its box and a cost. place_labels keeps its greedy order by priority and takes the first candidate that fits. Fitting, obstacles, lines and polygons then build on it by adding candidates or changing their cost.
Rules from the start:
- Existing maps don't move. The first candidate is today's position, so a saved project looks the same, plus labels that are dropped today.
- Deterministic: the same map and view give the same labels, with stable ties.
- A budget: a cap on candidates per label, on top of today's cap on candidates.
- Compatible projects: new settings with
#[serde(default)], left out when saved at their default, as feat(labels): a halo's own colour and width, and letter spacing, as QGIS's buffer and spacing #964 does.
Steps, each with its own issue and small PRs:
- Candidates for points: the positions around a point in order of preference, starting with the chosen one, plus an option for "the chosen one only" (today's behaviour).
- Lines: candidates along the line rather than its middle only, above or below it with an offset, and repeating every N points.
- Conflicts: a buffer per class, a label weight per class, "never remove" (place overlapping), and removing duplicate labels (the same text within N points).
- Polygons: the pole of inaccessibility instead of the centroid, inside before outside.
- Obstacles: a layer's features as obstacles with a weight, points first (Esri warns that line and polygon feature weights slow labelling down).
- Fitting: wrapping (stacking), a smaller size down to a minimum, overrunning the feature; abbreviations last.
- Later: leader lines and callouts, and Convert to Annotation keeping each label where it was placed.
Weights in two places, each for one thing:
- The label weight, on the label class: which label wins when two compete.
- The obstacle weight, on the layer: how much labels keep off its features. A layer without labels can still be an obstacle, as in ArcGIS Pro, where unlabelled weighted layers enter the engine as background features. Per class later, only if a case needs it.
- One view of every weight in the map, as ArcGIS Pro's Label Weight Ranking dialog, besides each setting in the Labeling pane and the layer.
In the UI: position options in the Labeling pane's Position tab (#917), conflicts and fitting in a tab of their own, and only everyday settings on the Labeling ribbon tab (#935).
Out of scope: expression labels, which belong to the shared expression engine in the roadmap ("Expressions and cartography") and #882 piece 7.
Overlap with #882 piece 4b (@pick2510): 4b lists offsets, backgrounds, wrapping and repeat along lines for the QGIS import. Repeat along lines and wrapping are placement here (steps 2 and 6), while offsets and backgrounds are part of the text symbol. A proposal: 4b keeps offsets and backgrounds, and the importer maps QGIS's repeat and wrap settings onto steps 2 and 6 once they are in, so the two don't build them twice. Happy to adjust the split.
Each step comes with tests in place_labels and screenshots compared against the same map labelled in ArcGIS Pro. I'd start with step 1 once the approach is agreed.
Estimated scope
Wishlist: a larger or longer-term idea
Alternatives considered
- Two engines with a switch, as in ArcGIS Pro. Two models to keep in step, and settings lost when switching. One engine whose defaults are the simple behaviour gives the same choice without either.
- A global optimiser (simulated annealing or similar). Places more labels in dense maps, but it is slower and not deterministic: labels would jump between redraws. The greedy order with candidates is predictable, and Lock already pins labels when needed.
- Building steps 2 to 6 straight onto today's single position. Each would need its own way to try again when a label collides; the candidate list gives them one.
- Leaving placement to the importers (QGIS, MapLibre). Both formats carry placement settings, but RustGIS has nothing to map them onto beyond the 12 fixed positions.
Activity
pick2510 commentedon Oct 27, 2026
Thanks, this fits well with the QGIS import (#882). Piece 4a (#964, halo colour and width, letter spacing) is merged. Here is how 4b goes, so the two don't build the same thing twice:
xOffset/yOffset, and the distance around a point) and a background shape behind the text (QGIS's rectangle: fill, stroke, padding, rounded corners). As you suggest, these are part of the text symbol and don't touch placement.LabelClass. A wrap length and wrap character; a repeat distance (points) for line labels.#[serde(default)]and left out when saved at their default, as in feat(labels): a halo's own colour and width, and letter spacing, as QGIS's buffer and spacing #964.wrapChar/autoWrapLength/repeatDistanceonto them.Distance from a line (QGIS's
distfor parallel and curved labels, above or below the line) I'll leave to your step 2, since it is a candidate question there. The importer keeps noting it until then.If you'd rather own wrapping and repeat entirely, say so before the second PR and I'll leave them as importer notes. Your step 1 (candidates for points) doesn't conflict with any of this. The offset in 4b is applied to whatever position is chosen, so step 1 can add candidates on top of it.