diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ddd7ba9f68..c5d2235f30 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -66,7 +66,7 @@ generate vector tile features according to the [profile](#profiles) in a worker - Uses an [IntRangeSet](planetiler-core/src/main/java/com/onthegomap/planetiler/collection/IntRangeSet.java) to optimize processing for large filled areas (like oceans) - If any features wrapped past -180 or 180 degrees longitude, repeat with a 360 or -360 degree offset - - Reassemble each vector tile geometry and round to tile precision (4096x4096) + - Reassemble each vector tile geometry and round to tile precision (default 4096x4096) - For polygons, [GeoUtils#snapAndFixPolygon](planetiler-core/src/main/java/com/onthegomap/planetiler/geo/GeoUtils.java) uses [JTS](https://github.com/locationtech/jts) utilities to fix any topology errors (i.e. self-intersections) diff --git a/config-example.properties b/config-example.properties index bf11420af0..d213aa9eb8 100644 --- a/config-example.properties +++ b/config-example.properties @@ -25,6 +25,10 @@ # minzoom=0 # maxzoom=14 +# tile_extent=4096 +# Prevent MapLibre fill rendering failures by simplifying final encoded polygon components that are too detailed: +# max_renderer_polygon_vertices=60000 +# max_renderer_polygon_simplification_tolerance=256 # Planetiler uses all available cores by default, but to override use: # threads=4 diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/FeatureMerge.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/FeatureMerge.java index e326300ba1..301e9b9f2c 100644 --- a/planetiler-core/src/main/java/com/onthegomap/planetiler/FeatureMerge.java +++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/FeatureMerge.java @@ -59,7 +59,7 @@ public class FeatureMerge { private static final BufferParameters bufferOps = new BufferParameters(); // this is slightly faster than Comparator.comparingInt private static final Comparator> BY_HILBERT_INDEX = - (o1, o2) -> Integer.compare(o1.hilbert, o2.hilbert); + (o1, o2) -> Integer.compareUnsigned(o1.hilbert, o2.hilbert); static { bufferOps.setJoinStyle(BufferParameters.JOIN_MITRE); diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/Planetiler.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/Planetiler.java index 56ee3dcb97..beb37b7074 100644 --- a/planetiler-core/src/main/java/com/onthegomap/planetiler/Planetiler.java +++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/Planetiler.java @@ -10,6 +10,7 @@ import com.onthegomap.planetiler.collection.LongLongMultimap; import com.onthegomap.planetiler.config.Arguments; import com.onthegomap.planetiler.config.PlanetilerConfig; +import com.onthegomap.planetiler.geo.GeoUtils; import com.onthegomap.planetiler.reader.GeoPackageReader; import com.onthegomap.planetiler.reader.NaturalEarthReader; import com.onthegomap.planetiler.reader.ShapefileReader; @@ -132,6 +133,8 @@ private Planetiler(Arguments arguments) { stats = arguments.getStats(); overallTimer = stats.startStageQuietly("overall"); config = PlanetilerConfig.from(arguments); + VectorTile.setExtent(config.tileExtent()); + GeoUtils.setTileExtent(config.tileExtent()); if (config.color() != null) { AnsiColors.setUseColors(config.color()); } diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/VectorTile.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/VectorTile.java index 3453b9ae64..6a41756b68 100644 --- a/planetiler-core/src/main/java/com/onthegomap/planetiler/VectorTile.java +++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/VectorTile.java @@ -26,6 +26,7 @@ import com.onthegomap.planetiler.geo.GeometryException; import com.onthegomap.planetiler.geo.GeometryType; import com.onthegomap.planetiler.geo.MutableCoordinateSequence; +import com.onthegomap.planetiler.geo.TileCoord; import com.onthegomap.planetiler.reader.WithTags; import com.onthegomap.planetiler.stats.DefaultStats; import com.onthegomap.planetiler.stats.Stats; @@ -39,6 +40,7 @@ import java.util.Locale; import java.util.Map; import java.util.Objects; +import java.util.PriorityQueue; import java.util.TreeMap; import java.util.function.Consumer; import java.util.stream.Collectors; @@ -60,6 +62,8 @@ import org.locationtech.jts.geom.Puntal; import org.locationtech.jts.geom.impl.CoordinateArraySequence; import org.locationtech.jts.geom.impl.PackedCoordinateSequence; +import org.locationtech.jts.geom.util.GeometryFixer; +import org.locationtech.jts.simplify.TopologyPreservingSimplifier; import org.maplibre.mlt.converter.mvt.MapboxVectorTile; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -93,8 +97,11 @@ public class VectorTile { private static final Logger LOGGER = LoggerFactory.getLogger(VectorTile.class); - // TODO make these configurable - private static final int EXTENT = 4096; + public static final int DEFAULT_EXTENT = 4096; + // Global vector tile extent configured at runtime through PlanetilerConfig. + private static volatile int tileExtent = DEFAULT_EXTENT; + private static final int HILBERT_LEVEL = 16; + private static final long HILBERT_MAX_COORD = (1L << HILBERT_LEVEL) - 1; private static final double SIZE = 256d; // use a treemap to ensure that layers are encoded in a consistent order private final Map layers = new TreeMap<>(); @@ -106,6 +113,17 @@ private static int[] getCommands(Geometry input, int scale) { return encoder.result.toArray(); } + public static int extent() { + return tileExtent; + } + + public static void setExtent(int extent) { + if (extent <= 0) { + throw new IllegalArgumentException("tile_extent must be > 0, got " + extent); + } + tileExtent = extent; + } + /** * Scales a geometry down by a factor of {@code 2^scale} without materializing an intermediate JTS geometry and * returns the encoded result. @@ -368,7 +386,11 @@ public static List decode(byte[] encoded) { List features = new ArrayList<>(); for (VectorTileProto.Tile.Layer layer : tile.getLayersList()) { String layerName = layer.getName(); - assert layer.getExtent() == 4096; + if (layer.getExtent() != tileExtent) { + throw new IllegalStateException( + "Unsupported vector tile extent: " + layer.getExtent() + " (expected " + tileExtent + ")" + ); + } List keys = layer.getKeysList(); List values = new ArrayList<>(); @@ -447,9 +469,22 @@ public static VectorGeometryMerger newMerger(GeometryType geometryType) { */ public static int hilbertIndex(Geometry geometry) { Coordinate coord = geometry.getCoordinate(); - int x = zigZagEncode((int) Math.round(coord.x * 4096 / 256)); - int y = zigZagEncode((int) Math.round(coord.y * 4096 / 256)); - return (int) Hilbert.hilbertXYToIndex(15, x, y); + int x = zigZagEncode((int) Math.round(coord.x * tileExtent / SIZE)); + int y = zigZagEncode((int) Math.round(coord.y * tileExtent / SIZE)); + return hilbertIndexForEncodedCoords(x, y); + } + + private static int hilbertIndexForEncodedCoords(int x, int y) { + int shift = Math.max(hilbertShiftToFitLevel(x), hilbertShiftToFitLevel(y)); + return (int) Hilbert.hilbertXYToIndex(HILBERT_LEVEL, x >>> shift, y >>> shift); + } + + private static int hilbertShiftToFitLevel(int coord) { + long unsignedCoord = Integer.toUnsignedLong(coord); + if (unsignedCoord <= HILBERT_MAX_COORD) { + return 0; + } + return Long.SIZE - Long.numberOfLeadingZeros(unsignedCoord) - HILBERT_LEVEL; } /** @@ -479,8 +514,8 @@ public static int countGeometries(VectorTileProto.Tile.Feature feature) { * avoid needing to create an extra JTS geometry for encoding. */ public static VectorGeometry encodeFill(double buffer) { - int min = (int) Math.round(EXTENT * buffer / 256d); - int width = EXTENT + min + min; + int min = (int) Math.round(tileExtent * buffer / 256d); + int width = tileExtent + min + min; return new VectorGeometry(new int[]{ CommandEncoder.commandAndLength(Command.MOVE_TO, 1), zigZagEncode(-min), zigZagEncode(-min), @@ -531,6 +566,359 @@ public VectorTile addLayerFeatures(String layerName, List features) { return this; } + /** + * Repairs polygon features whose final encoded command stream would exceed MapLibre's per-component fill-rendering + * vertex limit. Compliant features are inspected without decoding to JTS and remain byte-for-byte unchanged. + */ + public void enforceRendererPolygonLimit(TileCoord tileCoord, int maxVertices, double maxTolerance) { + double initialTolerance = SIZE / tileExtent; + for (var layerEntry : layers.entrySet()) { + String layerName = layerEntry.getKey(); + List features = layerEntry.getValue().encodedFeatures; + for (int i = 0; i < features.size(); i++) { + EncodedFeature feature = features.get(i); + VectorGeometry original = feature.geometry(); + if (original.geomType() != GeometryType.POLYGON) { + continue; + } + int originalCount = rendererPolygonVertexCount(original.commands()); + if (originalCount <= maxVertices) { + continue; + } + + RepairResult repaired; + try { + repaired = repairRendererPolygon(original, maxVertices, initialTolerance, maxTolerance); + } catch (RuntimeException e) { + throw new IllegalStateException( + "Unable to repair renderer polygon tile=" + tileCoord + " layer=" + layerName + " feature_id=" + + feature.id() + " original_vertices=" + originalCount + ": " + e.getMessage(), + e); + } + features.set(i, new EncodedFeature(feature.tags(), feature.id(), repaired.geometry())); + LOGGER.warn( + "Repaired renderer polygon tile={} layer={} feature_id={} original_vertices={} final_vertices={} tolerance={} attempts={}", + tileCoord, layerName, feature.id(), originalCount, repaired.vertexCount(), repaired.tolerance(), + repaired.attempts()); + } + } + } + + private static RepairResult repairRendererPolygon(VectorGeometry encoded, int maxVertices, double initialTolerance, + double maxTolerance) { + final Geometry original; + try { + original = encoded.decode(); + } catch (GeometryException e) { + throw new IllegalStateException("Unable to decode oversized final polygon geometry", e); + } + if (original.isEmpty() || !(original instanceof Polygon || original instanceof MultiPolygon) || !original.isValid()) { + throw new IllegalStateException("Oversized final polygon geometry is not a valid polygon"); + } + + int attempts = 0; + int bestVertexCount = rendererPolygonVertexCount(encoded.commands()); + for (double tolerance = initialTolerance; tolerance <= maxTolerance; tolerance *= 2) { + attempts++; + // Always simplify the original geometry, never the result of the previous attempt. + Geometry simplified = TopologyPreservingSimplifier.simplify(original, tolerance); + RepairResult result = tryRendererPolygonRepair(simplified, maxVertices, tolerance, attempts); + if (result != null) { + return result; + } + bestVertexCount = Math.min(bestVertexCount, encodedRendererPolygonVertexCount(simplified)); + + /* + * Whole-feature simplification can retain detail in one component because it must preserve topology against + * many nearby components. Try only the oversized components independently as a more aggressive fallback. + */ + Geometry componentsSimplified = simplifyPolygonComponents(original, tolerance, maxVertices, false); + result = tryRendererPolygonRepair(componentsSimplified, maxVertices, tolerance, attempts); + if (result != null) { + return result; + } + bestVertexCount = Math.min(bestVertexCount, encodedRendererPolygonVertexCount(componentsSimplified)); + + /* + * Simplifying an entire polygon at once can retain excessive detail when many nearby holes constrain one + * another. Fall back to simplifying every ring independently with the topology-preserving simplifier, then + * validate the reassembled polygon as a whole before accepting it. + */ + Geometry ringsSimplified = simplifyPolygonComponents(original, tolerance, maxVertices, true); + result = tryRendererPolygonRepair(ringsSimplified, maxVertices, tolerance, attempts); + if (result != null) { + return result; + } + bestVertexCount = Math.min(bestVertexCount, encodedRendererPolygonVertexCount(ringsSimplified)); + if (tolerance > maxTolerance / 2) { + break; + } + } + throw new IllegalStateException( + "Unable to simplify final polygon below " + maxVertices + " vertices by maximum tolerance " + maxTolerance + + " after " + attempts + " attempts (best=" + bestVertexCount + ")"); + } + + private static RepairResult tryRendererPolygonRepair(Geometry simplified, int maxVertices, double tolerance, + int attempts) { + if (simplified == null || simplified.isEmpty() || + !(simplified instanceof Polygon || simplified instanceof MultiPolygon)) { + return null; + } + simplified = repairRendererPolygonTopology(simplified); + if (simplified.isEmpty() || !(simplified instanceof Polygon || simplified instanceof MultiPolygon) || + !simplified.isValid()) { + return null; + } + Geometry oriented = normalizeRendererPolygonWinding(simplified); + VectorGeometry candidate = encodeGeometry(oriented); + int candidateCount = rendererPolygonVertexCount(candidate.commands()); + return candidateCount <= maxVertices && isValidEncodedPolygon(candidate) ? + new RepairResult(candidate, candidateCount, tolerance, attempts) : null; + } + + static Geometry repairRendererPolygonTopology(Geometry geometry) { + return geometry.isValid() ? geometry : GeometryFixer.fix(geometry); + } + + static Geometry normalizeRendererPolygonWinding(Geometry geometry) { + if (geometry instanceof Polygon polygon) { + return normalizeRendererPolygonWinding(polygon); + } + MultiPolygon multiPolygon = (MultiPolygon) geometry; + Polygon[] polygons = new Polygon[multiPolygon.getNumGeometries()]; + for (int i = 0; i < polygons.length; i++) { + polygons[i] = normalizeRendererPolygonWinding((Polygon) multiPolygon.getGeometryN(i)); + } + return geometry.getFactory().createMultiPolygon(polygons); + } + + private static Polygon normalizeRendererPolygonWinding(Polygon polygon) { + LinearRing shell = orientRendererRing((LinearRing) polygon.getExteriorRing(), true); + LinearRing[] holes = new LinearRing[polygon.getNumInteriorRing()]; + for (int i = 0; i < holes.length; i++) { + holes[i] = orientRendererRing((LinearRing) polygon.getInteriorRingN(i), false); + } + return polygon.getFactory().createPolygon(shell, holes); + } + + private static LinearRing orientRendererRing(LinearRing ring, boolean ccw) { + LinearRing result = (LinearRing) ring.copy(); + return Orientation.isCCW(result.getCoordinateSequence()) == ccw ? result : (LinearRing) result.reverse(); + } + + private static int encodedRendererPolygonVertexCount(Geometry geometry) { + return geometry == null || geometry.isEmpty() ? Integer.MAX_VALUE : + rendererPolygonVertexCount(encodeGeometry(geometry).commands()); + } + + private static Geometry simplifyPolygonComponents(Geometry original, double tolerance, int maxVertices, + boolean ringsIndependently) { + if (original instanceof Polygon polygon) { + return simplifyPolygonComponent(polygon, tolerance, maxVertices, ringsIndependently); + } + MultiPolygon multiPolygon = (MultiPolygon) original; + Polygon[] polygons = new Polygon[multiPolygon.getNumGeometries()]; + for (int i = 0; i < polygons.length; i++) { + Polygon polygon = (Polygon) multiPolygon.getGeometryN(i); + polygons[i] = simplifyPolygonComponent(polygon, tolerance, maxVertices, ringsIndependently); + if (polygons[i] == null) { + return null; + } + } + return original.getFactory().createMultiPolygon(polygons); + } + + private static Polygon simplifyPolygonComponent(Polygon polygon, double tolerance, int maxVertices, + boolean ringsIndependently) { + if (rendererPolygonVertexCount(encodeGeometry(polygon).commands()) <= maxVertices) { + return polygon; + } + if (!ringsIndependently) { + Geometry result = TopologyPreservingSimplifier.simplify(polygon, tolerance); + return result instanceof Polygon simplified ? simplified : null; + } + + GeometryFactory factory = polygon.getFactory(); + LinearRing shell = simplifyRingForRendererLimit((LinearRing) polygon.getExteriorRing(), tolerance, factory); + if (shell == null) { + return null; + } + LinearRing[] holes = new LinearRing[polygon.getNumInteriorRing()]; + for (int i = 0; i < holes.length; i++) { + holes[i] = simplifyRingForRendererLimit((LinearRing) polygon.getInteriorRingN(i), tolerance, factory); + if (holes[i] == null) { + return null; + } + } + Polygon result = factory.createPolygon(shell, holes); + return result.isValid() ? result : null; + } + + static LinearRing simplifyRingForRendererLimit(LinearRing ring, double tolerance, GeometryFactory factory) { + Polygon ringPolygon = factory.createPolygon((LinearRing) ring.copy()); + Geometry simplified = TopologyPreservingSimplifier.simplify(ringPolygon, tolerance); + if (!(simplified instanceof Polygon polygon) || polygon.isEmpty()) { + return null; + } + LinearRing result = (LinearRing) polygon.getExteriorRing().copy(); + // A hole is temporarily wrapped as a polygon shell above. JTS is free to normalize that shell's orientation, + // but MVT uses winding to distinguish shells from holes, so restore the input ring's winding before reassembly. + if (Orientation.isCCW(result.getCoordinateSequence()) != Orientation.isCCW(ring.getCoordinateSequence())) { + result = (LinearRing) result.reverse(); + } + return result; + } + + private static boolean isValidEncodedPolygon(VectorGeometry geometry) { + try { + Geometry decoded = geometry.decode(); + return !decoded.isEmpty() && (decoded instanceof Polygon || decoded instanceof MultiPolygon) && decoded.isValid(); + } catch (GeometryException e) { + return false; + } + } + + /** + * Returns the largest MapLibre fill vertex count among polygon components in an encoded MVT command stream. Each + * component includes its outer ring and at most the 500 largest non-zero-area holes. + */ + static int rendererPolygonVertexCount(int[] commands) { + List rings = decodeRingsForCounting(commands); + if (rings.isEmpty()) { + return 0; + } + + int outerWinding = 0; + int max = 0; + ComponentVertexCount component = null; + for (EncodedRing ring : rings) { + if (ring.signedArea() == 0) { + continue; + } + int winding = ring.signedArea() > 0 ? 1 : -1; + if (outerWinding == 0) { + outerWinding = winding; + } + if (winding == outerWinding) { + if (component != null) { + max = Math.max(max, component.total()); + } + component = new ComponentVertexCount(ring.vertices()); + } else if (component != null) { + component.addHole(ring); + } + } + return component == null ? max : Math.max(max, component.total()); + } + + private static List decodeRingsForCounting(int[] commands) { + List result = new ArrayList<>(); + RingAccumulator ring = null; + int x = 0; + int y = 0; + int i = 0; + while (i < commands.length) { + int commandAndLength = commands[i++]; + int command = commandAndLength & 7; + int length = commandAndLength >>> 3; + if (length <= 0) { + throw new IllegalArgumentException("Invalid zero-length polygon geometry command"); + } + if (command == Command.CLOSE_PATH.value) { + if (ring == null) { + throw new IllegalArgumentException("ClosePath before MoveTo in polygon geometry"); + } + ring.close(); + result.add(ring.result()); + ring = null; + continue; + } + if (command != Command.MOVE_TO.value && command != Command.LINE_TO.value) { + throw new IllegalArgumentException("Invalid polygon geometry command " + command); + } + for (int j = 0; j < length; j++) { + if (i + 1 >= commands.length) { + throw new IllegalArgumentException("Truncated polygon geometry command stream"); + } + x += zigZagDecode(commands[i++]); + y += zigZagDecode(commands[i++]); + if (command == Command.MOVE_TO.value) { + if (ring != null) { + throw new IllegalArgumentException("MoveTo before ClosePath in polygon geometry"); + } + ring = new RingAccumulator(x, y); + } else { + if (ring == null) { + throw new IllegalArgumentException("LineTo before MoveTo in polygon geometry"); + } + ring.add(x, y); + } + } + } + if (ring != null) { + throw new IllegalArgumentException("Polygon ring missing ClosePath"); + } + return result; + } + + private record EncodedRing(int vertices, double signedArea) {} + + private static final class RingAccumulator { + private final int firstX; + private final int firstY; + private int previousX; + private int previousY; + private int vertices = 1; + private double twiceArea = 0; + + private RingAccumulator(int x, int y) { + firstX = previousX = x; + firstY = previousY = y; + } + + private void add(int x, int y) { + twiceArea += (double) previousX * y - (double) x * previousY; + previousX = x; + previousY = y; + vertices++; + } + + private void close() { + twiceArea += (double) previousX * firstY - (double) firstX * previousY; + } + + private EncodedRing result() { + return new EncodedRing(vertices, twiceArea); + } + } + + private static final class ComponentVertexCount { + private static final int MAX_HOLES = 500; + private final int outerVertices; + private final PriorityQueue holes = + new PriorityQueue<>((a, b) -> Double.compare(Math.abs(a.signedArea()), Math.abs(b.signedArea()))); + private int holeVertices = 0; + + private ComponentVertexCount(int outerVertices) { + this.outerVertices = outerVertices; + } + + private void addHole(EncodedRing hole) { + holes.add(hole); + holeVertices += hole.vertices(); + if (holes.size() > MAX_HOLES) { + holeVertices -= holes.remove().vertices(); + } + } + + private int total() { + return outerVertices + holeVertices; + } + } + + private record RepairResult(VectorGeometry geometry, int vertexCount, double tolerance, int attempts) {} + /** * Alias for {@link #toProto(boolean)} where {@code includeIds=true} */ @@ -556,7 +944,7 @@ public VectorTileProto.Tile toProto(boolean includeIds) { VectorTileProto.Tile.Layer.Builder tileLayer = VectorTileProto.Tile.Layer.newBuilder() .setVersion(2) .setName(layerName) - .setExtent(EXTENT) + .setExtent(tileExtent) .addAllKeys(layer.keys()); for (Object value : layer.values()) { @@ -682,7 +1070,7 @@ public MapboxVectorTile toMltInput(Stats stats) { return null; } }).filter(Objects::nonNull).toList(); - return new org.maplibre.mlt.data.Layer(name, features, EXTENT); + return new org.maplibre.mlt.data.Layer(name, features, tileExtent); }).toList()); } @@ -791,8 +1179,9 @@ public VectorGeometry finish() { * specification. *

* To encode extra precision in intermediate feature geometries, the geometry contained in {@code commands} is scaled - * to a tile extent of {@code EXTENT * 2^scale}, so when the {@code scale == 0} the extent is {@link #EXTENT} and when - * {@code scale == 2} the extent is 4x{@link #EXTENT}. Geometries must be scaled back to 0 using {@link #unscale()} + * to a tile extent of {@code tileExtent * 2^scale}, so when the {@code scale == 0} the extent is + * {@link #tileExtent} and when {@code scale == 2} the extent is 4x{@link #tileExtent}. Geometries must be scaled + * back to 0 using {@link #unscale()} * before outputting to the archive. */ public record VectorGeometry(int[] commands, GeometryType geomType, int scale) { @@ -857,7 +1246,7 @@ private static boolean visitedEnoughSides(boolean allowEdges, int sides) { /** Converts an encoded geometry back to a JTS geometry. */ public Geometry decode() throws GeometryException { - return decodeCommands(geomType, commands, (EXTENT << scale) / SIZE); + return decodeCommands(geomType, commands, (tileExtent << scale) / SIZE); } /** Converts an encoded geometry back to a JTS geometry. */ @@ -926,7 +1315,7 @@ public boolean isFillOrEdge(boolean allowEdges) { boolean isLine = geomType == GeometryType.LINE; - int extent = EXTENT << scale; + int extent = tileExtent << scale; int visited = INSIDE; int firstX = 0; int firstY = 0; @@ -1005,7 +1394,7 @@ public VectorGeometry filterPointsOutsideBuffer(double buffer) { } IntArrayList result = null; - int extent = (EXTENT << scale); + int extent = (tileExtent << scale); int bufferInt = (int) Math.ceil(buffer * extent / 256); int min = -bufferInt; int max = extent + bufferInt; @@ -1070,9 +1459,9 @@ public int hilbertIndex() { if (commands.length < 3) { return 0; } - int x = commands[1]; - int y = commands[2]; - return (int) Hilbert.hilbertXYToIndex(15, x >> scale, y >> scale); + int x = commands[1] >>> scale; + int y = commands[2] >>> scale; + return hilbertIndexForEncodedCoords(x, y); } @@ -1085,8 +1474,8 @@ public CoordinateXY firstCoordinate() { return null; } double factor = 1 << scale; - double x = zigZagDecode(commands[1]) * SIZE / EXTENT / factor; - double y = zigZagDecode(commands[2]) * SIZE / EXTENT / factor; + double x = zigZagDecode(commands[1]) * SIZE / tileExtent / factor; + double y = zigZagDecode(commands[2]) * SIZE / tileExtent / factor; return new CoordinateXY(x, y); } } @@ -1192,7 +1581,7 @@ private static class CommandEncoder { int x = 0, y = 0; CommandEncoder(int scale) { - this.SCALE = (EXTENT << scale) / SIZE; + this.SCALE = (tileExtent << scale) / SIZE; } static boolean shouldClosePath(Geometry geometry) { diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/archive/TileArchiveWriter.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/archive/TileArchiveWriter.java index 0c929b6a32..97c306b123 100644 --- a/planetiler-core/src/main/java/com/onthegomap/planetiler/archive/TileArchiveWriter.java +++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/archive/TileArchiveWriter.java @@ -344,6 +344,8 @@ private void tileEncoderSink(Iterable prev) throws IOException { yield mlt; } case UNKNOWN, MVT -> { + tile.enforceRendererPolygonLimit(tileFeatures.tileCoord(), config.maxRendererPolygonVertices(), + config.maxRendererPolygonSimplificationTolerance()); var proto = tile.toProto(includeIds); layerStats = TileSizeStats.computeTileStats(proto); yield proto.toByteArray(); diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/collection/FeatureGroup.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/collection/FeatureGroup.java index 72c566c783..74dc27f238 100644 --- a/planetiler-core/src/main/java/com/onthegomap/planetiler/collection/FeatureGroup.java +++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/collection/FeatureGroup.java @@ -7,6 +7,7 @@ import com.onthegomap.planetiler.Profile; import com.onthegomap.planetiler.VectorTile; import com.onthegomap.planetiler.config.PlanetilerConfig; +import com.onthegomap.planetiler.geo.GeoUtils; import com.onthegomap.planetiler.geo.GeometryException; import com.onthegomap.planetiler.geo.GeometryType; import com.onthegomap.planetiler.geo.TileCoord; @@ -76,6 +77,8 @@ public final class FeatureGroup implements Iterable, this.profile = profile; this.config = config; this.stats = stats; + VectorTile.setExtent(config.tileExtent()); + GeoUtils.setTileExtent(config.tileExtent()); if (config.logJtsExceptions() && CURRENT_TILE == null) { CURRENT_TILE = ThreadLocal.withInitial(() -> null); } diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/config/PlanetilerConfig.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/config/PlanetilerConfig.java index 3a826153c9..9666d98e41 100644 --- a/planetiler-core/src/main/java/com/onthegomap/planetiler/config/PlanetilerConfig.java +++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/config/PlanetilerConfig.java @@ -29,6 +29,7 @@ public record PlanetilerConfig( int minzoom, int maxzoom, int maxzoomForRendering, + int tileExtent, boolean force, boolean append, boolean compressTempStorage, @@ -54,6 +55,8 @@ public record PlanetilerConfig( boolean osmLazyReads, boolean skipFilledTiles, int tileWarningSizeBytes, + int maxRendererPolygonVertices, + double maxRendererPolygonSimplificationTolerance, Boolean color, boolean keepUnzippedSources, TileCompression tileCompression, @@ -90,9 +93,22 @@ public record PlanetilerConfig( if (maxzoom > MAX_MAXZOOM) { throw new IllegalArgumentException("Max zoom must be <= " + MAX_MAXZOOM + ", was " + maxzoom); } + if (tileExtent <= 0 || (tileExtent & (tileExtent - 1)) != 0) { + throw new IllegalArgumentException("tile_extent must be a power of 2, was " + tileExtent); + } if (httpRetries < 0) { throw new IllegalArgumentException("HTTP Retries must be >= 0, was " + httpRetries); } + if (maxRendererPolygonVertices < 4 || maxRendererPolygonVertices > 65_535) { + throw new IllegalArgumentException( + "max_renderer_polygon_vertices must be between 4 and 65535, was " + maxRendererPolygonVertices); + } + if (!(maxRendererPolygonSimplificationTolerance > 0) || + !Double.isFinite(maxRendererPolygonSimplificationTolerance)) { + throw new IllegalArgumentException( + "max_renderer_polygon_simplification_tolerance must be finite and > 0, was " + + maxRendererPolygonSimplificationTolerance); + } } public static PlanetilerConfig defaults() { @@ -139,6 +155,8 @@ public static PlanetilerConfig from(Arguments arguments) { int renderMaxzoom = arguments.getInteger("render_maxzoom", "maximum rendering zoom level up to " + MAX_MAXZOOM, Math.max(maxzoom, DEFAULT_MAXZOOM)); + int tileExtent = arguments.getInteger("tile_extent", + "vector tile extent (default 4096)", 4096); Path tmpDir = arguments.file("tmpdir|tmp", "temp directory", Path.of("data", "tmp")); List extraNameTags = arguments.getList("extra_name_tags", "Extra name tags to copy from OSM to output", List.of()); @@ -162,6 +180,7 @@ public static PlanetilerConfig from(Arguments arguments) { minzoom, maxzoom, renderMaxzoom, + tileExtent, arguments.getBoolean("force", "overwriting output file and ignore disk/RAM warnings", false), arguments.getBoolean("append", "append to the output file - only supported by " + Stream.of(TileArchiveConfig.Format.values()) @@ -196,13 +215,13 @@ public static PlanetilerConfig from(Arguments arguments) { "Maximum bandwidth to consume when downloading files in units mb/s, mbps, kbps, etc.", "")), arguments.getDouble("min_feature_size_at_max_zoom", "Default value for the minimum size in tile pixels of features to emit at the maximum zoom level to allow for overzooming", - 256d / 4096), + 256d / tileExtent), arguments.getDouble("min_feature_size", "Default value for the minimum size in tile pixels of features to emit below the maximum zoom level", 1), arguments.getDouble("simplify_tolerance_at_max_zoom", "Default value for the tile pixel tolerance to use when simplifying features at the maximum zoom level to allow for overzooming", - 256d / 4096), + 256d / tileExtent), arguments.getDouble("simplify_tolerance", "Default value for the tile pixel tolerance to use when simplifying features below the maximum zoom level", 0.1d), @@ -215,6 +234,12 @@ public static PlanetilerConfig from(Arguments arguments) { (int) (arguments.getDouble("tile_warning_size_mb", "Maximum size in megabytes of a tile to emit a warning about", 1d) * 1024 * 1024), + arguments.getInteger("max_renderer_polygon_vertices", + "Maximum vertices in a polygon component (outer ring plus the 500 largest holes) before final MVT encoding", + 60_000), + arguments.getDouble("max_renderer_polygon_simplification_tolerance", + "Maximum tile-pixel tolerance used to repair polygons that exceed the renderer vertex limit", + 256d), arguments.getBooleanObject("color", "Color the terminal output"), arguments.getBoolean("keep_unzipped", "keep unzipped sources by default after reading", false), diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/geo/GeoUtils.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/geo/GeoUtils.java index 4b684ae4b4..9135ad4723 100644 --- a/planetiler-core/src/main/java/com/onthegomap/planetiler/geo/GeoUtils.java +++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/geo/GeoUtils.java @@ -5,6 +5,7 @@ import com.onthegomap.planetiler.stats.Stats; import java.util.ArrayList; import java.util.List; +import java.util.concurrent.atomic.AtomicReference; import java.util.regex.Pattern; import java.util.stream.Stream; import org.geotools.api.referencing.FactoryException; @@ -48,10 +49,24 @@ */ public class GeoUtils { - /** Rounding precision for 256x256px tiles encoded using 4096 values. */ - public static final PrecisionModel TILE_PRECISION = new PrecisionModel(4096d / 256d); + /** + * Rounding precision for 256x256px tiles encoded using {@code tile_extent} values (default 4096). + */ + private static final AtomicReference tilePrecision = + new AtomicReference<>(new PrecisionModel(4096d / 256d)); public static final GeometryFactory JTS_FACTORY = new GeometryFactory(PackedCoordinateSequenceFactory.DOUBLE_FACTORY); + public static PrecisionModel tilePrecision() { + return tilePrecision.get(); + } + + public static void setTileExtent(int tileExtent) { + if (tileExtent <= 0) { + throw new IllegalArgumentException("tile_extent must be > 0, got " + tileExtent); + } + tilePrecision.set(new PrecisionModel(tileExtent / 256d)); + } + public static final Geometry EMPTY_GEOMETRY = JTS_FACTORY.createGeometryCollection(); public static final CoordinateSequence EMPTY_COORDINATE_SEQUENCE = new PackedCoordinateSequence.Double(0, 2, 0); public static final Point EMPTY_POINT = JTS_FACTORY.createPoint(); @@ -309,11 +324,11 @@ public static Geometry combinePoints(List points) { } /** - * Returns a copy of {@code geom} with coordinates rounded to {@link #TILE_PRECISION} and fixes any polygon + * Returns a copy of {@code geom} with coordinates rounded to {@link #tilePrecision()} and fixes any polygon * self-intersections or overlaps that may have caused. */ public static Geometry snapAndFixPolygon(Geometry geom, Stats stats, String stage) throws GeometryException { - return snapAndFixPolygon(geom, TILE_PRECISION, stats, stage); + return snapAndFixPolygon(geom, tilePrecision(), stats, stage); } private static class OrientationFixer extends GeometryTransformer { diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/render/FeatureRenderer.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/render/FeatureRenderer.java index a29477ee6e..4c989913f9 100644 --- a/planetiler-core/src/main/java/com/onthegomap/planetiler/render/FeatureRenderer.java +++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/render/FeatureRenderer.java @@ -49,6 +49,8 @@ public class FeatureRenderer implements Consumer, Clos /** Constructs a new feature render that will send rendered features to {@code consumer}. */ public FeatureRenderer(PlanetilerConfig config, Consumer consumer, Stats stats, Closeable closeable) { + VectorTile.setExtent(config.tileExtent()); + GeoUtils.setTileExtent(config.tileExtent()); this.config = config; this.consumer = consumer; this.stats = stats; @@ -141,7 +143,7 @@ private void renderPoint(int zoom, Map attrs, FeatureCollector.F RenderedFeature.Group groupInfo = null; if (hasLabelGrid && coords.length == 1) { double labelGridTileSize = feature.getPointLabelGridPixelSizeAtZoom(zoom) / 256d; - groupInfo = labelGridTileSize < 1d / 4096d ? null : new RenderedFeature.Group( + groupInfo = labelGridTileSize < 1d / config.tileExtent() ? null : new RenderedFeature.Group( GeoUtils.labelGridId(tilesAtZoom, labelGridTileSize, coords[0]), feature.getPointLabelGridLimitAtZoom(zoom) ); @@ -264,9 +266,11 @@ private void writeTileFeatures(int zoom, long id, FeatureCollector.Feature featu // post-processing. Features need to be "unscaled" in FeatureGroup after line merging, // and before emitting to the output archive. scale = Math.max(config.maxzoom(), 14) - zoom; - // need 14 bits to represent tile coordinates (4096 * 2 for buffer * 2 for zigzag encoding) + // need enough bits to represent tile coordinates (extent * 2 for buffer * 2 for zigzag encoding) // so cap the scale factor to avoid overflowing 32-bit integer space - scale = Math.min(31 - 14, scale); + long maxCoordinate = config.tileExtent() * 4L; + int bits = 64 - Long.numberOfLeadingZeros(maxCoordinate - 1); + scale = Math.clamp(scale, 0, Math.max(0, 31 - bits)); } if (!geom.isEmpty()) { diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/render/TiledGeometry.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/render/TiledGeometry.java index 07534fc445..ba92923bad 100644 --- a/planetiler-core/src/main/java/com/onthegomap/planetiler/render/TiledGeometry.java +++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/render/TiledGeometry.java @@ -20,6 +20,7 @@ import com.carrotsearch.hppc.IntObjectMap; import com.carrotsearch.hppc.cursors.IntCursor; import com.carrotsearch.hppc.cursors.IntObjectCursor; +import com.onthegomap.planetiler.VectorTile; import com.onthegomap.planetiler.collection.Hppc; import com.onthegomap.planetiler.collection.IntRangeSet; import com.onthegomap.planetiler.geo.GeoUtils; @@ -71,7 +72,6 @@ public class TiledGeometry { private static final Format FORMAT = Format.defaultInstance(); - private static final double NEIGHBOR_BUFFER_EPS = 0.1d / 4096; private final Map>> tileContents = new HashMap<>(); private final TileExtents.ForZoom extents; @@ -87,7 +87,7 @@ private TiledGeometry(TileExtents.ForZoom extents, double buffer, int z, boolean this.extents = extents; this.buffer = buffer; // make sure we inspect neighboring tiles when a line runs along an edge - this.neighborBuffer = buffer + NEIGHBOR_BUFFER_EPS; + this.neighborBuffer = buffer + 0.1d / VectorTile.extent(); this.z = z; this.area = area; this.maxTilesAtThisZoom = 1 << z; diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/util/LoopLineMerger.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/util/LoopLineMerger.java index a80cf1a5a5..6d75215f69 100644 --- a/planetiler-core/src/main/java/com/onthegomap/planetiler/util/LoopLineMerger.java +++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/util/LoopLineMerger.java @@ -42,7 +42,7 @@ public class LoopLineMerger { private final List output = new ArrayList<>(); private int numNodes = 0; private int numEdges = 0; - private PrecisionModel precisionModel = new PrecisionModel(GeoUtils.TILE_PRECISION); + private PrecisionModel precisionModel = new PrecisionModel(GeoUtils.tilePrecision()); private GeometryFactory factory = new GeometryFactory(precisionModel); private double minLength = 0.0; private double loopMinLength = 0.0; diff --git a/planetiler-core/src/test/java/com/onthegomap/planetiler/FeatureCollectorTest.java b/planetiler-core/src/test/java/com/onthegomap/planetiler/FeatureCollectorTest.java index 8fa6e39449..8485cbd22e 100644 --- a/planetiler-core/src/test/java/com/onthegomap/planetiler/FeatureCollectorTest.java +++ b/planetiler-core/src/test/java/com/onthegomap/planetiler/FeatureCollectorTest.java @@ -5,6 +5,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import com.onthegomap.planetiler.config.Arguments; import com.onthegomap.planetiler.config.PlanetilerConfig; import com.onthegomap.planetiler.geo.GeoUtils; import com.onthegomap.planetiler.geo.GeometryException; @@ -244,6 +245,16 @@ void testSetTolerance() { assertEquals(256d / 4096, poly.getPixelToleranceAtZoom(14)); } + @Test + void testTileExtentArgAffectsDefaultMaxZoomThresholds() { + var customConfig = PlanetilerConfig.from(Arguments.of(Map.of("tile_extent", "8192"))); + var customFactory = new FeatureCollector.Factory(customConfig, Stats.inMemory()); + var collector = customFactory.get(newReaderFeature(rectangle(10, 20), Map.of())); + var poly = collector.polygon("layername"); + assertEquals(256d / 8192, poly.getMinPixelSizeAtZoom(14)); + assertEquals(256d / 8192, poly.getPixelToleranceAtZoom(14)); + } + @Test void testSetToleranceAtAllZooms() { var collector = factory.get(newReaderFeature(rectangle(10, 20), Map.of())); diff --git a/planetiler-core/src/test/java/com/onthegomap/planetiler/PlanetilerTests.java b/planetiler-core/src/test/java/com/onthegomap/planetiler/PlanetilerTests.java index 1f402eb4e3..f7741ffaf9 100644 --- a/planetiler-core/src/test/java/com/onthegomap/planetiler/PlanetilerTests.java +++ b/planetiler-core/src/test/java/com/onthegomap/planetiler/PlanetilerTests.java @@ -2088,11 +2088,91 @@ void testMergeLineStringsIgnoresRoundingIntersections() throws Exception { feature(newMultiLineString( newLineString(32, 64.3125, 37, 64.0625, 42, 64.3125), newLineString(32, 64, 37, 64.0625, 42, 64) - ), Map.of()) + ), "layer", Map.of(), 0) ) )), sortListValues(results.tiles)); } + @Test + void testTileExtentWorstCasePointLinePolygon() throws Exception { + var baseline = runTileExtentWorstCasePointLinePolygon(4096); + + // Include both high and low extents to stress clipping/rounding behavior. + for (int tileExtent : List.of(512, 1024, 8192, 16384)) { + var result = runTileExtentWorstCasePointLinePolygon(tileExtent); + assertEquals(sortListValues(baseline.tiles), sortListValues(result.tiles), "tile_extent=" + tileExtent); + } + } + + private PlanetilerResults runTileExtentWorstCasePointLinePolygon(int tileExtent) throws Exception { + var points = newMultiPoint( + z14Point(-255, -255), + z14Point(513, -255), + z14Point(513, 513), + z14Point(-255, 513), + z14Point(0, 0), + z14Point(128, 128), + z14Point(256, 256) + ); + + var lines = newMultiLineString( + newLineString(z14CoordinatePixelList( + -255, -255, + 513, -255, + 513, 513, + -255, 513, + -255, -255 + )), + newLineString(z14CoordinatePixelList( + 0, 0, + 128, 128, + 256, 256 + )) + ); + + var polygon = newPolygon(z14CoordinatePixelList( + -255, -255, + 513, -255, + 513, 513, + -255, 513, + -255, -255 + )); + + return runWithReaderFeatures( + Map.of( + "threads", "1", + "maxzoom", "14", + "tile_extent", Integer.toString(tileExtent) + ), + List.of( + newReaderFeature(points, Map.of()), + newReaderFeature(lines, Map.of()), + newReaderFeature(polygon, Map.of()) + ), + (in, features) -> { + if (in.isPoint()) { + features.point("points") + .setZoomRange(14, 14) + .setBufferPixels(257); + } + if (in.canBeLine()) { + features.line("lines") + .setZoomRange(14, 14) + .setBufferPixels(257) + .setMinPixelSize(0) + .setPixelTolerance(0); + } + if (in.canBePolygon()) { + features.polygon("polygons") + .setZoomRange(14, 14) + .setBufferPixels(257) + .setMinPixelSize(0) + .setPixelTolerance(0); + } + } + ); + } + @ParameterizedTest @ValueSource(booleans = {false, true}) void testMergePolygons(boolean unionOverlapping) throws Exception { diff --git a/planetiler-core/src/test/java/com/onthegomap/planetiler/RendererPolygonLimitTest.java b/planetiler-core/src/test/java/com/onthegomap/planetiler/RendererPolygonLimitTest.java new file mode 100644 index 0000000000..cde983dde6 --- /dev/null +++ b/planetiler-core/src/test/java/com/onthegomap/planetiler/RendererPolygonLimitTest.java @@ -0,0 +1,382 @@ +package com.onthegomap.planetiler; + +import static com.onthegomap.planetiler.geo.GeoUtils.JTS_FACTORY; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.onthegomap.planetiler.config.Arguments; +import com.onthegomap.planetiler.config.PlanetilerConfig; +import com.onthegomap.planetiler.geo.GeoUtils; +import com.onthegomap.planetiler.geo.GeometryException; +import com.onthegomap.planetiler.geo.GeometryPipeline; +import com.onthegomap.planetiler.geo.TileCoord; +import com.onthegomap.planetiler.reader.SimpleFeature; +import com.onthegomap.planetiler.render.FeatureRenderer; +import com.onthegomap.planetiler.render.RenderedFeature; +import com.onthegomap.planetiler.stats.Stats; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.locationtech.jts.algorithm.Orientation; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.LinearRing; +import org.locationtech.jts.geom.MultiPolygon; +import org.locationtech.jts.geom.Polygon; +import vector_tile.VectorTileProto; + +class RendererPolygonLimitTest { + + private static final TileCoord Z0 = TileCoord.ofXYZ(0, 0, 0); + + @AfterEach + void restoreExtent() { + VectorTile.setExtent(VectorTile.DEFAULT_EXTENT); + GeoUtils.setTileExtent(VectorTile.DEFAULT_EXTENT); + } + + @Test + void passing59999VertexRingRemainsUnchanged() { + VectorTile.setExtent(1 << 20); + Polygon polygon = polygon(noisyCircle(59_999, 80, 2)); + VectorTile.VectorGeometry geometry = VectorTile.encodeGeometry(polygon); + assertEquals(59_999, VectorTile.rendererPolygonVertexCount(geometry.commands())); + + int[] result = enforceAndSerialize(geometry, 60_000, 256); + + assertArrayEquals(geometry.commands(), result); + } + + @Test + void failing60001VertexRingIsRepairedAndValid() throws GeometryException { + VectorTile.setExtent(1 << 20); + VectorTile.VectorGeometry geometry = VectorTile.encodeGeometry(polygon(noisyCircle(60_001, 80, 2))); + assertEquals(60_001, VectorTile.rendererPolygonVertexCount(geometry.commands())); + + int[] repaired = enforceAndSerialize(geometry, 60_000, 256); + + assertTrue(VectorTile.rendererPolygonVertexCount(repaired) <= 60_000); + assertNotEquals(Arrays.hashCode(geometry.commands()), Arrays.hashCode(repaired)); + assertTrue(new VectorTile.VectorGeometry(repaired, geometry.geomType(), 0).decode().isValid()); + } + + @Test + void ringAboveMapLibre65535LimitIsRepaired() { + VectorTile.setExtent(1 << 20); + VectorTile.VectorGeometry geometry = VectorTile.encodeGeometry(polygon(noisyCircle(66_000, 80, 2))); + assertTrue(VectorTile.rendererPolygonVertexCount(geometry.commands()) > 65_535); + + int[] repaired = enforceAndSerialize(geometry, 60_000, 256); + + assertTrue(VectorTile.rendererPolygonVertexCount(repaired) <= 60_000); + } + + @Test + void multipolygonIsCheckedPerComponentRatherThanByFeatureTotal() { + VectorTile.setExtent(1 << 20); + Polygon left = polygon(noisyCircle(35_000, 35, 1, 60, 128)); + Polygon right = polygon(noisyCircle(35_000, 35, 1, 196, 128)); + VectorTile.VectorGeometry geometry = VectorTile.encodeGeometry( + JTS_FACTORY.createMultiPolygon(new Polygon[]{left, right})); + assertTrue(left.getNumPoints() + right.getNumPoints() > 60_000); + assertEquals(35_000, VectorTile.rendererPolygonVertexCount(geometry.commands())); + + assertArrayEquals(geometry.commands(), enforceAndSerialize(geometry, 60_000, 256)); + } + + @Test + void multipolygonWithOversizedComponentIsRepaired() throws GeometryException { + VectorTile.setExtent(1 << 20); + Polygon large = polygon(noisyCircle(60_001, 70, 2, 90, 128)); + Polygon small = polygon(noisyCircle(100, 15, 1, 220, 128)); + VectorTile.VectorGeometry geometry = VectorTile.encodeGeometry( + JTS_FACTORY.createMultiPolygon(new Polygon[]{large, small})); + assertEquals(60_001, VectorTile.rendererPolygonVertexCount(geometry.commands())); + + int[] repaired = enforceAndSerialize(geometry, 60_000, 256); + + assertTrue(VectorTile.rendererPolygonVertexCount(repaired) <= 60_000); + assertTrue(new VectorTile.VectorGeometry(repaired, geometry.geomType(), 0).decode().isValid()); + } + + @Test + void exactly500HolesAreCounted() { + VectorTile.setExtent(8192); + VectorTile.VectorGeometry geometry = VectorTile.encodeGeometry(polygonWithHoles(500, false)); + + assertEquals(4 + 500 * 4, VectorTile.rendererPolygonVertexCount(geometry.commands())); + } + + @Test + void only500LargestHolesAreCounted() { + VectorTile.setExtent(8192); + VectorTile.VectorGeometry geometry = VectorTile.encodeGeometry(polygonWithHoles(501, true)); + + // The 501st and smallest hole has three vertices and is deliberately excluded. + assertEquals(4 + 500 * 4, VectorTile.rendererPolygonVertexCount(geometry.commands())); + } + + @Test + void polygonWithManyDetailedHolesIsRepaired() throws GeometryException { + VectorTile.setExtent(1 << 20); + VectorTile.VectorGeometry geometry = VectorTile.encodeGeometry(detailedPolygonWithHoles(500, 125)); + assertTrue(VectorTile.rendererPolygonVertexCount(geometry.commands()) > 60_000); + + int[] repaired = enforceAndSerialize(geometry, 60_000, 256); + + assertTrue(VectorTile.rendererPolygonVertexCount(repaired) <= 60_000); + assertTrue(new VectorTile.VectorGeometry(repaired, geometry.geomType(), 0).decode().isValid()); + } + + @Test + void ringFallbackPreservesMvtWinding() { + Coordinate[] coordinates = noisyCircle(1_000, 80, 2); + for (boolean reverse : List.of(false, true)) { + Coordinate[] oriented = Arrays.stream(coordinates).map(Coordinate::copy).toArray(Coordinate[]::new); + if (reverse) { + reverse(oriented); + } + LinearRing ring = JTS_FACTORY.createLinearRing(oriented); + LinearRing simplified = VectorTile.simplifyRingForRendererLimit(ring, 1, JTS_FACTORY); + + assertEquals(Orientation.isCCW(ring.getCoordinateSequence()), + Orientation.isCCW(simplified.getCoordinateSequence())); + } + } + + @Test + void repairedCandidateNormalizesShellAndHoleWindingForMvt() throws GeometryException { + Polygon first = polygonWithHoles(1, false); + Polygon second = polygon(noisyCircle(100, 20, 1, 300, 128)); + // Deliberately give the second shell the winding that MVT reserves for holes. + second = JTS_FACTORY.createPolygon((LinearRing) second.getExteriorRing().reverse()); + Geometry normalized = VectorTile.normalizeRendererPolygonWinding( + JTS_FACTORY.createMultiPolygon(new Polygon[]{first, second})); + + for (int i = 0; i < normalized.getNumGeometries(); i++) { + Polygon polygon = (Polygon) normalized.getGeometryN(i); + assertTrue(Orientation.isCCW(polygon.getExteriorRing().getCoordinateSequence())); + for (int j = 0; j < polygon.getNumInteriorRing(); j++) { + assertTrue(!Orientation.isCCW(polygon.getInteriorRingN(j).getCoordinateSequence())); + } + } + assertTrue(VectorTile.encodeGeometry(normalized).decode().isValid()); + } + + @Test + void invalidFallbackMultipolygonIsFixedBeforeEncoding() throws GeometryException { + Polygon left = polygon(closedRing( + new Coordinate(0, 0), new Coordinate(20, 0), new Coordinate(20, 20), new Coordinate(0, 20))); + Polygon overlapping = polygon(closedRing( + new Coordinate(10, 10), new Coordinate(30, 10), new Coordinate(30, 30), new Coordinate(10, 30))); + MultiPolygon invalid = JTS_FACTORY.createMultiPolygon(new Polygon[]{left, overlapping}); + assertTrue(!invalid.isValid()); + + Geometry repaired = VectorTile.repairRendererPolygonTopology(invalid); + + assertTrue(!repaired.isEmpty()); + assertTrue(repaired instanceof Polygon || repaired instanceof MultiPolygon); + assertTrue(repaired.isValid()); + assertTrue(VectorTile.encodeGeometry(VectorTile.normalizeRendererPolygonWinding(repaired)).decode().isValid()); + } + + @Test + void zeroAreaAndDegenerateRingsAreIgnored() { + Coordinate[] outer = { + new Coordinate(0, 0), new Coordinate(100, 0), new Coordinate(100, 100), new Coordinate(0, 100) + }; + Coordinate[] zeroArea = { + new Coordinate(10, 10), new Coordinate(20, 20), new Coordinate(30, 30), new Coordinate(20, 20) + }; + + assertEquals(4, VectorTile.rendererPolygonVertexCount(encodeRawRings(outer, zeroArea))); + } + + @ParameterizedTest + @ValueSource(ints = {4096, 8192}) + void actualClipQuantizeEncodeEnforceAndSerializePath(int extent) throws GeometryException { + // This input starts in world coordinates and passes through FeatureRenderer clipping, polygon repair, + // quantization, MVT command encoding, renderer-limit enforcement, and protobuf serialization. + Polygon worldPolygon = polygon(sawtoothCoastline(3_500, 20)); + RenderPathResult rendered = renderFinalGeometry(worldPolygon, extent); + assertTrue(rendered.originalCount() > 60_000, + "synthetic ring retained " + rendered.originalCount() + + " vertices after clipping and quantization at extent " + extent); + + int[] repaired = enforceAndSerialize(rendered.geometry(), 60_000, 256); + + assertTrue(VectorTile.rendererPolygonVertexCount(repaired) <= 60_000); + assertTrue(new VectorTile.VectorGeometry(repaired, rendered.geometry().geomType(), 0).decode().isValid()); + } + + @Test + void difficultGeometryFailsAtMaximumTolerance() { + VectorTile.setExtent(1 << 20); + VectorTile.VectorGeometry geometry = VectorTile.encodeGeometry(polygon(noisyCircle(66_000, 80, 20))); + double oneGridUnit = 256d / VectorTile.extent(); + + IllegalStateException error = assertThrows(IllegalStateException.class, + () -> enforceAndSerialize(geometry, 60_000, oneGridUnit)); + + assertTrue(error.getMessage().contains("maximum tolerance")); + } + + private static int[] enforceAndSerialize(VectorTile.VectorGeometry geometry, int maxVertices, + double maxTolerance) { + VectorTile tile = new VectorTile(); + tile.addLayerFeatures("test", List.of(new VectorTile.Feature("test", 123, geometry, Map.of("name", "shape")))); + tile.enforceRendererPolygonLimit(Z0, maxVertices, maxTolerance); + byte[] serialized = tile.toProto().toByteArray(); + try { + VectorTileProto.Tile proto = VectorTileProto.Tile.parseFrom(serialized); + return proto.getLayers(0).getFeatures(0).getGeometryList().stream().mapToInt(Integer::intValue).toArray(); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + + private static RenderPathResult renderFinalGeometry(Polygon worldPolygon, int extent) { + PlanetilerConfig config = PlanetilerConfig.from(Arguments.of( + "maxzoom", "0", + "render_maxzoom", "0", + "tile_extent", Integer.toString(extent) + )); + var source = SimpleFeature.create( + GeoUtils.worldToLatLonCoords(worldPolygon), HashMap.newHashMap(0), null, null, 123); + var feature = new FeatureCollector.Factory(config, Stats.inMemory()).get(source) + .polygon("test") + .setZoomRange(0, 0) + .setMinPixelSize(0) + .transformScaledGeometry(GeometryPipeline.NOOP); + List rendered = new ArrayList<>(); + new FeatureRenderer(config, rendered::add, Stats.inMemory()).accept(feature); + assertEquals(1, rendered.size()); + VectorTile.VectorGeometry geometry = rendered.getFirst().vectorTileFeature().geometry(); + return new RenderPathResult(geometry, VectorTile.rendererPolygonVertexCount(geometry.commands())); + } + + private static Polygon polygon(Coordinate[] coordinates) { + return JTS_FACTORY.createPolygon(coordinates); + } + + private static Coordinate[] noisyCircle(int vertices, double radius, double noise) { + return noisyCircle(vertices, radius, noise, 128, 128); + } + + private static Coordinate[] noisyCircle(int vertices, double radius, double noise, double centerX, + double centerY) { + Coordinate[] coordinates = new Coordinate[vertices + 1]; + for (int i = 0; i < vertices; i++) { + double angle = 2 * Math.PI * i / vertices; + double adjustedRadius = radius + noise * Math.sin(i * 17.0); + coordinates[i] = new Coordinate( + centerX + adjustedRadius * Math.cos(angle), + centerY + adjustedRadius * Math.sin(angle) + ); + } + coordinates[vertices] = coordinates[0].copy(); + return coordinates; + } + + private static Coordinate[] sawtoothCoastline(int columns, int pointsPerColumn) { + List coordinates = new ArrayList<>(columns * pointsPerColumn + 4); + for (int column = 0; column < columns; column++) { + double x = 0.05 + 0.9 * column / (columns - 1d); + for (int row = 0; row < pointsPerColumn; row++) { + int orderedRow = (column & 1) == 0 ? row : pointsPerColumn - 1 - row; + double y = 0.2 + 0.5 * orderedRow / (pointsPerColumn - 1d); + coordinates.add(new Coordinate(x, y)); + } + } + coordinates.add(new Coordinate(0.95, 0.95)); + coordinates.add(new Coordinate(0.05, 0.95)); + coordinates.add(coordinates.getFirst().copy()); + return coordinates.toArray(Coordinate[]::new); + } + + private static Polygon polygonWithHoles(int holes, boolean lastIsSmallTriangle) { + LinearRing shell = JTS_FACTORY.createLinearRing(closedRing( + new Coordinate(1, 1), new Coordinate(255, 1), new Coordinate(255, 255), new Coordinate(1, 255))); + LinearRing[] holeRings = new LinearRing[holes]; + for (int i = 0; i < holes; i++) { + double x = 3 + (i % 25) * 10; + double y = 3 + (i / 25) * 10; + Coordinate[] coordinates; + if (lastIsSmallTriangle && i == holes - 1) { + coordinates = closedRing( + new Coordinate(x, y), new Coordinate(x + 0.25, y), new Coordinate(x, y + 0.25)); + } else { + coordinates = closedRing( + new Coordinate(x, y), new Coordinate(x, y + 2), new Coordinate(x + 2, y + 2), + new Coordinate(x + 2, y)); + } + holeRings[i] = JTS_FACTORY.createLinearRing(coordinates); + } + return JTS_FACTORY.createPolygon(shell, holeRings); + } + + private static Polygon detailedPolygonWithHoles(int holes, int verticesPerHole) { + LinearRing shell = JTS_FACTORY.createLinearRing(closedRing( + new Coordinate(1, 1), new Coordinate(255, 1), new Coordinate(255, 255), new Coordinate(1, 255))); + LinearRing[] holeRings = new LinearRing[holes]; + for (int i = 0; i < holes; i++) { + double x = 5 + (i % 25) * 10; + double y = 5 + (i / 25) * 10; + Coordinate[] coordinates = noisyCircle(verticesPerHole, 2, 0.2, x, y); + reverse(coordinates); + holeRings[i] = JTS_FACTORY.createLinearRing(coordinates); + } + return JTS_FACTORY.createPolygon(shell, holeRings); + } + + private static void reverse(Coordinate[] coordinates) { + for (int i = 0, j = coordinates.length - 1; i < j; i++, j--) { + Coordinate swap = coordinates[i]; + coordinates[i] = coordinates[j]; + coordinates[j] = swap; + } + } + + private static Coordinate[] closedRing(Coordinate... coordinates) { + Coordinate[] result = Arrays.copyOf(coordinates, coordinates.length + 1); + result[coordinates.length] = coordinates[0].copy(); + return result; + } + + private static int[] encodeRawRings(Coordinate[]... rings) { + List commands = new ArrayList<>(); + int previousX = 0; + int previousY = 0; + for (Coordinate[] ring : rings) { + commands.add(9); // MoveTo, length 1 + int x = (int) ring[0].x; + int y = (int) ring[0].y; + commands.add(VectorTile.zigZagEncode(x - previousX)); + commands.add(VectorTile.zigZagEncode(y - previousY)); + previousX = x; + previousY = y; + commands.add(((ring.length - 1) << 3) | 2); // LineTo + for (int i = 1; i < ring.length; i++) { + x = (int) ring[i].x; + y = (int) ring[i].y; + commands.add(VectorTile.zigZagEncode(x - previousX)); + commands.add(VectorTile.zigZagEncode(y - previousY)); + previousX = x; + previousY = y; + } + commands.add(15); // ClosePath, length 1 + } + return commands.stream().mapToInt(Integer::intValue).toArray(); + } + + private record RenderPathResult(VectorTile.VectorGeometry geometry, int originalCount) {} +} diff --git a/planetiler-core/src/test/java/com/onthegomap/planetiler/VectorTileTest.java b/planetiler-core/src/test/java/com/onthegomap/planetiler/VectorTileTest.java index d3f9346958..e8e78952bb 100644 --- a/planetiler-core/src/test/java/com/onthegomap/planetiler/VectorTileTest.java +++ b/planetiler-core/src/test/java/com/onthegomap/planetiler/VectorTileTest.java @@ -256,6 +256,24 @@ void testRoundTripMultipoint() { })); } + @Test + void testHilbertIndexLargeTileExtent() { + int originalExtent = VectorTile.extent(); + try { + VectorTile.setExtent(16384); + var point = newPoint(513, 513); + + int fromGeometry = VectorTile.hilbertIndex(point); + int fromEncodedGeometry = VectorTile.encodeGeometry(point).hilbertIndex(); + int fromScaledEncodedGeometry = VectorTile.encodeGeometry(point, 2).hilbertIndex(); + + assertEquals(fromGeometry, fromEncodedGeometry); + assertEquals(fromGeometry, fromScaledEncodedGeometry); + } finally { + VectorTile.setExtent(originalExtent); + } + } + @Test void testRoundTripLineString() { testRoundTripGeometry(JTS_FACTORY.createLineString(new Coordinate[]{ diff --git a/planetiler-core/src/test/java/com/onthegomap/planetiler/config/PlanetilerConfigTest.java b/planetiler-core/src/test/java/com/onthegomap/planetiler/config/PlanetilerConfigTest.java new file mode 100644 index 0000000000..f56db92a1c --- /dev/null +++ b/planetiler-core/src/test/java/com/onthegomap/planetiler/config/PlanetilerConfigTest.java @@ -0,0 +1,38 @@ +package com.onthegomap.planetiler.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class PlanetilerConfigTest { + + @Test + void testTileExtentMustBePowerOf2() { + var exception = assertThrows(IllegalArgumentException.class, + () -> PlanetilerConfig.from(Arguments.of("tile_extent", "5000"))); + assertTrue(exception.getMessage().contains("power of 2")); + } + + @Test + void testTileExtentPowerOf2Allowed() { + assertEquals(8192, PlanetilerConfig.from(Arguments.of("tile_extent", "8192")).tileExtent()); + } + + @Test + void testRendererPolygonLimitOptions() { + var config = PlanetilerConfig.from(Arguments.of( + "max-renderer-polygon-vertices", "50000", + "max-renderer-polygon-simplification-tolerance", "12.5" + )); + assertEquals(50_000, config.maxRendererPolygonVertices()); + assertEquals(12.5, config.maxRendererPolygonSimplificationTolerance()); + } + + @Test + void testRendererPolygonLimitMustNotExceedMapLibreLimit() { + assertThrows(IllegalArgumentException.class, + () -> PlanetilerConfig.from(Arguments.of("max_renderer_polygon_vertices", "65536"))); + } +}