diff --git a/NOTICE.md b/NOTICE.md index 77eb1f02..ac4701c6 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -23,6 +23,7 @@ The `planetiler-core` module includes the following software: - org.openstreetmap.osmosis:osmosis-osm-binary (LGPL 3.0) - com.carrotsearch:hppc (Apache license) - com.github.jnr:jnr-ffi (Apache license) + - org.roaringbitmap:RoaringBitmap (Apache license) - Adapted code: - `DouglasPeuckerSimplifier` from [JTS](https://github.com/locationtech/jts) (EDL) - `OsmMultipolygon` from [imposm3](https://github.com/omniscale/imposm3) (Apache license) diff --git a/planetiler-core/pom.xml b/planetiler-core/pom.xml index 91418134..856cd11b 100644 --- a/planetiler-core/pom.xml +++ b/planetiler-core/pom.xml @@ -27,6 +27,11 @@ hppc 0.9.1 + + org.roaringbitmap + RoaringBitmap + 0.9.30 + org.openstreetmap.osmosis osmosis-osm-binary diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/collection/IntRangeSet.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/collection/IntRangeSet.java index 83d2df32..f918f5d1 100644 --- a/planetiler-core/src/main/java/com/onthegomap/planetiler/collection/IntRangeSet.java +++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/collection/IntRangeSet.java @@ -1,99 +1,84 @@ package com.onthegomap.planetiler.collection; -import com.google.common.collect.Range; -import com.google.common.collect.TreeRangeSet; -import java.util.Iterator; import java.util.PrimitiveIterator; import javax.annotation.concurrent.NotThreadSafe; +import org.roaringbitmap.PeekableIntIterator; +import org.roaringbitmap.RoaringBitmap; /** - * A set of ints backed by a {@link TreeRangeSet} to efficiently represent large continuous ranges. + * A set of ints backed by a {@link RoaringBitmap} to efficiently represent large continuous ranges. *

* This makes iterating through tile coordinates inside ocean polygons significantly faster. */ -@SuppressWarnings("UnstableApiUsage") @NotThreadSafe public class IntRangeSet implements Iterable { - private final TreeRangeSet rangeSet = TreeRangeSet.create(); + private final RoaringBitmap bitmap = new RoaringBitmap(); /** Mutates and returns this range set, adding all elements in {@code other} to it. */ public IntRangeSet addAll(IntRangeSet other) { - rangeSet.addAll(other.rangeSet); + bitmap.or(other.bitmap); return this; } /** Mutates and returns this range set, removing all elements in {@code other} from it. */ public IntRangeSet removeAll(IntRangeSet other) { - rangeSet.removeAll(other.rangeSet); + bitmap.andNot(other.bitmap); return this; } + public static void main(String[] args) { + var set = new IntRangeSet(); + set.add(0, 100000); + set.remove(10000); + System.err.println(set.bitmap.getSizeInBytes()); + set.bitmap.runOptimize(); + System.err.println(set.bitmap.getSizeInBytes()); + } + @Override public PrimitiveIterator.OfInt iterator() { - return new Iter(rangeSet.asRanges().iterator()); + return new Iter(bitmap.getIntIterator()); } /** Mutates and returns this range set, with range {@code a} to {@code b} (inclusive) added. */ public IntRangeSet add(int a, int b) { - rangeSet.add(Range.closedOpen(a, b + 1)); + bitmap.add(a, (long) b + 1); return this; } /** Mutates and returns this range set, with {@code a} removed. */ public IntRangeSet remove(int a) { - rangeSet.remove(Range.closedOpen(a, a + 1)); + bitmap.remove(a); return this; } public boolean contains(int y) { - return rangeSet.contains(y); + return bitmap.contains(y); + } + + /** Returns the underlying {@link RoaringBitmap} for this int range. */ + public RoaringBitmap bitmap() { + return bitmap; } /** Mutates and returns this range set to remove all elements not in {@code other} */ public IntRangeSet intersect(IntRangeSet other) { - rangeSet.removeAll(other.rangeSet.complement()); + bitmap.and(other.bitmap); return this; } /** Iterate through all ints in this range */ - private static class Iter implements PrimitiveIterator.OfInt { + private record Iter(PeekableIntIterator iter) implements PrimitiveIterator.OfInt { - private final Iterator> rangeIter; - Range range; - int cur; - boolean hasNext = true; - - private Iter(Iterator> rangeIter) { - this.rangeIter = rangeIter; - advance(); - } - - private void advance() { - while (true) { - if (range != null && cur < range.upperEndpoint() - 1) { - cur++; - return; - } else if (rangeIter.hasNext()) { - range = rangeIter.next(); - cur = range.lowerEndpoint() - 1; - } else { - hasNext = false; - return; - } - } + @Override + public boolean hasNext() { + return iter.hasNext(); } @Override public int nextInt() { - int result = cur; - advance(); - return result; - } - - @Override - public boolean hasNext() { - return hasNext; + return iter.next(); } } } diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/config/Bounds.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/config/Bounds.java index ef489b3e..f77560bf 100644 --- a/planetiler-core/src/main/java/com/onthegomap/planetiler/config/Bounds.java +++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/config/Bounds.java @@ -4,6 +4,7 @@ import com.onthegomap.planetiler.geo.GeoUtils; import com.onthegomap.planetiler.geo.TileExtents; import com.onthegomap.planetiler.reader.osm.OsmInputFile; import org.locationtech.jts.geom.Envelope; +import org.locationtech.jts.geom.Geometry; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -21,6 +22,8 @@ public class Bounds { private Envelope world; private TileExtents tileExtents; + private Geometry shape; + Bounds(Envelope latLon) { set(latLon); } @@ -35,7 +38,7 @@ public class Bounds { public TileExtents tileExtents() { if (tileExtents == null) { - tileExtents = TileExtents.computeFromWorldBounds(PlanetilerConfig.MAX_MAXZOOM, world()); + tileExtents = TileExtents.computeFromWorldBounds(PlanetilerConfig.MAX_MAXZOOM, world(), shape); } return tileExtents; } @@ -52,11 +55,20 @@ public class Bounds { return this; } + /** Planetiler will emit any tile that intersects {@code shape}. */ + public Bounds setShape(Geometry shape) { + this.shape = shape; + if (latLon == null) { + set(shape.getEnvelopeInternal()); + } + return this; + } + private void set(Envelope latLon) { if (latLon != null) { this.latLon = latLon; this.world = GeoUtils.toWorldBounds(latLon); - this.tileExtents = TileExtents.computeFromWorldBounds(PlanetilerConfig.MAX_MAXZOOM, world); + this.tileExtents = TileExtents.computeFromWorldBounds(PlanetilerConfig.MAX_MAXZOOM, world, shape); } } 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 cb17075f..4d429725 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 @@ -2,6 +2,10 @@ package com.onthegomap.planetiler.config; import com.onthegomap.planetiler.collection.LongLongMap; import com.onthegomap.planetiler.collection.Storage; +import com.onthegomap.planetiler.reader.osm.PolyFileReader; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Path; import java.time.Duration; import java.util.stream.Stream; @@ -90,9 +94,19 @@ public record PlanetilerConfig( int featureProcessThreads = arguments.getInteger("process_threads", "number of threads to use when processing input features", Math.max(threads < 4 ? threads : (threads - featureWriteThreads), 1)); + Bounds bounds = new Bounds(arguments.bounds("bounds", "bounds")); + Path polygonFile = + arguments.file("polygon", "a .poly file that limits output to tiles intersecting the shape", null); + if (polygonFile != null) { + try { + bounds.setShape(PolyFileReader.parsePolyFile(polygonFile)); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } return new PlanetilerConfig( arguments, - new Bounds(arguments.bounds("bounds", "bounds")), + bounds, threads, featureWriteThreads, featureProcessThreads, 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 accfeed1..e77fa2a8 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 @@ -329,13 +329,13 @@ public class GeoUtils { * * @param tilesAtZoom the tile width of the world at this zoom level * @param labelGridTileSize the tile width of each grid square - * @param coord the coordinate + * @param coord the coordinate, scaled to this zoom level * @return an ID representing the grid square that {@code coord} falls into. */ public static long labelGridId(int tilesAtZoom, double labelGridTileSize, Coordinate coord) { return GeoUtils.longPair( - (int) Math.floor(wrapDouble(coord.getX() * tilesAtZoom, tilesAtZoom) / labelGridTileSize), - (int) Math.floor((coord.getY() * tilesAtZoom) / labelGridTileSize) + (int) Math.floor(wrapDouble(coord.getX(), tilesAtZoom) / labelGridTileSize), + (int) Math.floor((coord.getY()) / labelGridTileSize) ); } diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/geo/TileCoord.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/geo/TileCoord.java index fea2dd8c..be209361 100644 --- a/planetiler-core/src/main/java/com/onthegomap/planetiler/geo/TileCoord.java +++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/geo/TileCoord.java @@ -118,6 +118,7 @@ public record TileCoord(int encoded, int x, int y, int z) implements Comparable< ); } + /** Returns a URL that displays the openstreetmap data for this tile. */ public String getDebugUrl() { Coordinate coord = getLatLon(); diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/geo/TileExtents.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/geo/TileExtents.java index 0c4bf89d..2d464541 100644 --- a/planetiler-core/src/main/java/com/onthegomap/planetiler/geo/TileExtents.java +++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/geo/TileExtents.java @@ -1,13 +1,19 @@ package com.onthegomap.planetiler.geo; +import com.onthegomap.planetiler.render.TiledGeometry; import java.util.function.Predicate; import org.locationtech.jts.geom.Envelope; +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.util.AffineTransformation; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * A function that filters to only tile coordinates that overlap a given {@link Envelope}. */ public class TileExtents implements Predicate { + private static final Logger LOGGER = LoggerFactory.getLogger(TileExtents.class); private final ForZoom[] zoomExtents; private TileExtents(ForZoom[] zoomExtents) { @@ -24,15 +30,34 @@ public class TileExtents implements Predicate { /** Returns a filter to tiles that intersect {@code worldBounds} (specified in world web mercator coordinates). */ public static TileExtents computeFromWorldBounds(int maxzoom, Envelope worldBounds) { + return computeFromWorldBounds(maxzoom, worldBounds, null); + } + + + /** Returns a filter to tiles that intersect {@code worldBounds} (specified in world web mercator coordinates). */ + public static TileExtents computeFromWorldBounds(int maxzoom, Envelope worldBounds, Geometry shape) { ForZoom[] zoomExtents = new ForZoom[maxzoom + 1]; + var mercator = shape == null ? null : GeoUtils.latLonToWorldCoords(shape); for (int zoom = 0; zoom <= maxzoom; zoom++) { int max = 1 << zoom; - zoomExtents[zoom] = new ForZoom( + + var forZoom = new ForZoom( + zoom, quantizeDown(worldBounds.getMinX(), max), quantizeDown(worldBounds.getMinY(), max), quantizeUp(worldBounds.getMaxX(), max), - quantizeUp(worldBounds.getMaxY(), max) + quantizeUp(worldBounds.getMaxY(), max), + null ); + + if (mercator != null) { + Geometry scaled = AffineTransformation.scaleInstance(1 << zoom, 1 << zoom).transform(mercator); + TiledGeometry.CoveredTiles covered = TiledGeometry.getCoveredTiles(scaled, zoom, forZoom); + forZoom = forZoom.withShape(covered); + LOGGER.info("prepareShapeForZoom z{} {}", zoom, covered); + } + + zoomExtents[zoom] = forZoom; } return new TileExtents(zoomExtents); } @@ -52,12 +77,25 @@ public class TileExtents implements Predicate { /** * X/Y extents within a given zoom level. {@code minX} and {@code minY} are inclusive and {@code maxX} and {@code - * maxY} are exclusive. + * maxY} are exclusive. shape is an optional polygon defining a more refine shape */ - public record ForZoom(int minX, int minY, int maxX, int maxY) { + public record ForZoom(int z, int minX, int minY, int maxX, int maxY, TilePredicate shapeFilter) + implements TilePredicate { + public ForZoom withShape(TilePredicate shape) { + return new ForZoom(z, minX, minY, maxX, maxY, shape); + } + + @Override public boolean test(int x, int y) { - return testX(x) && testY(y); + return testX(x) && testY(y) && testOverShape(x, y); + } + + private boolean testOverShape(int x, int y) { + if (shapeFilter != null) { + return shapeFilter.test(x, y); + } + return true; } public boolean testX(int x) { diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/geo/TilePredicate.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/geo/TilePredicate.java new file mode 100644 index 00000000..5e6831da --- /dev/null +++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/geo/TilePredicate.java @@ -0,0 +1,5 @@ +package com.onthegomap.planetiler.geo; + +public interface TilePredicate { + boolean test(int x, int y); +} diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/reader/osm/PolyFileReader.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/reader/osm/PolyFileReader.java new file mode 100644 index 00000000..4577b804 --- /dev/null +++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/reader/osm/PolyFileReader.java @@ -0,0 +1,103 @@ +package com.onthegomap.planetiler.reader.osm; + +import static com.onthegomap.planetiler.geo.GeoUtils.JTS_FACTORY; + +import com.onthegomap.planetiler.geo.GeoUtils; +import com.onthegomap.planetiler.geo.MutableCoordinateSequence; +import com.onthegomap.planetiler.reader.FileFormatException; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.Reader; +import java.io.StringReader; +import java.nio.file.Files; +import java.nio.file.Path; +import org.locationtech.jts.geom.Geometry; + +/** + * Parse a polygon file used for filtering planetiler output to a specific shape. + * + * @see Osmosis/Polygon Filter File + * Format + */ +public class PolyFileReader { + + private PolyFileReader() { + throw new IllegalStateException("Utility class"); + } + + /** + * Reads a polygon from a file. + */ + public static Geometry parsePolyFile(Path polyFile) throws IOException { + try (BufferedReader reader = Files.newBufferedReader(polyFile)) { + return parsePolyFile(reader); + } + } + + /** + * Reads a polygon from a string. + */ + public static Geometry parsePolyFile(String polyFile) throws IOException { + try (Reader reader = new StringReader(polyFile)) { + return parsePolyFile(reader); + } + } + + /** + * Reads a polygon from a {@link Reader} {@code input}. + */ + public static Geometry parsePolyFile(Reader input) throws IOException { + Geometry result = GeoUtils.EMPTY_POLYGON; + try (BufferedReader reader = input instanceof BufferedReader br ? br : new BufferedReader(input)) { + String line; + MutableCoordinateSequence currentRing = null; + boolean firstLine = true, inRing = false, inPolygon = true, hole = false; + while ((line = reader.readLine()) != null) { + if (line.isBlank()) { + // ingore line + } else if (!inPolygon) { + throw new FileFormatException("File continues after end of polygon"); + } else if (firstLine) { + firstLine = false; + // first line is junk. + } else if (inRing) { + if (line.strip().equals("END")) { + // we are at the end of a ring, perhaps with more to come. + currentRing.closeRing(); + var polygon = JTS_FACTORY.createPolygon(JTS_FACTORY.createLinearRing(currentRing), null); + if (hole) { + result = result.difference(polygon); + } else { + result = result.union(polygon); + } + currentRing = null; + inRing = false; + } else { + // we are in a ring and picking up new coordinates. + String[] splitted = line.trim().split("\s+"); + currentRing.addPoint(Double.parseDouble(splitted[0]), Double.parseDouble(splitted[1])); + } + } else { + if (line.strip().equals("END")) { + // we are at the end of the whole polygon. + inPolygon = false; + } else { + // we are at the start of a polygon part. + currentRing = new MutableCoordinateSequence(); + hole = line.strip().charAt(0) == '!'; + inRing = true; + } + } + } + + if (inRing) { + throw new FileFormatException("Unclosed ring"); + } + if (inPolygon) { + throw new FileFormatException("File ends before end of polygon"); + } + + return result; + } + } +} 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 7632d716..d0a07685 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 @@ -94,13 +94,24 @@ public class FeatureRenderer implements Consumer, Clos } } - private void renderPoint(FeatureCollector.Feature feature, Coordinate... coords) { + private void renderPoint(FeatureCollector.Feature feature, Coordinate... origCoords) { long id = idGenerator.incrementAndGet(); boolean hasLabelGrid = feature.hasLabelGrid(); + Coordinate[] coords = new Coordinate[origCoords.length]; + for (int i = 0; i < origCoords.length; i++) { + coords[i] = origCoords[i].copy(); + } for (int zoom = feature.getMaxZoom(); zoom >= feature.getMinZoom(); zoom--) { Map attrs = feature.getAttrsAtZoom(zoom); double buffer = feature.getBufferPixelsAtZoom(zoom) / 256; int tilesAtZoom = 1 << zoom; + // scale coordinates for this zoom + for (int i = 0; i < coords.length; i++) { + var orig = origCoords[i]; + coords[i].setX(orig.x * tilesAtZoom); + coords[i].setY(orig.y * tilesAtZoom); + } + // for "label grid" point density limiting, compute the grid square that this point sits in // only valid if not a multipoint @@ -115,9 +126,9 @@ public class FeatureRenderer implements Consumer, Clos // compute the tile coordinate of every tile these points should show up in at the given buffer size TileExtents.ForZoom extents = config.bounds().tileExtents().getForZoom(zoom); - TiledGeometry tiled = TiledGeometry.slicePointsIntoTiles(extents, buffer, zoom, coords, feature.getSourceId()); + TiledGeometry tiled = TiledGeometry.slicePointsIntoTiles(extents, buffer, zoom, coords); int emitted = 0; - for (var entry : tiled.getTileData()) { + for (var entry : tiled.getTileData().entrySet()) { TileCoord tile = entry.getKey(); List> result = entry.getValue(); Geometry geom = GeometryCoordinateSequences.reassemblePoints(result); @@ -186,7 +197,7 @@ public class FeatureRenderer implements Consumer, Clos List> groups = GeometryCoordinateSequences.extractGroups(geom, minSize); double buffer = feature.getBufferPixelsAtZoom(z) / 256; TileExtents.ForZoom extents = config.bounds().tileExtents().getForZoom(z); - TiledGeometry sliced = TiledGeometry.sliceIntoTiles(groups, buffer, area, z, extents, feature.getSourceId()); + TiledGeometry sliced = TiledGeometry.sliceIntoTiles(groups, buffer, area, z, extents); Map attrs = feature.getAttrsAtZoom(sliced.zoomLevel()); if (numPointsAttr != null) { // if profile wants the original number of points that the simplified but untiled geometry started with @@ -202,7 +213,7 @@ public class FeatureRenderer implements Consumer, Clos private void writeTileFeatures(int zoom, long id, FeatureCollector.Feature feature, TiledGeometry sliced, Map attrs) { int emitted = 0; - for (var entry : sliced.getTileData()) { + for (var entry : sliced.getTileData().entrySet()) { TileCoord tile = entry.getKey(); try { List> geoms = entry.getValue(); 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 22675822..c9958685 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 @@ -26,20 +26,33 @@ import com.onthegomap.planetiler.geo.GeoUtils; import com.onthegomap.planetiler.geo.MutableCoordinateSequence; import com.onthegomap.planetiler.geo.TileCoord; import com.onthegomap.planetiler.geo.TileExtents; +import com.onthegomap.planetiler.geo.TilePredicate; +import com.onthegomap.planetiler.util.Format; import java.util.ArrayList; import java.util.Collections; import java.util.EnumSet; import java.util.HashMap; +import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.TreeSet; +import java.util.stream.Stream; import javax.annotation.concurrent.NotThreadSafe; import org.locationtech.jts.geom.Coordinate; import org.locationtech.jts.geom.CoordinateSequence; import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.GeometryCollection; +import org.locationtech.jts.geom.LineString; +import org.locationtech.jts.geom.Lineal; +import org.locationtech.jts.geom.MultiLineString; +import org.locationtech.jts.geom.MultiPoint; +import org.locationtech.jts.geom.MultiPolygon; +import org.locationtech.jts.geom.Point; +import org.locationtech.jts.geom.Polygon; +import org.locationtech.jts.geom.Polygonal; +import org.locationtech.jts.geom.Puntal; import org.locationtech.jts.geom.impl.PackedCoordinateSequence; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import org.roaringbitmap.RoaringBitmap; /** * Splits geometries represented by lists of {@link CoordinateSequence CoordinateSequences} into the geometries that @@ -48,28 +61,27 @@ import org.slf4j.LoggerFactory; * {@link GeometryCoordinateSequences} converts between JTS {@link Geometry} instances and {@link CoordinateSequence} * lists for this utility. *

- * This class is adapted from the stripe clipping algorithm in https://github.com/mapbox/geojson-vt/ and modified so - * that it eagerly produces all sliced tiles at a zoom level for each input geometry. + * This class is adapted from the stripe clipping algorithm in + * geojson-vt and modified so that it eagerly produces all sliced + * tiles at a zoom level for each input geometry. */ @NotThreadSafe -class TiledGeometry { +public class TiledGeometry { - private static final Logger LOGGER = LoggerFactory.getLogger(TiledGeometry.class); + private static final Format FORMAT = Format.defaultInstance(); private static final double NEIGHBOR_BUFFER_EPS = 0.1d / 4096; - private final long featureId; private final Map>> tileContents = new HashMap<>(); - /** Map from X coordinate to range of Y coordinates that contain filled tiles inside this geometry */ - private Map filledRanges = null; private final TileExtents.ForZoom extents; private final double buffer; private final double neighborBuffer; private final int z; private final boolean area; private final int maxTilesAtThisZoom; + /** Map from X coordinate to range of Y coordinates that contain filled tiles inside this geometry */ + private Map filledRanges = null; - private TiledGeometry(TileExtents.ForZoom extents, double buffer, int z, boolean area, long featureId) { - this.featureId = featureId; + private TiledGeometry(TileExtents.ForZoom extents, double buffer, int z, boolean area) { this.extents = extents; this.buffer = buffer; // make sure we inspect neighboring tiles when a line runs along an edge @@ -87,12 +99,11 @@ class TiledGeometry { * @param z zoom level * @param coords the world web mercator coordinates of each point to emit at this zoom level where (0,0) is the * northwest and (2^z,2^z) is the southeast corner of the planet - * @param id feature ID * @return each tile this feature touches, and the points that appear on each */ - public static TiledGeometry slicePointsIntoTiles(TileExtents.ForZoom extents, double buffer, int z, - Coordinate[] coords, long id) { - TiledGeometry result = new TiledGeometry(extents, buffer, z, false, id); + static TiledGeometry slicePointsIntoTiles(TileExtents.ForZoom extents, double buffer, int z, + Coordinate[] coords) { + TiledGeometry result = new TiledGeometry(extents, buffer, z, false); for (Coordinate coord : coords) { result.slicePoint(coord); } @@ -107,35 +118,69 @@ class TiledGeometry { return value; } - private void slicePoint(Coordinate coord) { - double worldX = coord.getX() * maxTilesAtThisZoom; - double worldY = coord.getY() * maxTilesAtThisZoom; - int minX = (int) Math.floor(worldX - neighborBuffer); - int maxX = (int) Math.floor(worldX + neighborBuffer); - int minY = Math.max(extents.minY(), (int) Math.floor(worldY - neighborBuffer)); - int maxY = Math.min(extents.maxY() - 1, (int) Math.floor(worldY + neighborBuffer)); - for (int x = minX; x <= maxX; x++) { - double tileX = worldX - x; - int wrappedX = wrapInt(x, maxTilesAtThisZoom); - // point may end up inside bounds after wrapping - if (extents.testX(wrappedX)) { - for (int y = minY; y <= maxY; y++) { - TileCoord tile = TileCoord.ofXYZ(wrappedX, y, z); - double tileY = worldY - y; - tileContents.computeIfAbsent(tile, t -> List.of(new ArrayList<>())) - .get(0) - .add(GeoUtils.coordinateSequence(tileX * 256, tileY * 256)); - } - } + /** + * Returns all the points that appear on tiles representing points at {@code coords}. + * + * @param scaledGeom the scaled geometry to slice into tiles, in world web mercator coordinates where (0,0) is the + * northwest and (1,1) is the southeast corner of the planet + * @param minSize the minimum length of a line or area of a polygon to emit + * @param buffer how far detail should be included beyond the edge of each tile (0=none, 1=a full tile width) + * @param z zoom level + * @param extents range of tile coordinates within the bounds of the map to generate + * @return each tile this feature touches, and the points that appear on each + */ + public static TiledGeometry sliceIntoTiles(Geometry scaledGeom, double minSize, double buffer, int z, + TileExtents.ForZoom extents) { + + if (scaledGeom.isEmpty()) { + // ignore + return new TiledGeometry(extents, buffer, z, false); + } else if (scaledGeom instanceof Point point) { + return slicePointsIntoTiles(extents, buffer, z, point.getCoordinates()); + } else if (scaledGeom instanceof MultiPoint points) { + return slicePointsIntoTiles(extents, buffer, z, points.getCoordinates()); + } else if (scaledGeom instanceof Polygon || scaledGeom instanceof MultiPolygon || + scaledGeom instanceof LineString || + scaledGeom instanceof MultiLineString) { + var coordinateSequences = GeometryCoordinateSequences.extractGroups(scaledGeom, minSize); + boolean area = scaledGeom instanceof Polygonal; + return sliceIntoTiles(coordinateSequences, buffer, area, z, extents); + } else { + throw new UnsupportedOperationException( + "Unsupported JTS geometry type " + scaledGeom.getClass().getSimpleName() + " " + + scaledGeom.getGeometryType()); } } - public int zoomLevel() { - return z; + /** + * Returns the set of tiles that {@code scaledGeom} touches at a zoom level. + * + * @param scaledGeom The geometry in scaled web mercator coordinates where northwest is (0,0) and southeast is + * (2^z,2^z) + * @param zoom The zoom level + * @param extents The tile extents for this zoom level. + * @return A {@link CoveredTiles} instance for the tiles that are covered by this geometry. + */ + public static CoveredTiles getCoveredTiles(Geometry scaledGeom, int zoom, TileExtents.ForZoom extents) { + if (scaledGeom.isEmpty()) { + return new CoveredTiles(new RoaringBitmap(), zoom); + } else if (scaledGeom instanceof Puntal || scaledGeom instanceof Polygonal || scaledGeom instanceof Lineal) { + return sliceIntoTiles(scaledGeom, 0, 0, zoom, extents).getCoveredTiles(); + } else if (scaledGeom instanceof GeometryCollection gc) { + CoveredTiles result = new CoveredTiles(new RoaringBitmap(), zoom); + for (int i = 0; i < gc.getNumGeometries(); i++) { + result = CoveredTiles.merge(getCoveredTiles(gc.getGeometryN(i), zoom, extents), result); + } + return result; + } else { + throw new UnsupportedOperationException( + "Unsupported JTS geometry type " + scaledGeom.getClass().getSimpleName() + " " + + scaledGeom.getGeometryType()); + } } /** - * Returns all the points that appear on tiles representing points at {@code coords}. + * Returns the tiles that this geometry touches, and the contents of those tiles for this geometry. * * @param groups the list of linestrings or polygon rings extracted using {@link GeometryCoordinateSequences} in * world web mercator coordinates where (0,0) is the northwest and (2^z,2^z) is the southeast corner of @@ -144,12 +189,11 @@ class TiledGeometry { * @param area {@code true} if this is a polygon {@code false} if this is a linestring * @param z zoom level * @param extents range of tile coordinates within the bounds of the map to generate - * @param id feature ID * @return each tile this feature touches, and the points that appear on each */ - public static TiledGeometry sliceIntoTiles(List> groups, double buffer, boolean area, int z, - TileExtents.ForZoom extents, long id) { - TiledGeometry result = new TiledGeometry(extents, buffer, z, area, id); + static TiledGeometry sliceIntoTiles(List> groups, double buffer, boolean area, int z, + TileExtents.ForZoom extents) { + TiledGeometry result = new TiledGeometry(extents, buffer, z, area); EnumSet wrapResult = result.sliceWorldCopy(groups, 0); if (wrapResult.contains(Direction.RIGHT)) { result.sliceWorldCopy(groups, -result.maxTilesAtThisZoom); @@ -160,32 +204,6 @@ class TiledGeometry { return result; } - /** - * Returns an iterator over the coordinates of every tile that is completely filled within this polygon at this zoom - * level, ordered by x ascending, y ascending. - */ - public Iterable getFilledTiles() { - return filledRanges == null ? Collections.emptyList() : - () -> filledRanges.entrySet().stream() - .mapMulti((entry, next) -> { - int x = entry.getKey(); - for (int y : entry.getValue()) { - TileCoord coord = TileCoord.ofXYZ(x, y, z); - if (!tileContents.containsKey(coord)) { - next.accept(coord); - } - } - }).iterator(); - } - - /** - * Returns every tile that this geometry touches, and the partial geometry contained on that tile that can be - * reassembled using {@link GeometryCoordinateSequences}. - */ - public Iterable>>> getTileData() { - return tileContents.entrySet(); - } - private static int wrapX(int x, int max) { x %= max; if (x < 0) { @@ -220,6 +238,79 @@ class TiledGeometry { }, 2, 0); } + private void slicePoint(Coordinate coord) { + double worldX = coord.getX(); + double worldY = coord.getY(); + int minX = (int) Math.floor(worldX - neighborBuffer); + int maxX = (int) Math.floor(worldX + neighborBuffer); + int minY = Math.max(extents.minY(), (int) Math.floor(worldY - neighborBuffer)); + int maxY = Math.min(extents.maxY() - 1, (int) Math.floor(worldY + neighborBuffer)); + for (int x = minX; x <= maxX; x++) { + double tileX = worldX - x; + int wrappedX = wrapInt(x, maxTilesAtThisZoom); + // point may end up inside bounds after wrapping + if (extents.testX(wrappedX)) { + for (int y = minY; y <= maxY; y++) { + if (extents.test(wrappedX, y)) { + TileCoord tile = TileCoord.ofXYZ(wrappedX, y, z); + double tileY = worldY - y; + tileContents.computeIfAbsent(tile, t -> List.of(new ArrayList<>())) + .get(0) + .add(GeoUtils.coordinateSequence(tileX * 256, tileY * 256)); + } + } + } + } + } + + public int zoomLevel() { + return z; + } + + /** + * Returns an iterator over the coordinates of every tile that is completely filled within this polygon at this zoom + * level, ordered by x ascending, y ascending. + */ + public Iterable getFilledTiles() { + return filledRanges == null ? Collections.emptyList() : + () -> filledRanges.entrySet().stream() + .mapMulti((entry, next) -> { + int x = entry.getKey(); + for (int y : entry.getValue()) { + if (extents.test(x, y)) { + TileCoord coord = TileCoord.ofXYZ(x, y, z); + if (!tileContents.containsKey(coord)) { + next.accept(coord); + } + } + } + }).iterator(); + } + + /** Returns the tiles touched by this geometry. */ + public CoveredTiles getCoveredTiles() { + RoaringBitmap bitmap = new RoaringBitmap(); + for (TileCoord coord : tileContents.keySet()) { + bitmap.add(maxTilesAtThisZoom * coord.x() + coord.y()); + } + if (filledRanges != null) { + for (var entry : filledRanges.entrySet()) { + long colStart = (long) entry.getKey() * maxTilesAtThisZoom; + var yRanges = entry.getValue(); + bitmap.or(RoaringBitmap.addOffset(yRanges.bitmap(), colStart)); + } + } + return new CoveredTiles(bitmap, z); + } + + /** + * Returns every tile that this geometry touches, and the partial geometry contained on that tile that can be + * reassembled using {@link GeometryCoordinateSequences}. + */ + public Map>> getTileData() { + return tileContents; + } + /** * Slices a geometry into tiles and stores in member fields for a single "copy" of the world. *

@@ -251,9 +342,6 @@ class TiledGeometry { * | | | | | | */ IntObjectMap> xSlices = sliceX(segment); - if (z >= 6 && xSlices.size() >= Math.pow(2, z) - 1) { - LOGGER.warn("Feature " + featureId + " crosses world at z" + z + ": " + xSlices.size()); - } for (IntObjectCursor> xCursor : xSlices) { int x = xCursor.key + xOffset; // skip processing content past the edge of the world, but return that we saw it @@ -299,7 +387,7 @@ class TiledGeometry { List outSeqs = inSeqs.stream() .filter(seq -> seq.size() >= minPoints) .toList(); - if (!outSeqs.isEmpty()) { + if (!outSeqs.isEmpty() && extents.test(tileID.x(), tileID.y())) { tileContents.computeIfAbsent(tileID, tile -> new ArrayList<>()).add(outSeqs); } } @@ -448,7 +536,6 @@ class TiledGeometry { boolean onLeftEdge = area && ax == bx && ax == leftEdge && by < ay; for (int y = startY; y <= endY; y++) { - // skip over filled tiles until we get to the next tile that already has detail on it if (area && y > endStartY && y < startEndY) { if (onRightEdge || onLeftEdge) { @@ -609,4 +696,51 @@ class TiledGeometry { RIGHT, LEFT } + + /** + * A set of tiles touched by a geometry. + */ + public static class CoveredTiles implements TilePredicate, Iterable { + private final RoaringBitmap bitmap; + private final int maxTilesAtZoom; + private final int z; + + private CoveredTiles(RoaringBitmap bitmap, int z) { + this.bitmap = bitmap; + this.maxTilesAtZoom = 1 << z; + this.z = z; + } + + /** + * Returns the union of tiles covered by {@code a} and {@code b}. + * + * @throws IllegalArgumentException if {@code a} and {@code b} have different zoom levels. + */ + public static CoveredTiles merge(CoveredTiles a, CoveredTiles b) { + if (a.z != b.z) { + throw new IllegalArgumentException("Cannot combine CoveredTiles with different zoom levels "); + } + return new CoveredTiles(RoaringBitmap.or(a.bitmap, b.bitmap), a.z); + } + + @Override + public boolean test(int x, int y) { + return bitmap.contains(x * maxTilesAtZoom + y); + } + + @Override + public String toString() { + return "CoveredTiles{z=" + z + ", tiles=" + FORMAT.integer(bitmap.getCardinality()) + ", storage=" + + FORMAT.storage(bitmap.getSizeInBytes()) + "B}"; + } + + public Stream stream() { + return bitmap.stream().mapToObj(i -> TileCoord.ofXYZ(i / maxTilesAtZoom, i % maxTilesAtZoom, z)); + } + + @Override + public Iterator iterator() { + return stream().iterator(); + } + } } 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 43a4b40e..f781ba84 100644 --- a/planetiler-core/src/test/java/com/onthegomap/planetiler/PlanetilerTests.java +++ b/planetiler-core/src/test/java/com/onthegomap/planetiler/PlanetilerTests.java @@ -1791,4 +1791,54 @@ class PlanetilerTests { assertTrue(renderMaxzoomResult.tiles.containsKey(z8Tile)); assertFalse(maxzoomResult.tiles.containsKey(z8Tile)); } + + private PlanetilerResults runForBoundsTest(int minzoom, int maxzoom, String key, String value) throws Exception { + return runWithReaderFeatures( + Map.of("threads", "1", key, value), + List.of( + newReaderFeature(WORLD_POLYGON, Map.of()) + ), + (in, features) -> features.polygon("layer") + .setZoomRange(minzoom, maxzoom) + .setBufferPixels(0) + ); + } + + @Test + void testBoundFilters() throws Exception { + var origResult = runForBoundsTest(0, 2, "", ""); + var bboxResult = runForBoundsTest(0, 2, "bounds", "1,-85.05113,180,-1"); + var polyResult = runForBoundsTest(0, 2, "polygon", TestUtils.pathToResource("bottomrightearth.poly").toString()); + + assertEquals(1 + 4 + 16, origResult.tiles.size()); + assertEquals(Set.of( + TileCoord.ofXYZ(0, 0, 0), + TileCoord.ofXYZ(1, 1, 1), + TileCoord.ofXYZ(2, 2, 2), + TileCoord.ofXYZ(3, 2, 2), + TileCoord.ofXYZ(2, 3, 2), + TileCoord.ofXYZ(3, 3, 2) + ), bboxResult.tiles.keySet()); + assertEquals(Set.of( + TileCoord.ofXYZ(0, 0, 0), + TileCoord.ofXYZ(1, 1, 1), + // TileCoord.ofXYZ(2, 2, 2), - omit since this one is outside of triangle + TileCoord.ofXYZ(3, 2, 2), + TileCoord.ofXYZ(2, 3, 2), + TileCoord.ofXYZ(3, 3, 2) + ), polyResult.tiles.keySet()); + + // but besides the omitted tile, the rest should be the same + bboxResult.tiles.remove(TileCoord.ofXYZ(2, 2, 2)); + assertEquals(bboxResult.tiles, polyResult.tiles); + } + + @Test + void testBoundFiltersFill() throws Exception { + var polyResultz8 = runForBoundsTest(8, 8, "polygon", TestUtils.pathToResource("bottomrightearth.poly").toString()); + + int z8tiles = 1 << 8; + assertFalse(polyResultz8.tiles.containsKey(TileCoord.ofXYZ(z8tiles * 3 / 4, z8tiles * 5 / 8, 8))); + assertTrue(polyResultz8.tiles.containsKey(TileCoord.ofXYZ(z8tiles * 3 / 4, z8tiles * 7 / 8, 8))); + } } diff --git a/planetiler-core/src/test/java/com/onthegomap/planetiler/geo/TileExtentsTest.java b/planetiler-core/src/test/java/com/onthegomap/planetiler/geo/TileExtentsTest.java index 389a9bf7..96117f06 100644 --- a/planetiler-core/src/test/java/com/onthegomap/planetiler/geo/TileExtentsTest.java +++ b/planetiler-core/src/test/java/com/onthegomap/planetiler/geo/TileExtentsTest.java @@ -1,7 +1,10 @@ package com.onthegomap.planetiler.geo; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import com.onthegomap.planetiler.TestUtils; import org.junit.jupiter.api.Test; import org.locationtech.jts.geom.Envelope; @@ -56,4 +59,29 @@ class TileExtentsTest { assertEquals(1 << z, extents.getForZoom(z).maxY(), "z" + z + " maxY"); } } + + @Test + void testShape() { + var size = Math.pow(2, -14); + var shape = GeoUtils.worldToLatLonCoords(TestUtils.newPolygon( + 0.5, 0.5 - size * 5, + 0.5 + size * 5, 0.5, + 0.5, 0.5 + size * 5, + 0.5 - size * 5, 0.5, + 0.5, 0.5 - size * 5 + )); + TileExtents extents = TileExtents + .computeFromWorldBounds(14, new Envelope(0.5 - size * 4, 0.5 + size * 4, 0.5 - size * 4, 0.5 + size * 4), + shape); + for (int z = 0; z <= 14; z++) { + int middle = (1 << z) / 2; + assertTrue(extents.test(middle, middle, z), "z" + z); + } + // inside shape and bounds + assertTrue(extents.test((1 << 13) + 3, (1 << 13), 14)); + // inside shape but outside bounds + assertFalse(extents.test((1 << 13) + 4, (1 << 13), 14)); + // inside bounds, outside shape + assertFalse(extents.test((1 << 13) + 3, (1 << 13) + 3, 14)); + } } diff --git a/planetiler-core/src/test/java/com/onthegomap/planetiler/reader/osm/PolyFileReaderTest.java b/planetiler-core/src/test/java/com/onthegomap/planetiler/reader/osm/PolyFileReaderTest.java new file mode 100644 index 00000000..454832b0 --- /dev/null +++ b/planetiler-core/src/test/java/com/onthegomap/planetiler/reader/osm/PolyFileReaderTest.java @@ -0,0 +1,169 @@ +package com.onthegomap.planetiler.reader.osm; + +import static com.onthegomap.planetiler.reader.osm.PolyFileReader.parsePolyFile; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.onthegomap.planetiler.reader.FileFormatException; +import java.io.IOException; +import org.junit.jupiter.api.Test; + +class PolyFileReaderTest { + + @Test + void testParseAustralia() throws Exception { + var poly = parsePolyFile(""" + australia_v + first_area + 0.1446693E+03 -0.3826255E+02 + 0.1446627E+03 -0.3825661E+02 + 0.1446763E+03 -0.3824465E+02 + 0.1446813E+03 -0.3824343E+02 + 0.1446824E+03 -0.3824484E+02 + 0.1446826E+03 -0.3825356E+02 + 0.1446876E+03 -0.3825210E+02 + 0.1446919E+03 -0.3824719E+02 + 0.1447006E+03 -0.3824723E+02 + 0.1447042E+03 -0.3825078E+02 + 0.1446758E+03 -0.3826229E+02 + 0.1446693E+03 -0.3826255E+02 + END + second_area + 0.1422436E+03 -0.3839315E+02 + 0.1422496E+03 -0.3839070E+02 + 0.1422543E+03 -0.3839025E+02 + 0.1422574E+03 -0.3839155E+02 + 0.1422467E+03 -0.3840065E+02 + 0.1422433E+03 -0.3840048E+02 + 0.1422420E+03 -0.3839857E+02 + 0.1422436E+03 -0.3839315E+02 + END + END + """); + assertEquals(2, poly.getNumGeometries()); + assertEquals(4.60252e-4, poly.getArea(), 1e-10); + } + + @Test + void testParseAustraliaOceana() throws IOException { + var poly = parsePolyFile(""" + australia-oceania + 1 + -107.863281 11.780702 + -104.171875 -28.082042 + -179.999999 -45.652740 + -179.999999 4.082818 + -107.863281 11.780702 + END + 0 + 89.512500 -11.143360 + 61.663780 -9.177713 + 44.655470 -57.087780 + 180.000000 -57.164820 + 180.000000 26.277810 + 141.547997 22.628320 + 130.145100 3.640314 + 129.953200 -0.535293 + 131.061600 -3.784815 + 130.266900 -10.043780 + 118.255700 -13.011650 + 102.800900 -8.390453 + 89.512500 -11.143360 + END + END + """); + assertEquals(2, poly.getNumGeometries()); + assertEquals(10876.51613, poly.getArea(), 1e-4); + } + + @Test + void testParseInvalid() throws IOException { + assertThrows(FileFormatException.class, () -> parsePolyFile(""" + name + section + 1 2 + 3 4 + 5 6 + 7 8 + """)); + assertThrows(FileFormatException.class, () -> parsePolyFile(""" + name + section + 1 2 + 3 4 + 5 6 + 7 8 + END + """)); + parsePolyFile(""" + name + section + 1 2 + 3 4 + 5 6 + 7 8 + END + END + """); + parsePolyFile(""" + + + name + + section + + 1 2 + 3 4 + 5 6 + 7 8 + + END + + END + + + """); + assertThrows(FileFormatException.class, () -> parsePolyFile(""" + name + section + 1 2 + 3 4 + 5 6 + 7 8 + END + END + name + section + 1 2 + 3 4 + 5 6 + 7 8 + END + END + """)); + } + + @Test + void testParseHole() throws IOException { + var poly = parsePolyFile(""" + poly + outer + 0 0 + 0 10 + 10 10 + 10 0 + 0 0 + END + !inner + 1 1 + 1 9 + 9 9 + 9 1 + 1 1 + END + END + """); + assertEquals(1, poly.getNumGeometries()); + assertEquals(10 * 10 - 8 * 8, poly.getArea(), 1e-4); + } +} diff --git a/planetiler-core/src/test/java/com/onthegomap/planetiler/render/FeatureRendererTest.java b/planetiler-core/src/test/java/com/onthegomap/planetiler/render/FeatureRendererTest.java index d40639fe..7833404d 100644 --- a/planetiler-core/src/test/java/com/onthegomap/planetiler/render/FeatureRendererTest.java +++ b/planetiler-core/src/test/java/com/onthegomap/planetiler/render/FeatureRendererTest.java @@ -137,6 +137,20 @@ class FeatureRendererTest { ), renderGeometry(feature)); } + @Test + void testEmitPointsRespectShape() { + config = PlanetilerConfig.from(Arguments.of( + "polygon", TestUtils.pathToResource("bottomrightearth.poly") + )); + var feature = pointFeature(newPoint(0.5 + 1d / 512, 0.5 + 1d / 512)) + .setZoomRange(0, 2) + .setBufferPixels(2); + assertSameNormalizedFeatures(Map.of( + TileCoord.ofXYZ(0, 0, 0), List.of(newPoint(128.5, 128.5)), + TileCoord.ofXYZ(1, 1, 1), List.of(newPoint(1, 1)) + ), renderGeometry(feature)); + } + @TestFactory List testProcessPointsNearInternationalDateLineAndPoles() { double d = 1d / 512; diff --git a/planetiler-core/src/test/java/com/onthegomap/planetiler/render/TiledGeometryTest.java b/planetiler-core/src/test/java/com/onthegomap/planetiler/render/TiledGeometryTest.java new file mode 100644 index 00000000..be026f22 --- /dev/null +++ b/planetiler-core/src/test/java/com/onthegomap/planetiler/render/TiledGeometryTest.java @@ -0,0 +1,149 @@ +package com.onthegomap.planetiler.render; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.onthegomap.planetiler.TestUtils; +import com.onthegomap.planetiler.geo.TileCoord; +import com.onthegomap.planetiler.geo.TileExtents; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Test; + +class TiledGeometryTest { + private static final int Z14_TILES = 1 << 14; + + @Test + void testPoint() { + var tiledGeom = TiledGeometry.getCoveredTiles(TestUtils.newPoint(0.5, 0.5), 14, + new TileExtents.ForZoom(14, 0, 0, Z14_TILES, Z14_TILES, null)); + assertTrue(tiledGeom.test(0, 0)); + assertFalse(tiledGeom.test(0, 1)); + assertFalse(tiledGeom.test(1, 0)); + assertFalse(tiledGeom.test(1, 1)); + assertEquals(Set.of(TileCoord.ofXYZ(0, 0, 14)), tiledGeom.stream().collect(Collectors.toSet())); + + tiledGeom = TiledGeometry.getCoveredTiles(TestUtils.newPoint(Z14_TILES - 0.5, Z14_TILES - 0.5), 14, + new TileExtents.ForZoom(14, 0, 0, Z14_TILES, Z14_TILES, null)); + assertTrue(tiledGeom.test(Z14_TILES - 1, Z14_TILES - 1)); + assertFalse(tiledGeom.test(Z14_TILES - 2, Z14_TILES - 1)); + assertFalse(tiledGeom.test(Z14_TILES - 1, Z14_TILES - 2)); + assertFalse(tiledGeom.test(Z14_TILES - 2, Z14_TILES - 2)); + assertEquals(Set.of(TileCoord.ofXYZ(Z14_TILES - 1, Z14_TILES - 1, 14)), + tiledGeom.stream().collect(Collectors.toSet())); + } + + @Test + void testMultiPoint() { + var tiledGeom = TiledGeometry.getCoveredTiles(TestUtils.newMultiPoint( + TestUtils.newPoint(0.5, 0.5), + TestUtils.newPoint(2.5, 1.5) + ), 14, + new TileExtents.ForZoom(14, 0, 0, Z14_TILES, Z14_TILES, null)); + assertEquals(Set.of( + TileCoord.ofXYZ(0, 0, 14), + TileCoord.ofXYZ(2, 1, 14) + ), tiledGeom.stream().collect(Collectors.toSet())); + } + + @Test + void testLine() { + var tiledGeom = TiledGeometry.getCoveredTiles(TestUtils.newLineString( + 0.5, 0.5, + 1.5, 0.5 + ), 14, new TileExtents.ForZoom(14, 0, 0, Z14_TILES, Z14_TILES, null)); + assertEquals(Set.of( + TileCoord.ofXYZ(0, 0, 14), + TileCoord.ofXYZ(1, 0, 14) + ), tiledGeom.stream().collect(Collectors.toSet())); + } + + @Test + void testMultiLine() { + var tiledGeom = TiledGeometry.getCoveredTiles(TestUtils.newMultiLineString( + TestUtils.newLineString( + 0.5, 0.5, + 1.5, 0.5 + ), + TestUtils.newLineString( + 3.5, 1.5, + 4.5, 1.5 + ) + ), 14, new TileExtents.ForZoom(14, 0, 0, Z14_TILES, Z14_TILES, null)); + assertEquals(Set.of( + TileCoord.ofXYZ(0, 0, 14), + TileCoord.ofXYZ(1, 0, 14), + TileCoord.ofXYZ(3, 1, 14), + TileCoord.ofXYZ(4, 1, 14) + ), tiledGeom.stream().collect(Collectors.toSet())); + } + + @Test + void testPolygon() { + var tiledGeom = + TiledGeometry.getCoveredTiles(TestUtils.newPolygon( + TestUtils.rectangleCoordList(25.5, 27.5), + List.of(TestUtils.rectangleCoordList(25.9, 27.1)) + ), 14, new TileExtents.ForZoom(14, 0, 0, Z14_TILES, Z14_TILES, null)); + assertEquals(Set.of( + TileCoord.ofXYZ(25, 25, 14), + TileCoord.ofXYZ(26, 25, 14), + TileCoord.ofXYZ(27, 25, 14), + + TileCoord.ofXYZ(25, 26, 14), + // TileCoord.ofXYZ(26, 26, 14), skipped because of hole! + TileCoord.ofXYZ(27, 26, 14), + + TileCoord.ofXYZ(25, 27, 14), + TileCoord.ofXYZ(26, 27, 14), + TileCoord.ofXYZ(27, 27, 14) + ), tiledGeom.stream().collect(Collectors.toSet())); + } + + @Test + void testMultiPolygon() { + var tiledGeom = TiledGeometry.getCoveredTiles(TestUtils.newMultiPolygon( + TestUtils.rectangle(25.5, 26.5), + TestUtils.rectangle(30.1, 30.9) + ), 14, + new TileExtents.ForZoom(14, 0, 0, Z14_TILES, Z14_TILES, null)); + assertEquals(Set.of( + TileCoord.ofXYZ(25, 25, 14), + TileCoord.ofXYZ(25, 26, 14), + TileCoord.ofXYZ(26, 25, 14), + TileCoord.ofXYZ(26, 26, 14), + + TileCoord.ofXYZ(30, 30, 14) + ), tiledGeom.stream().collect(Collectors.toSet())); + } + + @Test + void testEmpty() { + var tiledGeom = TiledGeometry.getCoveredTiles(TestUtils.newGeometryCollection(), 14, + new TileExtents.ForZoom(14, 0, 0, Z14_TILES, Z14_TILES, null)); + assertEquals(Set.of(), tiledGeom.stream().collect(Collectors.toSet())); + } + + @Test + void testGeometryCollection() { + var tiledGeom = TiledGeometry.getCoveredTiles(TestUtils.newGeometryCollection( + TestUtils.rectangle(0.1, 0.9), + TestUtils.newPoint(1.5, 1.5), + TestUtils.newGeometryCollection(TestUtils.newLineString(3.5, 10.5, 4.5, 10.5)) + ), 14, + new TileExtents.ForZoom(14, 0, 0, Z14_TILES, Z14_TILES, null)); + assertEquals(Set.of( + // rectangle + TileCoord.ofXYZ(0, 0, 14), + + // point + TileCoord.ofXYZ(1, 1, 14), + + // linestring + TileCoord.ofXYZ(3, 10, 14), + TileCoord.ofXYZ(4, 10, 14) + ), tiledGeom.stream().collect(Collectors.toSet())); + } +} diff --git a/planetiler-core/src/test/resources/bottomrightearth.poly b/planetiler-core/src/test/resources/bottomrightearth.poly new file mode 100644 index 00000000..d35b3381 --- /dev/null +++ b/planetiler-core/src/test/resources/bottomrightearth.poly @@ -0,0 +1,8 @@ +bottom-right +1 + 180 -1 + 180 -85 + 1 -85 + 180 -1 +END +END