Skip to content

Fix MLT polygon winding artifacts in maplibre-gl-js - #54

Merged
trasch merged 1 commit into
mainfrom
fix/mlt-polygon-winding-artifacts
Aug 19, 2026
Merged

trasch merged 1 commit into
mainfrom
fix/mlt-polygon-winding-artifacts

Conversation

@trasch

@trasch trasch commented Aug 19, 2026 •

Copy link
Copy Markdown
Contributor

Problem

maplibre-gl-js renders triangular fill artifacts on polygons-with-holes (e.g. oceans) when displaying PostGIS-sourced MLT tiles at low zooms (visible at z7).

Root cause

Two independent issues combine to produce the artifacts:

1. Inconsistent ring winding in tile space (MLTEncoder)

maplibre-gl-js's classifyRings regroups a flat ring list into polygons by using the first ring's signed area to decide exterior-vs-hole. PostGIS ST_ClipByBox2D / ST_MakeValid can leave rings with inconsistent winding in tile space, so the first ring of a polygon-with-holes may end up with the wrong sign, gets classified as a hole, and the polygon is split — producing triangular fill artifacts.

2. Morton/Hilbert dictionary vertex reordering (C++ MLT encoder)

With real PostGIS-sourced tiles (many features per layer, mixed geometries, vertices spanning the buffer zone with negative coordinates), the upstream C++ MLT encoder's space-filling-curve dictionary path produces tiles whose decoded ring0 starts at a dictionary-sorted anchor rather than the encoded first vertex, and whose winding is flipped — defeating the winding fix above.

Reproduced on a real served tile (z7/x65/y47, oceans layer, 67-ring MultiPolygon):

  • Encoder input ring0 first coord: (0, 2735)
  • Decoded ring0 first coord: (-405, 2730), sign + (wrong)

Fix

1. normalizePolygonTilespace in MLTEncoder.swift

Reverses each ring so the exterior has negative area and holes have positive area (maplibre's y-down convention), consistently across every polygon before encoding.

2. Disable Morton/Hilbert in Bridge.cpp

Forces the plain zigzag-delta vertex path that preserves vertex order and winding. Disabling enableMortonEncoding alone is not sufficient (the Hilbert path is gated only by validCoordinateRange), so geometryEncodingOption is set to PLAIN to take the early-return plain branch in encodeGeometryColumn. Cost: slightly larger tiles.

Upstream issue: https://github.com/maplibre/maplibre-tile-spec

Also

  • Updates the vendored maplibre-tile-spec submodule to the latest main (87878a90, +28 commits — none touch the encoder).
  • Adds a regression test multiPolygonManyRingsWithNegativeCoordsRoundtrip.

Verification

  • All 261 existing tests pass.
  • The real served tile (z7/x65/y47) now decodes correctly: oceans 67-ring MultiPolygon ring0 = [0, 2735] sign - (was [-405, 2730] sign +).
  • Artifacts gone in the maplibre-gl-js debug map.

Two fixes for triangular fill artifacts visible in maplibre-gl-js when
rendering fill polygons (e.g. oceans) from PostGIS-sourced MLT tiles:

1. Normalize polygon ring winding in tile space (MLTEncoder)
   maplibre-gl-js's classifyRings regroups a flat ring list into
   polygons by using the first ring's signed area to decide
   exterior-vs-hole. PostGIS ST_ClipByBox2D/ST_MakeValid can leave
   rings with inconsistent winding in tile space, so the first ring
   of a polygon-with-holes may end up with the wrong sign and be
   classified as a hole, splitting the polygon and producing
   triangular fill artifacts. normalizePolygonTilespace reverses each
   ring so the exterior has negative area and holes have positive
   area (maplibre's y-down convention), consistently across every
   polygon before encoding.

2. Disable Morton/Hilbert dictionary vertex encoding (Bridge.cpp)
   With real PostGIS-sourced tiles (many features per layer, mixed
   geometries, vertices spanning the buffer zone with negative
   coordinates), the upstream C++ MLT encoder's space-filling-curve
   dictionary path produces tiles whose decoded ring0 starts at a
   dictionary-sorted anchor rather than the encoded first vertex and
   whose winding is flipped, defeating the winding fix above.
   Disabling enableMortonEncoding alone is not sufficient (the
   Hilbert path is gated only by validCoordinateRange), so
   geometryEncodingOption is set to PLAIN to force the early-return
   plain zigzag-delta path in encodeGeometryColumn. Cost: slightly
   larger tiles. Upstream issue:
   https://github.com/maplibre/maplibre-tile-spec

Also updates the vendored maplibre-tile-spec submodule to the latest
main (87878a90) and adds a regression test
(multiPolygonManyRingsWithNegativeCoordsRoundtrip).
@trasch trasch self-assigned this Aug 19, 2026
@trasch trasch added the bug Something isn't working label Aug 19, 2026
@trasch
trasch merged commit 184ea04 into main Aug 19, 2026
1 check passed
@trasch
trasch deleted the fix/mlt-polygon-winding-artifacts branch August 19, 2026 16:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant