From c219c93f933e2c858db0003c2b8ae5be40f73b8b Mon Sep 17 00:00:00 2001 From: Mike Barry Date: Sat, 22 May 2021 07:07:34 -0400 Subject: [PATCH] polygon tests --- .../com/onthegomap/flatmap/TileExtents.java | 4 +- .../onthegomap/flatmap/VectorTileEncoder.java | 1 + .../MutableCoordinateSequence.java | 5 +- .../com/onthegomap/flatmap/geo/GeoUtils.java | 14 +- .../render/CoordinateSequenceExtractor.java | 83 +++-- .../flatmap/render/FeatureRenderer.java | 49 +-- .../flatmap/render/TiledGeometry.java | 75 +++- .../com/onthegomap/flatmap/FlatMapTest.java | 196 +++++++++- .../com/onthegomap/flatmap/TestUtils.java | 106 ++++++ .../onthegomap/flatmap/TileExtentsTest.java | 2 +- .../flatmap/render/FeatureRendererTest.java | 352 +++++++++++++++++- 11 files changed, 784 insertions(+), 103 deletions(-) diff --git a/src/main/java/com/onthegomap/flatmap/TileExtents.java b/src/main/java/com/onthegomap/flatmap/TileExtents.java index 8af1e477..0133c72e 100644 --- a/src/main/java/com/onthegomap/flatmap/TileExtents.java +++ b/src/main/java/com/onthegomap/flatmap/TileExtents.java @@ -54,11 +54,11 @@ public class TileExtents implements Predicate { } public boolean testX(int x) { - return x >= minX && x <= maxX; + return x >= minX && x < maxX; } public boolean testY(int y) { - return y >= minY && y <= maxY; + return y >= minY && y < maxY; } } } diff --git a/src/main/java/com/onthegomap/flatmap/VectorTileEncoder.java b/src/main/java/com/onthegomap/flatmap/VectorTileEncoder.java index a1886b8e..1221381e 100644 --- a/src/main/java/com/onthegomap/flatmap/VectorTileEncoder.java +++ b/src/main/java/com/onthegomap/flatmap/VectorTileEncoder.java @@ -196,6 +196,7 @@ public class VectorTileEncoder { if (first) { first = false; outerCCW = ccw; + assert outerCCW; } if (ccw == outerCCW) { ringsForCurrentPolygon = new ArrayList<>(); diff --git a/src/main/java/com/onthegomap/flatmap/collections/MutableCoordinateSequence.java b/src/main/java/com/onthegomap/flatmap/collections/MutableCoordinateSequence.java index a65bdb97..3d1c2dfa 100644 --- a/src/main/java/com/onthegomap/flatmap/collections/MutableCoordinateSequence.java +++ b/src/main/java/com/onthegomap/flatmap/collections/MutableCoordinateSequence.java @@ -51,7 +51,10 @@ public class MutableCoordinateSequence extends PackedCoordinateSequence { @Override public Envelope expandEnvelope(Envelope env) { - return null; + for (int i = 0; i < points.size(); i += dimension) { + env.expandToInclude(points.get(i), points.get(i + 1)); + } + return env; } public void addPoint(double x, double y) { diff --git a/src/main/java/com/onthegomap/flatmap/geo/GeoUtils.java b/src/main/java/com/onthegomap/flatmap/geo/GeoUtils.java index 6e0049eb..4ddd5ea4 100644 --- a/src/main/java/com/onthegomap/flatmap/geo/GeoUtils.java +++ b/src/main/java/com/onthegomap/flatmap/geo/GeoUtils.java @@ -23,6 +23,7 @@ public class GeoUtils { private static final LineString[] EMPTY_LINE_STRING_ARRAY = new LineString[0]; private static final Coordinate[] EMPTY_COORD_ARRAY = new Coordinate[0]; + private static final Point[] EMPTY_POINT_ARRAY = new Point[0]; private static final double WORLD_RADIUS_METERS = 6_378_137; private static final double WORLD_CIRCUMFERENCE_METERS = Math.PI * 2 * WORLD_RADIUS_METERS; @@ -191,11 +192,6 @@ public class GeoUtils { return JTS_FACTORY.createMultiPointFromCoords(coords.toArray(EMPTY_COORD_ARRAY)); } - public static Geometry multiPoint(double... coords) { - assert coords.length % 2 == 0; - return JTS_FACTORY.createMultiPoint(new PackedCoordinateSequence.Double(coords, 2, 0)); - } - public static Geometry createMultiLineString(List lineStrings) { return JTS_FACTORY.createMultiLineString(lineStrings.toArray(EMPTY_LINE_STRING_ARRAY)); } @@ -230,4 +226,12 @@ public class GeoUtils { (int) Math.floor((coord.getY() * tilesAtZoom) / labelGridTileSize) ); } + + public static CoordinateSequence coordinateSequence(double... coords) { + return new PackedCoordinateSequence.Double(coords, 2, 0); + } + + public static Geometry createMultiPoint(List points) { + return JTS_FACTORY.createMultiPoint(points.toArray(EMPTY_POINT_ARRAY)); + } } diff --git a/src/main/java/com/onthegomap/flatmap/render/CoordinateSequenceExtractor.java b/src/main/java/com/onthegomap/flatmap/render/CoordinateSequenceExtractor.java index 073a6ff3..a7c4d1cc 100644 --- a/src/main/java/com/onthegomap/flatmap/render/CoordinateSequenceExtractor.java +++ b/src/main/java/com/onthegomap/flatmap/render/CoordinateSequenceExtractor.java @@ -1,8 +1,6 @@ package com.onthegomap.flatmap.render; -import com.onthegomap.flatmap.FeatureCollector; import com.onthegomap.flatmap.geo.GeoUtils; -import com.onthegomap.flatmap.geo.TileCoord; import java.util.ArrayList; import java.util.List; import org.jetbrains.annotations.NotNull; @@ -13,6 +11,7 @@ import org.locationtech.jts.geom.Geometry; import org.locationtech.jts.geom.GeometryCollection; import org.locationtech.jts.geom.LineString; import org.locationtech.jts.geom.LinearRing; +import org.locationtech.jts.geom.Point; import org.locationtech.jts.geom.Polygon; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -21,39 +20,6 @@ class CoordinateSequenceExtractor { private static final Logger LOGGER = LoggerFactory.getLogger(CoordinateSequenceExtractor.class); - static Geometry reassembleLineString(List> geoms) { - Geometry geom; - List lineStrings = new ArrayList<>(); - for (List inner : geoms) { - for (CoordinateSequence coordinateSequence : inner) { - lineStrings.add(GeoUtils.JTS_FACTORY.createLineString(coordinateSequence)); - } - } - geom = GeoUtils.createMultiLineString(lineStrings); - return geom; - } - - @NotNull - static Geometry reassemblePolygon(FeatureCollector.Feature feature, TileCoord tile, - List> geoms) { - Geometry geom; - int numGeoms = geoms.size(); - Polygon[] polygons = new Polygon[numGeoms]; - for (int i = 0; i < numGeoms; i++) { - List group = geoms.get(i); - LinearRing first = GeoUtils.JTS_FACTORY.createLinearRing(group.get(0)); - LinearRing[] rest = new LinearRing[group.size() - 1]; - for (int j = 1; j < group.size(); j++) { - CoordinateSequence seq = group.get(j); - CoordinateSequences.reverse(seq); - rest[j - 1] = GeoUtils.JTS_FACTORY.createLinearRing(seq); - } - polygons[i] = GeoUtils.JTS_FACTORY.createPolygon(first, rest); - } - geom = GeoUtils.JTS_FACTORY.createMultiPolygon(polygons); - return geom; - } - static List> extractGroups(Geometry geom, double minSize) { List> result = new ArrayList<>(); extractGroups(geom, result, minSize); @@ -102,4 +68,51 @@ class CoordinateSequenceExtractor { } } } + + static Geometry reassembleLineStrings(List> geoms) { + List lineStrings = new ArrayList<>(); + for (List inner : geoms) { + for (CoordinateSequence coordinateSequence : inner) { + lineStrings.add(GeoUtils.JTS_FACTORY.createLineString(coordinateSequence)); + } + } + return lineStrings.size() == 1 ? lineStrings.get(0) : GeoUtils.createMultiLineString(lineStrings); + } + + @NotNull + static Geometry reassemblePolygons(List> groups) { + int numGeoms = groups.size(); + if (numGeoms == 1) { + return reassemblePolygon(groups.get(0)); + } else { + Polygon[] polygons = new Polygon[numGeoms]; + for (int i = 0; i < numGeoms; i++) { + polygons[i] = reassemblePolygon(groups.get(i)); + } + return GeoUtils.JTS_FACTORY.createMultiPolygon(polygons); + } + } + + private static Polygon reassemblePolygon(List group) { + LinearRing first = GeoUtils.JTS_FACTORY.createLinearRing(group.get(0)); + LinearRing[] rest = new LinearRing[group.size() - 1]; + for (int j = 1; j < group.size(); j++) { + CoordinateSequence seq = group.get(j); + CoordinateSequences.reverse(seq); + rest[j - 1] = GeoUtils.JTS_FACTORY.createLinearRing(seq); + } + return GeoUtils.JTS_FACTORY.createPolygon(first, rest); + } + + static Geometry reassemblePoints(List> result) { + List points = new ArrayList<>(); + for (List inner : result) { + for (CoordinateSequence coordinateSequence : inner) { + if (coordinateSequence.size() == 1) { + points.add(GeoUtils.JTS_FACTORY.createPoint(coordinateSequence)); + } + } + } + return points.size() == 1 ? points.get(0) : GeoUtils.createMultiPoint(points); + } } diff --git a/src/main/java/com/onthegomap/flatmap/render/FeatureRenderer.java b/src/main/java/com/onthegomap/flatmap/render/FeatureRenderer.java index a5daac9b..6e516257 100644 --- a/src/main/java/com/onthegomap/flatmap/render/FeatureRenderer.java +++ b/src/main/java/com/onthegomap/flatmap/render/FeatureRenderer.java @@ -8,7 +8,6 @@ import com.onthegomap.flatmap.geo.GeoUtils; import com.onthegomap.flatmap.geo.GeometryException; import com.onthegomap.flatmap.geo.TileCoord; import java.util.HashMap; -import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Optional; @@ -17,7 +16,6 @@ import java.util.concurrent.atomic.AtomicLong; import java.util.function.Consumer; import org.locationtech.jts.geom.Coordinate; import org.locationtech.jts.geom.CoordinateSequence; -import org.locationtech.jts.geom.CoordinateXY; import org.locationtech.jts.geom.Geometry; import org.locationtech.jts.geom.GeometryCollection; import org.locationtech.jts.geom.LineString; @@ -57,14 +55,6 @@ public class FeatureRenderer { this.consumer = consumer; } - private static int wrapInt(int value, int max) { - value %= max; - if (value < 0) { - value += max; - } - return value; - } - public void renderFeature(FeatureCollector.Feature feature) { renderGeometry(feature.getGeometry(), feature); } @@ -90,27 +80,6 @@ public class FeatureRenderer { } } - private void slicePoint(Map> output, int zoom, double buffer, Coordinate coord) { - // TODO put this into TiledGeometry - int tilesAtZoom = 1 << zoom; - double worldX = coord.getX() * tilesAtZoom; - double worldY = coord.getY() * tilesAtZoom; - int minX = (int) Math.floor(worldX - buffer); - int maxX = (int) Math.floor(worldX + buffer); - int minY = Math.max(0, (int) Math.floor(worldY - buffer)); - int maxY = Math.min(tilesAtZoom - 1, (int) Math.floor(worldY + buffer)); - for (int x = minX; x <= maxX; x++) { - double tileX = worldX - x; - for (int y = minY; y <= maxY; y++) { - TileCoord tile = TileCoord.ofXYZ(wrapInt(x, tilesAtZoom), y, zoom); - double tileY = worldY - y; - Coordinate outCoordinate = new CoordinateXY(tileX * 256, tileY * 256); - tilePrecision.makePrecise(outCoordinate); - output.computeIfAbsent(tile, t -> new HashSet<>()).add(outCoordinate); - } - } - } - private void addPointFeature(FeatureCollector.Feature feature, Coordinate... coords) { long id = idGen.incrementAndGet(); boolean hasLabelGrid = feature.hasLabelGrid(); @@ -119,10 +88,8 @@ public class FeatureRenderer { Map attrs = feature.getAttrsAtZoom(zoom); double buffer = feature.getBufferPixelsAtZoom(zoom) / 256; int tilesAtZoom = 1 << zoom; - for (Coordinate coord : coords) { - // TODO TiledGeometry.sliceIntoTiles(...) - slicePoint(sliced, zoom, buffer, coord); - } + TileExtents.ForZoom extents = config.extents().getForZoom(zoom); + TiledGeometry tiled = TiledGeometry.slicePointsIntoTiles(extents, buffer, zoom, coords); RenderedFeature.Group groupInfo = null; if (hasLabelGrid && coords.length == 1) { @@ -133,10 +100,10 @@ public class FeatureRenderer { ); } - for (var entry : sliced.entrySet()) { + for (var entry : tiled.getTileData()) { TileCoord tile = entry.getKey(); - Set value = entry.getValue(); - Geometry geom = value.size() == 1 ? GeoUtils.point(value.iterator().next()) : GeoUtils.multiPoint(value); + List> result = entry.getValue(); + Geometry geom = CoordinateSequenceExtractor.reassemblePoints(result); // TODO stats // TODO writeTileFeatures emitFeature(feature, id, attrs, tile, geom, groupInfo); @@ -197,7 +164,7 @@ public class FeatureRenderer { geom = simplifier.getResultGeometry(); List> groups = CoordinateSequenceExtractor.extractGroups(geom, minSize); - double buffer = feature.getBufferPixelsAtZoom(z); + double buffer = feature.getBufferPixelsAtZoom(z) / 256; TileExtents.ForZoom extents = config.extents().getForZoom(z); TiledGeometry sliced = TiledGeometry.sliceIntoTiles(groups, buffer, area, z, extents); writeTileFeatures(id, feature, sliced); @@ -213,10 +180,10 @@ public class FeatureRenderer { Geometry geom; if (feature.area()) { - geom = CoordinateSequenceExtractor.reassemblePolygon(feature, tile, geoms); + geom = CoordinateSequenceExtractor.reassemblePolygons(geoms); geom = GeoUtils.fixPolygon(geom, 2); } else { - geom = CoordinateSequenceExtractor.reassembleLineString(geoms); + geom = CoordinateSequenceExtractor.reassembleLineStrings(geoms); } try { diff --git a/src/main/java/com/onthegomap/flatmap/render/TiledGeometry.java b/src/main/java/com/onthegomap/flatmap/render/TiledGeometry.java index ea3c4707..95862485 100644 --- a/src/main/java/com/onthegomap/flatmap/render/TiledGeometry.java +++ b/src/main/java/com/onthegomap/flatmap/render/TiledGeometry.java @@ -21,13 +21,16 @@ import com.graphhopper.coll.GHIntObjectHashMap; import com.onthegomap.flatmap.TileExtents; import com.onthegomap.flatmap.collections.IntRange; import com.onthegomap.flatmap.collections.MutableCoordinateSequence; +import com.onthegomap.flatmap.geo.GeoUtils; import com.onthegomap.flatmap.geo.TileCoord; import java.util.ArrayList; +import java.util.Collections; import java.util.EnumSet; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.TreeSet; +import org.locationtech.jts.geom.Coordinate; import org.locationtech.jts.geom.CoordinateSequence; import org.locationtech.jts.geom.impl.PackedCoordinateSequence; import org.slf4j.Logger; @@ -40,11 +43,13 @@ import org.slf4j.LoggerFactory; class TiledGeometry { private static final Logger LOGGER = LoggerFactory.getLogger(TiledGeometry.class); + private static final double NEIGHBOR_BUFFER_EPS = 0.1d / 4096; private final Map>> tileContents = new HashMap<>(); - private final Map filledRanges = new HashMap<>(); + private Map filledRanges = null; private final TileExtents.ForZoom extents; private final double buffer; + private final double neighborBuffer; private final int z; private final boolean area; private final int max; @@ -52,11 +57,51 @@ class TiledGeometry { 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 + this.neighborBuffer = buffer + NEIGHBOR_BUFFER_EPS; this.z = z; this.area = area; this.max = 1 << z; } + public 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); + } + return result; + } + + private static int wrapInt(int value, int max) { + value %= max; + if (value < 0) { + value += max; + } + return value; + } + + private void slicePoint(Coordinate coord) { + double worldX = coord.getX() * max; + double worldY = coord.getY() * max; + 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, max); + if (extents.testX(wrappedX)) { + for (int y = minY; y <= maxY; y++) { + TileCoord tile = TileCoord.ofXYZ(wrappedX, y, z); + double tileY = worldY - y; + List points = tileContents.computeIfAbsent(tile, t -> List.of(new ArrayList<>())).get(0); + points.add(GeoUtils.coordinateSequence(tileX * 256, tileY * 256)); + } + } + } + } + public int zoomLevel() { return z; } @@ -76,7 +121,7 @@ class TiledGeometry { } public Iterable getFilledTiles() { - return () -> filledRanges.entrySet().stream() + return filledRanges == null ? Collections.emptyList() : () -> filledRanges.entrySet().stream() .mapMulti((entry, next) -> { int x = entry.getKey(); for (int y : entry.getValue()) { @@ -194,8 +239,8 @@ class TiledGeometry { double minX = Math.min(_ax, _bx); double maxX = Math.max(_ax, _bx); - int startX = (int) Math.floor(minX - buffer); - int endX = (int) Math.floor(maxX + buffer); + int startX = (int) Math.floor(minX - neighborBuffer); + int endX = (int) Math.floor(maxX + neighborBuffer); for (int x = startX; x <= endX; x++) { double ax = _ax - x; @@ -244,8 +289,8 @@ class TiledGeometry { // add the last point double _ax = segment.getX(segment.size() - 1); double ay = segment.getY(segment.size() - 1); - int startX = (int) Math.floor(_ax - buffer); - int endX = (int) Math.floor(_ax + buffer); + int startX = (int) Math.floor(_ax - neighborBuffer); + int endX = (int) Math.floor(_ax + neighborBuffer); for (int x = startX - 1; x <= endX + 1; x++) { double ax = _ax - x; @@ -297,10 +342,10 @@ class TiledGeometry { int extentMinY = extents.minY(); int extentMaxY = extents.maxY(); - int startY = Math.max(extentMinY, (int) Math.floor(minY - buffer)); - int endStartY = Math.max(extentMinY, (int) Math.floor(minY + buffer)); - int startEndY = Math.min(extentMaxY - 1, (int) Math.floor(maxY - buffer)); - int endY = Math.min(extentMaxY - 1, (int) Math.floor(maxY + buffer)); + int startY = Math.max(extentMinY, (int) Math.floor(minY - neighborBuffer)); + int endStartY = Math.max(extentMinY, (int) Math.floor(minY + neighborBuffer)); + int startEndY = Math.min(extentMaxY - 1, (int) Math.floor(maxY - neighborBuffer)); + int endY = Math.min(extentMaxY - 1, (int) Math.floor(maxY + neighborBuffer)); boolean onRightEdge = area && ax == bx && ax == rightEdge && by > ay; boolean onLeftEdge = area && ax == bx && ax == leftEdge && by < ay; @@ -409,8 +454,8 @@ class TiledGeometry { int last = stripeSegment.size() - 1; double ax = stripeSegment.getX(last); double ay = stripeSegment.getY(last); - int startY = (int) (ay - buffer); - int endY = (int) (ay + buffer); + int startY = (int) Math.floor(ay - neighborBuffer); + int endY = (int) Math.floor(ay + neighborBuffer); for (int y = startY - 1; y <= endY + 1; y++) { MutableCoordinateSequence slice = ySlices.get(y); @@ -433,6 +478,9 @@ class TiledGeometry { if (yRange == null) { return; } + if (filledRanges == null) { + filledRanges = new HashMap<>(); + } IntRange existing = filledRanges.get(x); if (existing == null) { filledRanges.put(x, yRange); @@ -445,6 +493,9 @@ class TiledGeometry { if (yRange == null) { return; } + if (filledRanges == null) { + filledRanges = new HashMap<>(); + } IntRange existing = filledRanges.get(x); if (existing != null) { existing.removeAll(yRange); diff --git a/src/test/java/com/onthegomap/flatmap/FlatMapTest.java b/src/test/java/com/onthegomap/flatmap/FlatMapTest.java index 57c6ef27..a438a97a 100644 --- a/src/test/java/com/onthegomap/flatmap/FlatMapTest.java +++ b/src/test/java/com/onthegomap/flatmap/FlatMapTest.java @@ -3,9 +3,23 @@ package com.onthegomap.flatmap; import static com.onthegomap.flatmap.TestUtils.assertSameJson; import static com.onthegomap.flatmap.TestUtils.assertSubmap; import static com.onthegomap.flatmap.TestUtils.feature; +import static com.onthegomap.flatmap.TestUtils.newCoordinateList; import static com.onthegomap.flatmap.TestUtils.newLineString; +import static com.onthegomap.flatmap.TestUtils.newMultiLineString; import static com.onthegomap.flatmap.TestUtils.newMultiPoint; import static com.onthegomap.flatmap.TestUtils.newPoint; +import static com.onthegomap.flatmap.TestUtils.newPolygon; +import static com.onthegomap.flatmap.TestUtils.rectangle; +import static com.onthegomap.flatmap.TestUtils.rectangleCoordList; +import static com.onthegomap.flatmap.TestUtils.tileBottom; +import static com.onthegomap.flatmap.TestUtils.tileBottomLeft; +import static com.onthegomap.flatmap.TestUtils.tileBottomRight; +import static com.onthegomap.flatmap.TestUtils.tileFill; +import static com.onthegomap.flatmap.TestUtils.tileLeft; +import static com.onthegomap.flatmap.TestUtils.tileRight; +import static com.onthegomap.flatmap.TestUtils.tileTop; +import static com.onthegomap.flatmap.TestUtils.tileTopLeft; +import static com.onthegomap.flatmap.TestUtils.tileTopRight; import static org.junit.jupiter.api.Assertions.assertEquals; import com.graphhopper.reader.ReaderRelation; @@ -29,6 +43,7 @@ import java.util.Map; import java.util.function.BiConsumer; import java.util.function.Function; import org.junit.jupiter.api.Test; +import org.locationtech.jts.geom.Coordinate; /** * In-memory tests with fake data and profiles to ensure all features work end-to-end. @@ -43,6 +58,8 @@ public class FlatMapTest { private static final double Z14_WIDTH = 1d / Z14_TILES; private static final int Z13_TILES = 1 << 13; private static final double Z13_WIDTH = 1d / Z13_TILES; + private static final int Z12_TILES = 1 << 12; + private static final double Z12_WIDTH = 1d / Z12_TILES; private final Stats stats = new Stats.InMemory(); private void processReaderFeatures(FeatureGroup featureGroup, Profile profile, CommonParams config, @@ -83,7 +100,11 @@ public class FlatMapTest { featureGroup.sorter().sort(); try (Mbtiles db = Mbtiles.newInMemoryDatabase()) { MbtilesWriter.writeOutput(featureGroup, db, () -> 0L, profile, config, stats); - return new FlatMapResults(TestUtils.getTileMap(db), db.metadata().getAll()); + var tileMap = TestUtils.getTileMap(db); + tileMap.values().forEach(fs -> { + fs.forEach(f -> f.geometry().validate()); + }); + return new FlatMapResults(tileMap, db.metadata().getAll()); } } @@ -296,6 +317,179 @@ public class FlatMapTest { ), results.tiles); } + @Test + public void testMultiLineString() throws IOException, SQLException { + double x1 = 0.5 + Z14_WIDTH / 2; + double y1 = 0.5 + Z14_WIDTH / 2; + double x2 = x1 + Z14_WIDTH; + double y2 = y1 + Z14_WIDTH; + double lat1 = GeoUtils.getWorldLat(y1); + double lng1 = GeoUtils.getWorldLon(x1); + double lat2 = GeoUtils.getWorldLat(y2); + double lng2 = GeoUtils.getWorldLon(x2); + + var results = runWithReaderFeatures( + Map.of("threads", "1"), + List.of( + new ReaderFeature(newMultiLineString( + newLineString(lng1, lat1, lng2, lat2), + newLineString(lng2, lat2, lng1, lat1) + ), Map.of( + "attr", "value" + )) + ), + (in, features) -> { + features.line("layer") + .setZoomRange(13, 14) + .setBufferPixels(4); + } + ); + + assertSubmap(Map.of( + TileCoord.ofXYZ(Z14_TILES / 2, Z14_TILES / 2, 14), List.of( + feature(newMultiLineString( + newLineString(128, 128, 260, 260), + newLineString(260, 260, 128, 128) + ), Map.of()) + ), + TileCoord.ofXYZ(Z14_TILES / 2 + 1, Z14_TILES / 2 + 1, 14), List.of( + feature(newMultiLineString( + newLineString(-4, -4, 128, 128), + newLineString(128, 128, -4, -4) + ), Map.of()) + ), + TileCoord.ofXYZ(Z13_TILES / 2, Z13_TILES / 2, 13), List.of( + feature(newMultiLineString( + newLineString(64, 64, 192, 192), + newLineString(192, 192, 64, 64) + ), Map.of()) + ) + ), results.tiles); + } + + private List z14CoordinateList(double... coords) { + List points = newCoordinateList(coords); + points.forEach(c -> { + c.x = 0.5 + c.x * Z14_WIDTH; + c.y = 0.5 + c.y * Z14_WIDTH; + }); + return points; + } + + @Test + public void testPolygon() throws IOException, SQLException { + List outerPoints = z14CoordinateList( + 0.5, 0.5, + 3.5, 0.5, + 3.5, 2.5, + 0.5, 2.5, + 0.5, 0.5 + ); + List innerPoints = z14CoordinateList( + 1.25, 1.25, + 1.75, 1.25, + 1.75, 1.75, + 1.25, 1.75, + 1.25, 1.25 + ); + + var results = runWithReaderFeatures( + Map.of("threads", "1"), + List.of( + new ReaderFeature(newPolygon( + outerPoints, + List.of(innerPoints) + ), Map.of()) + ), + (in, features) -> { + features.line("layer") + .setZoomRange(12, 14) + .setBufferPixels(4); + } + ); + + assertSubmap(Map.ofEntries( + // Z14 - row 1 + newTileEntry(Z14_TILES / 2, Z14_TILES / 2, 14, List.of( + feature(tileBottomRight(4), Map.of()) + )), + newTileEntry(Z14_TILES / 2 + 1, Z14_TILES / 2, 14, List.of( + feature(tileBottom(4), Map.of()) + )), + newTileEntry(Z14_TILES / 2 + 2, Z14_TILES / 2, 14, List.of( + feature(tileBottom(4), Map.of()) + )), + newTileEntry(Z14_TILES / 2 + 3, Z14_TILES / 2, 14, List.of( + feature(tileBottomLeft(4), Map.of()) + )), + // Z14 - row 2 + newTileEntry(Z14_TILES / 2, Z14_TILES / 2 + 1, 14, List.of( + feature(tileRight(4), Map.of()) + )), + newTileEntry(Z14_TILES / 2 + 1, Z14_TILES / 2 + 1, 14, List.of( + feature(newPolygon( + tileFill(5), + List.of(newCoordinateList( + 64, 64, + 192, 64, + 192, 192, + 64, 192, + 64, 64 + )) + ), Map.of()) + )), + newTileEntry(Z14_TILES / 2 + 2, Z14_TILES / 2 + 1, 14, List.of( + feature(newPolygon(tileFill(5), List.of()), Map.of()) + )), + newTileEntry(Z14_TILES / 2 + 3, Z14_TILES / 2 + 1, 14, List.of( + feature(tileLeft(4), Map.of()) + )), + // Z14 - row 3 + newTileEntry(Z14_TILES / 2, Z14_TILES / 2 + 2, 14, List.of( + feature(tileTopRight(4), Map.of()) + )), + newTileEntry(Z14_TILES / 2 + 1, Z14_TILES / 2 + 2, 14, List.of( + feature(tileTop(4), Map.of()) + )), + newTileEntry(Z14_TILES / 2 + 2, Z14_TILES / 2 + 2, 14, List.of( + feature(tileTop(4), Map.of()) + )), + newTileEntry(Z14_TILES / 2 + 3, Z14_TILES / 2 + 2, 14, List.of( + feature(tileTopLeft(4), Map.of()) + )), + // Z13 + newTileEntry(Z13_TILES / 2, Z13_TILES / 2, 13, List.of( + feature(newPolygon( + rectangleCoordList(64, 256 + 4), + List.of(rectangleCoordList(128 + 64, 256 - 64)) // hole + ), Map.of()) + )), + newTileEntry(Z13_TILES / 2 + 1, Z13_TILES / 2, 13, List.of( + feature(rectangle(-4, 64, 256 - 64, 256 + 4), Map.of()) + )), + newTileEntry(Z13_TILES / 2, Z13_TILES / 2 + 1, 13, List.of( + feature(rectangle(64, -4, 256 + 4, 64), Map.of()) + )), + newTileEntry(Z13_TILES / 2 + 1, Z13_TILES / 2 + 1, 13, List.of( + feature(rectangle(-4, -4, 256 - 64, 64), Map.of()) + )), + // Z12 + newTileEntry(Z12_TILES / 2, Z12_TILES / 2, 12, List.of( + feature(newPolygon( + rectangleCoordList(32, 32, 256 - 32, 128 + 32), + List.of( + rectangleCoordList(64 + 32, 128 - 32) // hole + ) + ), Map.of()) + )) + ), results.tiles); + } + + private Map.Entry> newTileEntry(int x, int y, int z, + List features) { + return Map.entry(TileCoord.ofXYZ(x, y, z), features); + } + private interface LayerPostprocessFunction { List process(String layer, int zoom, List items); diff --git a/src/test/java/com/onthegomap/flatmap/TestUtils.java b/src/test/java/com/onthegomap/flatmap/TestUtils.java index 96696c99..70a05744 100644 --- a/src/test/java/com/onthegomap/flatmap/TestUtils.java +++ b/src/test/java/com/onthegomap/flatmap/TestUtils.java @@ -1,6 +1,9 @@ package com.onthegomap.flatmap; 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 static org.junit.jupiter.api.Assertions.fail; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; @@ -23,6 +26,7 @@ import java.util.TreeMap; import java.util.function.Function; import java.util.stream.Collectors; import java.util.zip.GZIPInputStream; +import org.locationtech.jts.algorithm.Orientation; import org.locationtech.jts.geom.Coordinate; import org.locationtech.jts.geom.CoordinateSequence; import org.locationtech.jts.geom.CoordinateXY; @@ -30,6 +34,8 @@ 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.LinearRing; +import org.locationtech.jts.geom.MultiLineString; import org.locationtech.jts.geom.MultiPoint; import org.locationtech.jts.geom.MultiPolygon; import org.locationtech.jts.geom.Point; @@ -55,10 +61,80 @@ public class TestUtils { return GeoUtils.JTS_FACTORY.createPolygon(newCoordinateList(coords).toArray(new Coordinate[0])); } + public static Polygon tileBottomRight(double buffer) { + return rectangle(128, 128, 256 + buffer, 256 + buffer); + } + + public static Polygon tileBottom(double buffer) { + return rectangle(-buffer, 128, 256 + buffer, 256 + buffer); + } + + public static Polygon tileBottomLeft(double buffer) { + return rectangle(-buffer, 128, 128, 256 + buffer); + } + + public static Polygon tileLeft(double buffer) { + return rectangle(-buffer, -buffer, 128, 256 + buffer); + } + + public static Polygon tileTopLeft(double buffer) { + return rectangle(-buffer, -buffer, 128, 128); + } + + public static Polygon tileTop(double buffer) { + return rectangle(-buffer, -buffer, 256 + buffer, 128); + } + + public static Polygon tileTopRight(double buffer) { + return rectangle(128, -buffer, 256 + buffer, 128); + } + + public static Polygon tileRight(double buffer) { + return rectangle(128, -buffer, 256 + buffer, 256 + buffer); + } + + public static List tileFill(double buffer) { + return rectangleCoordList(-buffer, 256 + buffer); + } + + public static List rectangleCoordList(double minX, double minY, double maxX, double maxY) { + return newCoordinateList( + minX, minY, + maxX, minY, + maxX, maxY, + minX, maxY, + minX, minY + ); + } + + public static List rectangleCoordList(double min, double max) { + return rectangleCoordList(min, min, max, max); + } + + public static Polygon rectangle(double minX, double minY, double maxX, double maxY) { + return newPolygon(rectangleCoordList(minX, minY, maxX, maxY), List.of()); + } + + public static Polygon rectangle(double min, double max) { + return rectangle(min, min, max, max); + } + + public static Polygon newPolygon(List outer, List> inner) { + return GeoUtils.JTS_FACTORY.createPolygon( + GeoUtils.JTS_FACTORY.createLinearRing(outer.toArray(new Coordinate[0])), + inner.stream().map(i -> GeoUtils.JTS_FACTORY.createLinearRing(i.toArray(new Coordinate[0]))) + .toArray(LinearRing[]::new) + ); + } + public static LineString newLineString(double... coords) { return GeoUtils.JTS_FACTORY.createLineString(newCoordinateList(coords).toArray(new Coordinate[0])); } + public static MultiLineString newMultiLineString(LineString... lineStrings) { + return GeoUtils.JTS_FACTORY.createMultiLineString(lineStrings); + } + public static Point newPoint(double x, double y) { return GeoUtils.JTS_FACTORY.createPoint(new CoordinateXY(x, y)); } @@ -158,9 +234,39 @@ public class TestUtils { return GeoUtils.JTS_FACTORY.createGeometryCollection(); } + public static void validateGeometry(Geometry g) { + if (g instanceof GeometryCollection gs) { + for (int i = 0; i < gs.getNumGeometries(); i++) { + validateGeometry(gs.getGeometryN(i)); + } + } else if (g instanceof Point point) { + assertFalse(point.isEmpty(), "empty: " + point); + } else if (g instanceof LineString line) { + assertTrue(line.getNumPoints() >= 2, "too few points: " + line); + } else if (g instanceof Polygon poly) { + var outer = poly.getExteriorRing(); + assertTrue(Orientation.isCCW(outer.getCoordinateSequence()), "outer not CCW: " + poly); + assertTrue(outer.getNumPoints() >= 4, "outer too few points: " + poly); + assertTrue(outer.isClosed(), "outer not closed: " + poly); + for (int i = 0; i < poly.getNumInteriorRing(); i++) { + var inner = poly.getInteriorRingN(i); + assertFalse(Orientation.isCCW(inner.getCoordinateSequence()), + "inner " + i + " not CW: " + poly); + assertTrue(outer.getNumPoints() >= 4, "inner " + i + " too few points: " + poly); + assertTrue(inner.isClosed(), "inner " + i + " not closed: " + poly); + } + } else { + fail("Unrecognized geometry: " + g); + } + } + public interface GeometryComparision { Geometry geom(); + + default void validate() { + validateGeometry(geom()); + } } private static record NormGeometry(Geometry geom) implements GeometryComparision { diff --git a/src/test/java/com/onthegomap/flatmap/TileExtentsTest.java b/src/test/java/com/onthegomap/flatmap/TileExtentsTest.java index 53ce9ec2..cc677628 100644 --- a/src/test/java/com/onthegomap/flatmap/TileExtentsTest.java +++ b/src/test/java/com/onthegomap/flatmap/TileExtentsTest.java @@ -47,7 +47,7 @@ public class TileExtentsTest { } @Test - public void bottomLeft() { + public void testBottomLeft() { TileExtents extents = TileExtents .computeFromWorldBounds(14, new Envelope(0, eps, 1 - eps, 1)); for (int z = 0; z <= 14; z++) { diff --git a/src/test/java/com/onthegomap/flatmap/render/FeatureRendererTest.java b/src/test/java/com/onthegomap/flatmap/render/FeatureRendererTest.java index 1b45a2ee..cf0d6a27 100644 --- a/src/test/java/com/onthegomap/flatmap/render/FeatureRendererTest.java +++ b/src/test/java/com/onthegomap/flatmap/render/FeatureRendererTest.java @@ -1,15 +1,21 @@ package com.onthegomap.flatmap.render; +import static com.onthegomap.flatmap.TestUtils.assertExactSameFeatures; import static com.onthegomap.flatmap.TestUtils.assertSameNormalizedFeatures; import static com.onthegomap.flatmap.TestUtils.emptyGeometry; import static com.onthegomap.flatmap.TestUtils.newLineString; +import static com.onthegomap.flatmap.TestUtils.newMultiLineString; import static com.onthegomap.flatmap.TestUtils.newMultiPoint; import static com.onthegomap.flatmap.TestUtils.newPoint; +import static com.onthegomap.flatmap.TestUtils.newPolygon; +import static com.onthegomap.flatmap.TestUtils.rectangle; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.DynamicTest.dynamicTest; +import com.onthegomap.flatmap.Arguments; import com.onthegomap.flatmap.CommonParams; import com.onthegomap.flatmap.FeatureCollector; +import com.onthegomap.flatmap.TestUtils; import com.onthegomap.flatmap.geo.GeoUtils; import com.onthegomap.flatmap.geo.TileCoord; import com.onthegomap.flatmap.read.ReaderFeature; @@ -23,11 +29,13 @@ import java.util.TreeMap; import org.junit.jupiter.api.DynamicTest; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestFactory; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import org.locationtech.jts.geom.Geometry; public class FeatureRendererTest { - private final CommonParams config = CommonParams.defaults(); + private CommonParams config = CommonParams.defaults(); private FeatureCollector collector(Geometry worldGeom) { var latLonGeom = GeoUtils.worldToLatLonCoords(worldGeom); @@ -38,6 +46,7 @@ public class FeatureRendererTest { Map> result = new TreeMap<>(); new FeatureRenderer(config, rendered -> result.computeIfAbsent(rendered.tile(), tile -> new HashSet<>()) .add(rendered.vectorTileFeature().geometry().decode())).renderFeature(feature); + result.values().forEach(gs -> gs.forEach(TestUtils::validateGeometry)); return result; } @@ -45,11 +54,16 @@ public class FeatureRendererTest { Map> result = new TreeMap<>(); new FeatureRenderer(config, rendered -> result.computeIfAbsent(rendered.tile(), tile -> new HashSet<>()) .add(rendered)).renderFeature(feature); + result.values() + .forEach(gs -> gs.forEach(f -> TestUtils.validateGeometry(f.vectorTileFeature().geometry().decode()))); return result; } private static final int Z14_TILES = 1 << 14; private static final double Z14_WIDTH = 1d / Z14_TILES; + private static final double Z14_PX = Z14_WIDTH / 256; + private static final int Z13_TILES = 1 << 13; + private static final double Z13_WIDTH = 1d / Z13_TILES; @Test public void testEmptyGeometry() { @@ -57,6 +71,10 @@ public class FeatureRendererTest { assertSameNormalizedFeatures(Map.of(), renderGeometry(feature)); } + /* + * POINT TESTS + */ + @Test public void testSinglePoint() { var feature = pointFeature(newPoint(0.5 + Z14_WIDTH / 2, 0.5 + Z14_WIDTH / 2)) @@ -82,6 +100,33 @@ public class FeatureRendererTest { ), renderGeometry(feature)); } + @Test + public void testRepeatSinglePointNeighboringTilesBuffer0() { + var feature = pointFeature(newPoint(0.5, 0.5)) + .setZoomRange(1, 1) + .setBufferPixels(0); + assertSameNormalizedFeatures(Map.of( + TileCoord.ofXYZ(0, 0, 1), List.of(newPoint(256, 256)), + TileCoord.ofXYZ(1, 0, 1), List.of(newPoint(0, 256)), + TileCoord.ofXYZ(0, 1, 1), List.of(newPoint(256, 0)), + TileCoord.ofXYZ(1, 1, 1), List.of(newPoint(0, 0)) + ), renderGeometry(feature)); + } + + @Test + public void testEmitPointsRespectExtents() { + config = CommonParams.from(Arguments.of( + "bounds", "0,-80,180,0" + )); + var feature = pointFeature(newPoint(0.5 + 1d / 512, 0.5 + 1d / 512)) + .setZoomRange(0, 1) + .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 public List testProcessPointsNearInternationalDateLineAndPoles() { double d = 1d / 512; @@ -221,6 +266,10 @@ public class FeatureRendererTest { return collector(geom).point("layer"); } + /* + * LINE TESTS + */ + private FeatureCollector.Feature lineFeature(Geometry geom) { return collector(geom).line("layer"); } @@ -234,17 +283,310 @@ public class FeatureRendererTest { )) .setZoomRange(14, 14) .setBufferPixels(8); + assertExactSameFeatures(Map.of( + TileCoord.ofXYZ(Z14_TILES / 2, Z14_TILES / 2, 14), List.of( + newLineString(64, 64, 192, 192) + ) + ), renderGeometry(feature)); + } + + @Test + public void testSimplifyLine() { + double z14hypot = Math.sqrt(Z14_WIDTH * Z14_WIDTH); + var feature = lineFeature(newLineString( + 0.5 + z14hypot / 4, 0.5 + z14hypot / 4, + 0.5 + z14hypot / 2, 0.5 + z14hypot / 2, + 0.5 + z14hypot * 3 / 4, 0.5 + z14hypot * 3 / 4 + )) + .setZoomRange(14, 14) + .setBufferPixels(8); + assertExactSameFeatures(Map.of( + TileCoord.ofXYZ(Z14_TILES / 2, Z14_TILES / 2, 14), List.of( + newLineString(64, 64, 192, 192) + ) + ), renderGeometry(feature)); + } + + @Test + public void testSplitLineFeatureTouchingNeighboringTile() { + double z14hypot = Math.sqrt(Z14_WIDTH * Z14_WIDTH); + var feature = lineFeature(newLineString( + 0.5 + z14hypot / 4, 0.5 + z14hypot / 4, + 0.5 + Z14_WIDTH * (256 - 8) / 256d, 0.5 + Z14_WIDTH * (256 - 8) / 256d + )) + .setZoomRange(14, 14) + .setBufferPixels(8); + assertExactSameFeatures(Map.of( + TileCoord.ofXYZ(Z14_TILES / 2, Z14_TILES / 2, 14), List.of( + newLineString(64, 64, 256 - 8, 256 - 8) + ) + // only a single point in neighboring tile, exclude + ), renderGeometry(feature)); + } + + @Test + public void testSplitLineFeatureEnteringNeighboringTileBoudary() { + double z14hypot = Math.sqrt(Z14_WIDTH * Z14_WIDTH); + var feature = lineFeature(newLineString( + 0.5 + z14hypot / 4, 0.5 + z14hypot / 4, + 0.5 + Z14_WIDTH * (256 - 7) / 256d, 0.5 + Z14_WIDTH * (256 - 7) / 256d + )) + .setZoomRange(14, 14) + .setBufferPixels(8); + assertExactSameFeatures(Map.of( + TileCoord.ofXYZ(Z14_TILES / 2, Z14_TILES / 2, 14), List.of( + newLineString(64, 64, 256 - 7, 256 - 7) + ), + TileCoord.ofXYZ(Z14_TILES / 2 + 1, Z14_TILES / 2, 14), List.of( + newLineString(-8, 248, -7, 249) + ), + TileCoord.ofXYZ(Z14_TILES / 2, Z14_TILES / 2 + 1, 14), List.of( + newLineString(248, -8, 249, -7) + ), + TileCoord.ofXYZ(Z14_TILES / 2 + 1, Z14_TILES / 2 + 1, 14), List.of( + newLineString(-8, -8, -7, -7) + ) + ), renderGeometry(feature)); + } + + @Test + public void test3PointLine() { + var feature = lineFeature(newLineString( + 0.5 + Z14_WIDTH / 2, 0.5 + Z14_WIDTH / 2, + 0.5 + 3 * Z14_WIDTH / 2, 0.5 + Z14_WIDTH / 2, + 0.5 + 3 * Z14_WIDTH / 2, 0.5 + 3 * Z14_WIDTH / 2 + )) + .setZoomRange(14, 14) + .setBufferPixels(8); + assertExactSameFeatures(Map.of( + TileCoord.ofXYZ(Z14_TILES / 2, Z14_TILES / 2, 14), List.of( + newLineString(128, 128, 256 + 8, 128) + ), + TileCoord.ofXYZ(Z14_TILES / 2 + 1, Z14_TILES / 2, 14), List.of( + newLineString(-8, 128, 128, 128, 128, 256 + 8) + ), + TileCoord.ofXYZ(Z14_TILES / 2 + 1, Z14_TILES / 2 + 1, 14), List.of( + newLineString(128, -8, 128, 128) + ) + ), renderGeometry(feature)); + } + + @Test + public void testLimitSingleLineStringLength() { + var eps = Z13_WIDTH / 4096; + var pixel = Z13_WIDTH / 256; + var featureBelow = lineFeature(newMultiLineString( + // below limit - ignore + newLineString(0.5, 0.5 + pixel, 0.5 + pixel - eps, 0.5 + pixel) + )) + .setMinPixelSize(1) + .setZoomRange(13, 13) + .setBufferPixels(0); + var featureAbove = lineFeature(newMultiLineString( + // above limit - allow + newLineString(0.5, 0.5 + pixel, 0.5 + pixel + eps, 0.5 + pixel) + )) + .setMinPixelSize(1) + .setZoomRange(13, 13) + .setBufferPixels(0); + assertExactSameFeatures(Map.of(), renderGeometry(featureBelow)); + assertExactSameFeatures(Map.of( + TileCoord.ofXYZ(Z13_TILES / 2, Z13_TILES / 2, 13), List.of( + newLineString(0, 1, 1 + 256d / 4096, 1) + ) + ), renderGeometry(featureAbove)); + } + + @Test + public void testLimitMultiLineStringLength() { + var eps = Z13_WIDTH / 4096; + var pixel = Z13_WIDTH / 256; + var feature = lineFeature(newMultiLineString( + // below limit - ignore + newLineString(0.5, 0.5 + pixel, 0.5 + pixel - eps, 0.5 + pixel), + // above limit - allow + newLineString(0.5, 0.5 + pixel, 0.5 + pixel + eps, 0.5 + pixel) + )) + .setMinPixelSize(1) + .setZoomRange(13, 13) + .setBufferPixels(0); + assertExactSameFeatures(Map.of( + TileCoord.ofXYZ(Z13_TILES / 2, Z13_TILES / 2, 13), List.of( + newLineString(0, 1, 1 + 256d / 4096, 1) + ) + ), renderGeometry(feature)); + } + + /* + * POLYGON TESTS + */ + private FeatureCollector.Feature polygonFeature(Geometry geom) { + return collector(geom).polygon("layer"); + } + + @Test + public void testSimpleTriangleCCW() { + var feature = polygonFeature( + newPolygon( + 0.5 + Z14_PX * 10, 0.5 + Z14_PX * 10, + 0.5 + Z14_PX * 20, 0.5 + Z14_PX * 10, + 0.5 + Z14_PX * 10, 0.5 + Z14_PX * 20, + 0.5 + Z14_PX * 10, 0.5 + Z14_PX * 10 + ) + ) + .setMinPixelSize(1) + .setZoomRange(14, 14) + .setBufferPixels(0); assertSameNormalizedFeatures(Map.of( TileCoord.ofXYZ(Z14_TILES / 2, Z14_TILES / 2, 14), List.of( - newPoint(64, 64), - newPoint(192, 192) + newPolygon( + 10, 10, + 20, 10, + 10, 20, + 10, 10 + ) + ) + ), renderGeometry(feature)); + } + + @Test + public void testSimpleTriangleCW() { + var feature = polygonFeature( + newPolygon( + 0.5 + Z14_PX * 10, 0.5 + Z14_PX * 10, + 0.5 + Z14_PX * 10, 0.5 + Z14_PX * 20, + 0.5 + Z14_PX * 20, 0.5 + Z14_PX * 10, + 0.5 + Z14_PX * 10, 0.5 + Z14_PX * 10 + ) + ) + .setMinPixelSize(1) + .setZoomRange(14, 14) + .setBufferPixels(0); + assertSameNormalizedFeatures(Map.of( + TileCoord.ofXYZ(Z14_TILES / 2, Z14_TILES / 2, 14), List.of( + newPolygon( + 10, 10, + 10, 20, + 20, 10, + 10, 10 + ) + ) + ), renderGeometry(feature)); + } + + @Test + public void testTriangleTouchingNeighboringTileDoesNotEmit() { + var feature = polygonFeature( + newPolygon( + 0.5 + Z14_PX * 10, 0.5 + Z14_PX * 10, + 0.5 + Z14_PX * 256, 0.5 + Z14_PX * 10, + 0.5 + Z14_PX * 10, 0.5 + Z14_PX * 20, + 0.5 + Z14_PX * 10, 0.5 + Z14_PX * 10 + ) + ) + .setMinPixelSize(1) + .setZoomRange(14, 14) + .setBufferPixels(0); + assertSameNormalizedFeatures(Map.of( + TileCoord.ofXYZ(Z14_TILES / 2, Z14_TILES / 2, 14), List.of( + newPolygon( + 10, 10, + 256, 10, + 10, 20, + 10, 10 + ) + ) + ), renderGeometry(feature)); + } + + @Test + public void testTriangleTouchingNeighboringTileBelowDoesNotEmit() { + var feature = polygonFeature( + newPolygon( + 0.5 + Z14_PX * 10, 0.5 + Z14_PX * 10, + 0.5 + Z14_PX * 20, 0.5 + Z14_PX * 10, + 0.5 + Z14_PX * 10, 0.5 + Z14_PX * 256, + 0.5 + Z14_PX * 10, 0.5 + Z14_PX * 10 + ) + ) + .setMinPixelSize(1) + .setZoomRange(14, 14) + .setBufferPixels(0); + assertSameNormalizedFeatures(Map.of( + TileCoord.ofXYZ(Z14_TILES / 2, Z14_TILES / 2, 14), List.of( + newPolygon( + 10, 10, + 20, 10, + 10, 256, + 10, 10 + ) + ) + ), renderGeometry(feature)); + } + + @ParameterizedTest + @CsvSource({ + "0,256, 0,256", // all + + "0,10, 0,10", // top-left + "5,10, 0,10", // top partial + "250,256, 0,10", // top all + "250,256, 0,10", // top-right + + "250,256, 0,256", // right all + "250,256, 10,250", // right partial + "250,256, 250,256", // right bottom + + "0,256, 250,256", // bottom all + "240,250, 250,256", // bottom partial + "0,10, 250,256", // bottom left + + "0,10, 0,256", // left all + "0,10, 240,250", // left partial + }) + public void testRectangleTouchingNeighboringTilesDoesNotEmit(int x1, int x2, int y1, int y2) { + var feature = polygonFeature( + rectangle( + 0.5 + Z14_PX * x1, + 0.5 + Z14_PX * y1, + 0.5 + Z14_PX * x2, + 0.5 + Z14_PX * y2 + ) + ) + .setMinPixelSize(1) + .setZoomRange(14, 14) + .setBufferPixels(0); + assertSameNormalizedFeatures(Map.of( + TileCoord.ofXYZ(Z14_TILES / 2, Z14_TILES / 2, 14), List.of( + rectangle(x1, y1, x2, y2) + ) + ), renderGeometry(feature)); + } + + @Test + public void testOverlapTileHorizontal() { + var feature = polygonFeature( + rectangle( + 0.5 + Z14_PX * 10, + 0.5 + Z14_PX * 10, + 0.5 + Z14_PX * 258, + 0.5 + Z14_PX * 20 + ) + ) + .setMinPixelSize(1) + .setZoomRange(14, 14) + .setBufferPixels(1); + assertSameNormalizedFeatures(Map.of( + TileCoord.ofXYZ(Z14_TILES / 2, Z14_TILES / 2, 14), List.of( + rectangle(10, 10, 257, 20) + ), + TileCoord.ofXYZ(Z14_TILES / 2 + 1, Z14_TILES / 2, 14), List.of( + rectangle(-1, 10, 2, 20) ) ), renderGeometry(feature)); } // TODO: centroid - // TODO: line - // TODO: multilinestring // TODO: poly // TODO: multipolygon // TODO: geometry collection