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