kopia lustrzana https://github.com/onthegomap/planetiler
				
				
				
			feat: `--polygon` argument to constrain mbtiles to a poly shape (#280)
							rodzic
							
								
									d1d68cf753
								
							
						
					
					
						commit
						7818634774
					
				|  | @ -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) | ||||
|  |  | |||
|  | @ -27,6 +27,11 @@ | |||
|       <artifactId>hppc</artifactId> | ||||
|       <version>0.9.1</version> | ||||
|     </dependency> | ||||
|     <dependency> | ||||
|       <groupId>org.roaringbitmap</groupId> | ||||
|       <artifactId>RoaringBitmap</artifactId> | ||||
|       <version>0.9.30</version> | ||||
|     </dependency> | ||||
|     <dependency> | ||||
|       <groupId>org.openstreetmap.osmosis</groupId> | ||||
|       <artifactId>osmosis-osm-binary</artifactId> | ||||
|  |  | |||
|  | @ -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. | ||||
|  * <p> | ||||
|  * This makes iterating through tile coordinates inside ocean polygons significantly faster. | ||||
|  */ | ||||
| @SuppressWarnings("UnstableApiUsage") | ||||
| @NotThreadSafe | ||||
| public class IntRangeSet implements Iterable<Integer> { | ||||
| 
 | ||||
|   private final TreeRangeSet<Integer> 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<Range<Integer>> rangeIter; | ||||
|     Range<Integer> range; | ||||
|     int cur; | ||||
|     boolean hasNext = true; | ||||
| 
 | ||||
|     private Iter(Iterator<Range<Integer>> 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(); | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  |  | |||
|  | @ -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); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|  |  | |||
|  | @ -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, | ||||
|  |  | |||
|  | @ -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) | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|  |  | |||
|  | @ -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(); | ||||
|  |  | |||
|  | @ -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<TileCoord> { | ||||
| 
 | ||||
|   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<TileCoord> { | |||
| 
 | ||||
|   /** 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<TileCoord> { | |||
| 
 | ||||
|   /** | ||||
|    * 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) { | ||||
|  |  | |||
|  | @ -0,0 +1,5 @@ | |||
| package com.onthegomap.planetiler.geo; | ||||
| 
 | ||||
| public interface TilePredicate { | ||||
|   boolean test(int x, int y); | ||||
| } | ||||
|  | @ -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 <a href="https://wiki.openstreetmap.org/wiki/Osmosis/Polygon_Filter_File_Format">Osmosis/Polygon Filter File | ||||
|  *      Format</a> | ||||
|  */ | ||||
| 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; | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | @ -94,13 +94,24 @@ public class FeatureRenderer implements Consumer<FeatureCollector.Feature>, 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<String, Object> 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<FeatureCollector.Feature>, 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<List<CoordinateSequence>> result = entry.getValue(); | ||||
|         Geometry geom = GeometryCoordinateSequences.reassemblePoints(result); | ||||
|  | @ -186,7 +197,7 @@ public class FeatureRenderer implements Consumer<FeatureCollector.Feature>, Clos | |||
|       List<List<CoordinateSequence>> 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<String, Object> 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<FeatureCollector.Feature>, Clos | |||
|   private void writeTileFeatures(int zoom, long id, FeatureCollector.Feature feature, TiledGeometry sliced, | ||||
|     Map<String, Object> attrs) { | ||||
|     int emitted = 0; | ||||
|     for (var entry : sliced.getTileData()) { | ||||
|     for (var entry : sliced.getTileData().entrySet()) { | ||||
|       TileCoord tile = entry.getKey(); | ||||
|       try { | ||||
|         List<List<CoordinateSequence>> geoms = entry.getValue(); | ||||
|  |  | |||
|  | @ -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. | ||||
|  * <p> | ||||
|  * 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 | ||||
|  * <a href="https://github.com/mapbox/geojson-vt/">geojson-vt</a> 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<TileCoord, List<List<CoordinateSequence>>> tileContents = new HashMap<>(); | ||||
|   /** Map from X coordinate to range of Y coordinates that contain filled tiles inside this geometry */ | ||||
|   private Map<Integer, IntRangeSet> 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<Integer, IntRangeSet> 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<List<CoordinateSequence>> 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<List<CoordinateSequence>> groups, double buffer, boolean area, int z, | ||||
|     TileExtents.ForZoom extents) { | ||||
|     TiledGeometry result = new TiledGeometry(extents, buffer, z, area); | ||||
|     EnumSet<Direction> 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<TileCoord> getFilledTiles() { | ||||
|     return filledRanges == null ? Collections.emptyList() : | ||||
|       () -> filledRanges.entrySet().stream() | ||||
|         .<TileCoord>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<Map.Entry<TileCoord, List<List<CoordinateSequence>>>> 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<TileCoord> getFilledTiles() { | ||||
|     return filledRanges == null ? Collections.emptyList() : | ||||
|       () -> filledRanges.entrySet().stream() | ||||
|         .<TileCoord>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<TileCoord, List<List<CoordinateSequence>>> getTileData() { | ||||
|     return tileContents; | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Slices a geometry into tiles and stores in member fields for a single "copy" of the world. | ||||
|    * <p> | ||||
|  | @ -251,9 +342,6 @@ class TiledGeometry { | |||
|          *  | | | | | | | ||||
|          */ | ||||
|         IntObjectMap<List<MutableCoordinateSequence>> 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<List<MutableCoordinateSequence>> 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<CoordinateSequence> 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<TileCoord> { | ||||
|     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<TileCoord> stream() { | ||||
|       return bitmap.stream().mapToObj(i -> TileCoord.ofXYZ(i / maxTilesAtZoom, i % maxTilesAtZoom, z)); | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|     public Iterator<TileCoord> iterator() { | ||||
|       return stream().iterator(); | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  |  | |||
|  | @ -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))); | ||||
|   } | ||||
| } | ||||
|  |  | |||
|  | @ -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)); | ||||
|   } | ||||
| } | ||||
|  |  | |||
|  | @ -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); | ||||
|   } | ||||
| } | ||||
|  | @ -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<DynamicTest> testProcessPointsNearInternationalDateLineAndPoles() { | ||||
|     double d = 1d / 512; | ||||
|  |  | |||
|  | @ -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())); | ||||
|   } | ||||
| } | ||||
|  | @ -0,0 +1,8 @@ | |||
| bottom-right | ||||
| 1 | ||||
|    180 -1 | ||||
|    180 -85 | ||||
|    1 -85 | ||||
|    180 -1 | ||||
| END | ||||
| END | ||||
		Ładowanie…
	
		Reference in New Issue
	
	 farfromrefuge
						farfromrefuge