From 3306ad7c7f42c72a667a59eaafbc702d5fc88f13 Mon Sep 17 00:00:00 2001 From: Clemens Portele Date: Thu, 6 Aug 2026 16:43:16 +0200 Subject: [PATCH 1/2] tiles: make the internal tile extent of vector tiles configurable The internal coordinate system of a vector tile was fixed to the tile extent of the tiling scheme, 4096 for WebMercatorQuad. A tileset can now declare its own tile extent, which increases the geometric precision of its tiles and reduces the simplification of their geometries, at the cost of larger tiles. That makes it possible to serve fewer zoom levels and let clients overzoom the last one. The extent of a tileset applies to the encoder, the precision model, the Douglas-Peucker distance, the buffer around a tile and the optimized PostGIS tile generation. A combined tileset encodes with its own extent; a source tileset that uses a different one is rescaled. --- .../tiles/app/FeatureEncoderMVT.java | 7 +++-- .../tiles/app/TileBuilderDefault.java | 8 +++--- .../tiles/app/TileBuilderPgisAsMvt.java | 15 +++++------ .../tiles/app/TileEncoderMvt.java | 5 +++- .../tiles/domain/TileGenerationOptions.java | 27 +++++++++++++++++++ .../tiles/domain/TileMatrixSet.java | 18 ++++++++++++- .../tiles/domain/TileMatrixSetBase.java | 2 ++ .../tiles/domain/TilesetFeatures.java | 8 ++++++ 8 files changed, 72 insertions(+), 18 deletions(-) diff --git a/xtraplatform-tiles/src/main/java/de/ii/xtraplatform/tiles/app/FeatureEncoderMVT.java b/xtraplatform-tiles/src/main/java/de/ii/xtraplatform/tiles/app/FeatureEncoderMVT.java index 7b0518a5b..85fd9dd30 100644 --- a/xtraplatform-tiles/src/main/java/de/ii/xtraplatform/tiles/app/FeatureEncoderMVT.java +++ b/xtraplatform-tiles/src/main/java/de/ii/xtraplatform/tiles/app/FeatureEncoderMVT.java @@ -60,13 +60,12 @@ public FeatureEncoderMVT(TileGenerationContext encodingContext) { super(encodingContext); this.parameters = encodingContext.getParameters(); this.tile = encodingContext.getCoordinates(); - this.tileEncoder = new VectorTileEncoder(tile.getTileMatrixSet().getTileExtent()); + final int tileExtent = parameters.getTileExtentOrDefault(tile.getTileMatrixSet()); + this.tileEncoder = new VectorTileEncoder(tileExtent); this.affineTransformation = createTransformNativeToTile(); this.tileset = encodingContext.getTileset(); this.tilePrecisionModel = - new PrecisionModel( - (double) tile.getTileMatrixSet().getTileExtent() - / (double) tile.getTileMatrixSet().getTileSize()); + new PrecisionModel((double) tileExtent / (double) tile.getTileMatrixSet().getTileSize()); this.geometryFactoryTile = new GeometryFactory(tilePrecisionModel); this.geometryFactoryWorld = new GeometryFactory(); diff --git a/xtraplatform-tiles/src/main/java/de/ii/xtraplatform/tiles/app/TileBuilderDefault.java b/xtraplatform-tiles/src/main/java/de/ii/xtraplatform/tiles/app/TileBuilderDefault.java index 0d66683e9..32d08a746 100644 --- a/xtraplatform-tiles/src/main/java/de/ii/xtraplatform/tiles/app/TileBuilderDefault.java +++ b/xtraplatform-tiles/src/main/java/de/ii/xtraplatform/tiles/app/TileBuilderDefault.java @@ -258,7 +258,9 @@ private FeatureQuery getFeatureQuery( .limit(tileset.getFeatureLimit()) .offset(0) .crs(tile.getTileMatrixSet().getCrs()) - .maxAllowableOffset(getMaxAllowableOffset(tile, nativeCrs)); + .maxAllowableOffset( + getMaxAllowableOffset( + tile, nativeCrs, tileset.getTileExtentOrDefault(tile.getTileMatrixSet()))); if (tileset.getFilters().containsKey(tile.getTileMatrixSet().getId())) { tileset.getFilters().get(tile.getTileMatrixSet().getId()).stream() @@ -310,10 +312,10 @@ private FeatureQuery getFeatureQuery( return queryBuilder.build(); } - public double getMaxAllowableOffset(TileCoordinates tile, EpsgCrs nativeCrs) { + public double getMaxAllowableOffset(TileCoordinates tile, EpsgCrs nativeCrs, int tileExtent) { double maxAllowableOffsetTileMatrixSet = tile.getTileMatrixSet() - .getMaxAllowableOffset(tile.getLevel(), tile.getRow(), tile.getCol()); + .getMaxAllowableOffset(tile.getLevel(), tile.getRow(), tile.getCol(), tileExtent); Unit tmsCrsUnit = crsInfo.getUnit(tile.getTileMatrixSet().getCrs()); Unit nativeCrsUnit = crsInfo.getUnit(nativeCrs); if (tmsCrsUnit.equals(nativeCrsUnit)) { diff --git a/xtraplatform-tiles/src/main/java/de/ii/xtraplatform/tiles/app/TileBuilderPgisAsMvt.java b/xtraplatform-tiles/src/main/java/de/ii/xtraplatform/tiles/app/TileBuilderPgisAsMvt.java index db697f68e..ea34293fc 100644 --- a/xtraplatform-tiles/src/main/java/de/ii/xtraplatform/tiles/app/TileBuilderPgisAsMvt.java +++ b/xtraplatform-tiles/src/main/java/de/ii/xtraplatform/tiles/app/TileBuilderPgisAsMvt.java @@ -290,19 +290,16 @@ private String queryToSql( .map(FeatureSchema::getName); String filter = filtersToSql(providerId, tileset, schema, tms.getId(), level); - int buffer = (tms.getTileExtent() / tms.getTileSize()) * TileBuilder.BUFFER_SIZE_FORMAL; + int tileExtent = tileset.getTileExtentOrDefault(tms); + // the buffer is in the units of the internal coordinate system, so it has to be derived from + // the tile extent to stay proportional to the tile + int buffer = (tileExtent / tms.getTileSize()) * TileBuilder.BUFFER_SIZE_FORMAL; String bounds = "bounds AS (SELECT %1$s AS geom, %2$s::box2d AS b2d)"; String mvtgeom = String.format( "mvtgeom AS (SELECT ST_AsMVTGeom(ST_Transform(A.%1$s, %%3$s), bounds.b2d, %6$d, %7$d) AS geom, %2$s FROM %3$s A, bounds WHERE (%5$s AND ST_Intersects(A.%1$s, ST_Transform(bounds.geom, %4$s))))", - geomColumn, - attrColumns, - table, - nativeCrs.getCode(), - filter, - tms.getTileExtent(), - buffer); + geomColumn, attrColumns, table, nativeCrs.getCode(), filter, tileExtent, buffer); String template = String.format( @@ -310,7 +307,7 @@ private String queryToSql( bounds, mvtgeom, schema.getName(), - tms.getTileExtent(), + tileExtent, idProperty.map(id -> ", '" + id + "'").orElse("")); queryTemplates.put(key, template); diff --git a/xtraplatform-tiles/src/main/java/de/ii/xtraplatform/tiles/app/TileEncoderMvt.java b/xtraplatform-tiles/src/main/java/de/ii/xtraplatform/tiles/app/TileEncoderMvt.java index 3949a0891..9cb3326af 100644 --- a/xtraplatform-tiles/src/main/java/de/ii/xtraplatform/tiles/app/TileEncoderMvt.java +++ b/xtraplatform-tiles/src/main/java/de/ii/xtraplatform/tiles/app/TileEncoderMvt.java @@ -46,7 +46,10 @@ public byte[] combine( TilesetFeatures combinedTileset = data.getTilesets().get(tile.getTileset()); List tilesets = getLayerTilesets(data, combinedTileset, tile.getGenerationParametersTransient()); - VectorTileEncoder encoder = new VectorTileEncoder(tile.getTileMatrixSet().getTileExtent()); + // the layers of the source tilesets are decoded to a normalized coordinate space, so a source + // tileset with a different tile extent is rescaled to the tile extent of the combined tileset + VectorTileEncoder encoder = + new VectorTileEncoder(combinedTileset.getTileExtentOrDefault(tile.getTileMatrixSet())); VectorTileDecoder decoder = new VectorTileDecoder(); for (String tileset : tilesets) { diff --git a/xtraplatform-tiles/src/main/java/de/ii/xtraplatform/tiles/domain/TileGenerationOptions.java b/xtraplatform-tiles/src/main/java/de/ii/xtraplatform/tiles/domain/TileGenerationOptions.java index f302b2176..4e8d817d8 100644 --- a/xtraplatform-tiles/src/main/java/de/ii/xtraplatform/tiles/domain/TileGenerationOptions.java +++ b/xtraplatform-tiles/src/main/java/de/ii/xtraplatform/tiles/domain/TileGenerationOptions.java @@ -9,6 +9,7 @@ import java.util.List; import java.util.Map; +import java.util.Objects; import javax.annotation.Nullable; public interface TileGenerationOptions { @@ -97,4 +98,30 @@ public interface TileGenerationOptions { * @since v4.3 */ List getProfiles(); + + /** + * @langEn The width and height of a tile in the internal coordinate system that is used for the + * coordinates in a vector tile. A higher value increases the geometric precision of the tiles + * and reduces the simplification of the geometries, but it also increases the size of the + * tiles. The value should be a power of 2. Renderers typically restrict the internal + * precision, MapLibre for example normalizes all coordinates to a value of `8192`, so higher + * values have no effect in such clients. Changing the value invalidates the content of + * existing tile caches, the tiles have to be seeded again. + * @langDe Die Breite und Höhe einer Kachel in dem internen Koordinatensystem, das für die + * Koordinaten in einer Vector Tile verwendet wird. Ein höherer Wert erhöht die geometrische + * Genauigkeit der Kacheln und reduziert die Vereinfachung der Geometrien, erhöht aber auch + * die Größe der Kacheln. Der Wert sollte eine Zweierpotenz sein. Renderer beschränken die + * interne Genauigkeit üblicherweise, MapLibre normalisiert zum Beispiel alle Koordinaten auf + * einen Wert von `8192`, höhere Werte haben in solchen Clients daher keinen Effekt. Eine + * Änderung des Werts invalidiert den Inhalt bestehender Tile-Caches, die Kacheln müssen + * erneut geseedet werden. + * @default 4096 + * @since v4.9 + */ + @Nullable + Integer getTileExtent(); + + default int getTileExtentOrDefault(TileMatrixSetBase tileMatrixSet) { + return Objects.isNull(getTileExtent()) ? tileMatrixSet.getTileExtent() : getTileExtent(); + } } diff --git a/xtraplatform-tiles/src/main/java/de/ii/xtraplatform/tiles/domain/TileMatrixSet.java b/xtraplatform-tiles/src/main/java/de/ii/xtraplatform/tiles/domain/TileMatrixSet.java index e385a6fd4..94a57f548 100644 --- a/xtraplatform-tiles/src/main/java/de/ii/xtraplatform/tiles/domain/TileMatrixSet.java +++ b/xtraplatform-tiles/src/main/java/de/ii/xtraplatform/tiles/domain/TileMatrixSet.java @@ -151,8 +151,24 @@ default int getRows(int level) { * scheme */ default double getMaxAllowableOffset(int level, int row, int col) { + return getMaxAllowableOffset(level, row, col, getTileExtent()); + } + + /** + * determine the Douglas-Peucker distance parameter for a tile that uses a tile extent that + * differs from the tile extent of the tiling scheme + * + * @param level the zoom level + * @param row the row + * @param col the column + * @param tileExtent the width/height of the tile in its internal coordinate system + * @return the distance in the units of measure of the coordinate references system of the tiling + * scheme + */ + @Override + default double getMaxAllowableOffset(int level, int row, int col, int tileExtent) { BoundingBox bbox = getBoundingBox(); - return (bbox.getXmax() - bbox.getXmin()) / getCols(level) / getTileExtent(); + return (bbox.getXmax() - bbox.getXmin()) / getCols(level) / tileExtent; } /** diff --git a/xtraplatform-tiles/src/main/java/de/ii/xtraplatform/tiles/domain/TileMatrixSetBase.java b/xtraplatform-tiles/src/main/java/de/ii/xtraplatform/tiles/domain/TileMatrixSetBase.java index 01d0f239a..3c3aea39a 100644 --- a/xtraplatform-tiles/src/main/java/de/ii/xtraplatform/tiles/domain/TileMatrixSetBase.java +++ b/xtraplatform-tiles/src/main/java/de/ii/xtraplatform/tiles/domain/TileMatrixSetBase.java @@ -31,5 +31,7 @@ public interface TileMatrixSetBase { double getMaxAllowableOffset(int level, int row, int col); + double getMaxAllowableOffset(int level, int row, int col, int tileExtent); + int getTmsRow(int level, int row); } diff --git a/xtraplatform-tiles/src/main/java/de/ii/xtraplatform/tiles/domain/TilesetFeatures.java b/xtraplatform-tiles/src/main/java/de/ii/xtraplatform/tiles/domain/TilesetFeatures.java index 7fd25ebf6..3865902d5 100644 --- a/xtraplatform-tiles/src/main/java/de/ii/xtraplatform/tiles/domain/TilesetFeatures.java +++ b/xtraplatform-tiles/src/main/java/de/ii/xtraplatform/tiles/domain/TilesetFeatures.java @@ -69,6 +69,11 @@ public interface TilesetFeatures @Override Boolean getSparse(); + @DocIgnore + @Nullable + @Override + Integer getTileExtent(); + @DocIgnore @Override Map> getTransformations(); @@ -157,6 +162,9 @@ default TilesetFeatures mergeDefaults(TilesetFeaturesDefaults defaults) { if (Objects.isNull(this.getSparse()) && Objects.nonNull(defaults.getSparse())) { withDefaults.sparse(defaults.getSparse()); } + if (Objects.isNull(this.getTileExtent()) && Objects.nonNull(defaults.getTileExtent())) { + withDefaults.tileExtent(defaults.getTileExtent()); + } return withDefaults.build(); } From db9fcfa7c4156f780a6f711030897e4dfb403b50 Mon Sep 17 00:00:00 2001 From: Clemens Portele Date: Fri, 7 Aug 2026 11:32:30 +0200 Subject: [PATCH 2/2] fix the tile extent of a combined tileset not being applied The defaults of a tile provider are not merged into its tilesets when the provider is loaded, every use of a tileset has to merge them, and combining did not. A combined tileset that took its tile extent from the tileset defaults was therefore encoded with the tile extent of the tiling scheme, which quantised the coordinates of its source tilesets back down to that extent. --- .../main/java/de/ii/xtraplatform/tiles/app/TileEncoderMvt.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/xtraplatform-tiles/src/main/java/de/ii/xtraplatform/tiles/app/TileEncoderMvt.java b/xtraplatform-tiles/src/main/java/de/ii/xtraplatform/tiles/app/TileEncoderMvt.java index 9cb3326af..83ecbfe1e 100644 --- a/xtraplatform-tiles/src/main/java/de/ii/xtraplatform/tiles/app/TileEncoderMvt.java +++ b/xtraplatform-tiles/src/main/java/de/ii/xtraplatform/tiles/app/TileEncoderMvt.java @@ -43,7 +43,8 @@ public byte[] empty(TileMatrixSetBase tms) { public byte[] combine( TileQuery tile, TileProviderFeaturesData data, ChainedTileProvider tileProvider) throws IOException { - TilesetFeatures combinedTileset = data.getTilesets().get(tile.getTileset()); + TilesetFeatures combinedTileset = + data.getTilesets().get(tile.getTileset()).mergeDefaults(data.getTilesetDefaults()); List tilesets = getLayerTilesets(data, combinedTileset, tile.getGenerationParametersTransient()); // the layers of the source tilesets are decoded to a normalized coordinate space, so a source