Skip to content

[Feature]: Label placement as ArcGIS Pro's Standard and Maplex engines do it: candidate positions, conflicts, obstacles and fitting #995

Open

Description

@jgregoriorincon

Before you start

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:

  1. 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).
  2. Lines: candidates along the line rather than its middle only, above or below it with an offset, and repeating every N points.
  3. Conflicts: a buffer per class, a label weight per class, "never remove" (place overlapping), and removing duplicate labels (the same text within N points).
  4. Polygons: the pole of inaccessibility instead of the centroid, inside before outside.
  5. Obstacles: a layer's features as obstacles with a weight, points first (Esri warns that line and polygon feature weights slow labelling down).
  6. Fitting: wrapping (stacking), a smaller size down to a minimum, overrunning the feature; abbreviations last.
  7. 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

  1. pick2510 commented on Oct 27, 2026

    @pick2510
    Contributor

    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:

    • 4b, first PR: offsets and backgrounds. A label's offset from its point (QGIS 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.
    • 4b, second PR: wrapping and repeating along lines, as plain settings on LabelClass. A wrap length and wrap character; a repeat distance (points) for line labels.
      • New settings are #[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.
      • Placement stays minimal: wrapped labels are stacked lines at the same position, and repeated labels are placed at fixed steps along the line, each colliding like any other label.
      • Your steps 2 and 6 can take these settings over as their options: candidates along the line, fitting with wrap as one strategy among smaller size and overrun. They won't need a second copy, and the importer will keep mapping QGIS's wrapChar/autoWrapLength/repeatDistance onto them.

    Distance from a line (QGIS's dist for 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions