From 2d4d6187bf0c493c08745af69d47a218675ac3be Mon Sep 17 00:00:00 2001 From: Mike Barry Date: Wed, 19 May 2021 06:44:28 -0400 Subject: [PATCH] first pass of line/are rendering --- .../onthegomap/flatmap/FeatureCollector.java | 35 ++- .../onthegomap/flatmap/FeatureRenderer.java | 253 ++++++++++++++---- .../com/onthegomap/flatmap/TiledGeometry.java | 31 ++- .../onthegomap/flatmap/VectorTileEncoder.java | 2 +- .../flatmap/collections/CacheByZoom.java | 30 +++ .../flatmap/collections/FeatureGroup.java | 47 +++- .../MutableCoordinateSequence.java | 16 +- .../com/onthegomap/flatmap/geo/GeoUtils.java | 21 +- .../com/onthegomap/flatmap/read/Reader.java | 3 +- .../com/onthegomap/flatmap/TestUtils.java | 14 +- .../flatmap/VectorTileEncoderTest.java | 26 +- .../MutableCoordinateSequenceTest.java | 18 +- .../flatmap/read/NaturalEarthReaderTest.java | 2 +- .../flatmap/read/ShapefileReaderTest.java | 2 +- 14 files changed, 366 insertions(+), 134 deletions(-) create mode 100644 src/main/java/com/onthegomap/flatmap/collections/CacheByZoom.java diff --git a/src/main/java/com/onthegomap/flatmap/FeatureCollector.java b/src/main/java/com/onthegomap/flatmap/FeatureCollector.java index 5221da63..07338230 100644 --- a/src/main/java/com/onthegomap/flatmap/FeatureCollector.java +++ b/src/main/java/com/onthegomap/flatmap/FeatureCollector.java @@ -1,5 +1,6 @@ package com.onthegomap.flatmap; +import com.onthegomap.flatmap.collections.CacheByZoom; import java.util.ArrayList; import java.util.Iterator; import java.util.List; @@ -24,19 +25,19 @@ public class FeatureCollector implements Iterable { } public Feature point(String layer) { - var feature = new Feature(layer, source.isPoint() ? source.worldGeometry() : source.centroid()); + var feature = new Feature(layer, source.isPoint() ? source.worldGeometry() : source.centroid(), false); output.add(feature); return feature; } public Feature line(String layername) { - var feature = new Feature(layername, source.line()); + var feature = new Feature(layername, source.line(), false); output.add(feature); return feature; } public Feature polygon(String layername) { - var feature = new Feature(layername, source.polygon()); + var feature = new Feature(layername, source.polygon(), true); output.add(feature); return feature; } @@ -50,6 +51,7 @@ public class FeatureCollector implements Iterable { public final class Feature { + private final boolean area; private static final double DEFAULT_LABEL_GRID_SIZE = 0; private static final int DEFAULT_LABEL_GRID_LIMIT = 0; private final String layer; @@ -64,11 +66,14 @@ public class FeatureCollector implements Iterable { private ZoomFunction minPixelSize = null; private ZoomFunction labelGridPixelSize = null; private ZoomFunction labelGridLimit = null; + private boolean attrsChangeByZoom = false; + private CacheByZoom> attrCache = null; - private Feature(String layer, Geometry geom) { + private Feature(String layer, Geometry geom, boolean area) { this.layer = layer; this.geom = geom; this.zOrder = 0; + this.area = area; } public int getZorder() { @@ -169,7 +174,7 @@ public class FeatureCollector implements Iterable { return labelGridPixelSize != null || labelGridLimit != null; } - public Map getAttrsAtZoom(int zoom) { + private Map computeAttrsAtZoom(int zoom) { Map result = new TreeMap<>(); for (var entry : attrs.entrySet()) { Object value = entry.getValue(); @@ -183,18 +188,34 @@ public class FeatureCollector implements Iterable { return result; } + public Map getAttrsAtZoom(int zoom) { + if (!attrsChangeByZoom) { + return attrs; + } + if (attrCache == null) { + attrCache = CacheByZoom.create(config, this::computeAttrsAtZoom); + } + return attrCache.get(zoom); + } + public Feature inheritFromSource(String attr) { return setAttr(attr, source.getTag(attr)); } public Feature setAttr(String key, Object value) { + if (value instanceof ZoomFunction) { + attrsChangeByZoom = true; + } attrs.put(key, value); return this; } public Feature setAttrWithMinzoom(String key, Object value, int minzoom) { - attrs.put(key, ZoomFunction.minZoom(minzoom, value)); - return this; + return setAttr(key, ZoomFunction.minZoom(minzoom, value)); + } + + public boolean area() { + return area; } @Override diff --git a/src/main/java/com/onthegomap/flatmap/FeatureRenderer.java b/src/main/java/com/onthegomap/flatmap/FeatureRenderer.java index 32dde528..2ad01beb 100644 --- a/src/main/java/com/onthegomap/flatmap/FeatureRenderer.java +++ b/src/main/java/com/onthegomap/flatmap/FeatureRenderer.java @@ -2,6 +2,7 @@ package com.onthegomap.flatmap; import com.onthegomap.flatmap.geo.GeoUtils; import com.onthegomap.flatmap.geo.TileCoord; +import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -10,19 +11,26 @@ import java.util.Optional; import java.util.Set; import java.util.concurrent.atomic.AtomicLong; import java.util.function.Consumer; +import org.jetbrains.annotations.NotNull; +import org.locationtech.jts.algorithm.Area; import org.locationtech.jts.geom.Coordinate; import org.locationtech.jts.geom.CoordinateSequence; +import org.locationtech.jts.geom.CoordinateSequences; import org.locationtech.jts.geom.CoordinateXY; 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.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.PrecisionModel; +import org.locationtech.jts.geom.impl.PackedCoordinateSequence; import org.locationtech.jts.geom.util.AffineTransformation; +import org.locationtech.jts.precision.GeometryPrecisionReducer; import org.locationtech.jts.simplify.DouglasPeuckerSimplifier; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -32,6 +40,15 @@ public class FeatureRenderer { private static final AtomicLong idGen = new AtomicLong(0); private static final Logger LOGGER = LoggerFactory.getLogger(FeatureRenderer.class); + private static final PrecisionModel tilePrecision = new PrecisionModel(4096d / 256d); + private static final VectorTileEncoder.VectorGeometry FILL = VectorTileEncoder.encodeGeometry(GeoUtils.JTS_FACTORY + .createPolygon(GeoUtils.JTS_FACTORY.createLinearRing(new PackedCoordinateSequence.Double(new double[]{ + -5, -5, + 261, -5, + 261, 261, + -5, 261, + -5, -5 + }, 2, 0)))); private final CommonParams config; private final Consumer consumer; @@ -40,6 +57,22 @@ public class FeatureRenderer { this.consumer = consumer; } + private static int wrapInt(int value, int max) { + value %= max; + if (value < 0) { + value += max; + } + return value; + } + + private static double wrapDouble(double value, double max) { + value %= max; + if (value < 0) { + value += max; + } + return value; + } + public void renderFeature(FeatureCollector.Feature feature) { renderGeometry(feature.getGeometry(), feature); } @@ -65,23 +98,8 @@ public class FeatureRenderer { } } - private static int wrapInt(int value, int max) { - value %= max; - if (value < 0) { - value += max; - } - return value; - } - - private static double wrapDouble(double value, double max) { - value %= max; - if (value < 0) { - value += max; - } - return value; - } - 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; @@ -108,37 +126,49 @@ public class FeatureRenderer { double buffer = feature.getBufferPixelsAtZoom(zoom) / 256; int tilesAtZoom = 1 << zoom; for (Coordinate coord : coords) { + // TODO TiledGeometry.sliceIntoTiles(...) slicePoint(sliced, zoom, buffer, coord); } - Optional groupInfo = Optional.empty(); + RenderedFeature.Group groupInfo = null; if (feature.hasLabelGrid() && coords.length == 1) { double labelGridTileSize = feature.getLabelGridPixelSizeAtZoom(zoom) / 256d; - groupInfo = labelGridTileSize >= 1d / 4096d ? - Optional.of(new RenderedFeature.Group(GeoUtils.longPair( - (int) Math.floor(wrapDouble(coords[0].getX() * tilesAtZoom, tilesAtZoom) / labelGridTileSize), - (int) Math.floor((coords[0].getY() * tilesAtZoom) / labelGridTileSize) - ), feature.getLabelGridLimitAtZoom(zoom))) : Optional.empty(); + groupInfo = labelGridTileSize >= 1d / 4096d ? new RenderedFeature.Group(GeoUtils.longPair( + (int) Math.floor(wrapDouble(coords[0].getX() * tilesAtZoom, tilesAtZoom) / labelGridTileSize), + (int) Math.floor((coords[0].getY() * tilesAtZoom) / labelGridTileSize) + ), feature.getLabelGridLimitAtZoom(zoom)) : null; } for (var entry : sliced.entrySet()) { + TileCoord tile = entry.getKey(); Set value = entry.getValue(); Geometry geom = value.size() == 1 ? GeoUtils.point(value.iterator().next()) : GeoUtils.multiPoint(value); - consumer.accept(new RenderedFeature( - entry.getKey(), - new VectorTileEncoder.Feature( - feature.getLayer(), - id, - VectorTileEncoder.encodeGeometry(geom), - attrs - ), - feature.getZorder(), - groupInfo - )); + // TODO stats + // TODO writeTileFeatures + emitFeature(feature, id, attrs, groupInfo, tile, geom); } } } + private void emitFeature(FeatureCollector.Feature feature, long id, TileCoord tile, Geometry geom) { + emitFeature(feature, id, feature.getAttrsAtZoom(tile.z()), null, tile, geom); + } + + private void emitFeature(FeatureCollector.Feature feature, long id, Map attrs, + RenderedFeature.Group groupInfo, TileCoord tile, Geometry geom) { + consumer.accept(new RenderedFeature( + tile, + new VectorTileEncoder.Feature( + feature.getLayer(), + id, + VectorTileEncoder.encodeGeometry(geom), + attrs + ), + feature.getZorder(), + Optional.ofNullable(groupInfo) + )); + } + private void addPointFeature(FeatureCollector.Feature feature, MultiPoint points) { if (feature.hasLabelGrid()) { for (Coordinate coord : points.getCoordinates()) { @@ -150,6 +180,7 @@ public class FeatureRenderer { } private void addLinearFeature(FeatureCollector.Feature feature, Geometry input) { + long id = idGen.incrementAndGet(); // TODO move to feature? double minSizeAtMaxZoom = 1d / 4096; double normalTolerance = 0.1 / 256; @@ -175,27 +206,155 @@ public class FeatureRenderer { simplifier.setDistanceTolerance(tolerance); geom = simplifier.getResultGeometry(); - List> groups = extractGroups(geom); + List> groups = new ArrayList<>(); + extractGroups(geom, groups, minSize); double buffer = feature.getBufferPixelsAtZoom(z); TileExtents.ForZoom extents = config.extents().getForZoom(z); TiledGeometry sliced = TiledGeometry.sliceIntoTiles(groups, buffer, area, z, extents); - writeTileFeatures(feature, sliced); + writeTileFeatures(id, feature, sliced); } } - private void writeTileFeatures(FeatureCollector.Feature feature, TiledGeometry sliced) { - build polygons, enforce correctness - build linestrings - reduce precision - handle errors - fix orientation - write filled tiles - log stats + private void writeTileFeatures(long id, FeatureCollector.Feature feature, TiledGeometry sliced) { + for (var entry : sliced.getTileData()) { + TileCoord tile = entry.getKey(); + List> geoms = entry.getValue(); + + Geometry geom; + if (feature.area()) { + geom = reassemblePolygon(feature, tile, geoms); + } else { + geom = reassembleLineString(geoms); + } + + try { + geom = GeometryPrecisionReducer.reduce(geom, tilePrecision); + } catch (IllegalArgumentException e) { + LOGGER.warn("Error reducing precision of " + feature + " on " + tile + ": " + e); + } + + if (!geom.isEmpty()) { + // JTS utilities "fix" the geometry to be clockwise outer/CCW inner + if (feature.area()) { + geom = geom.reverse(); + } + emitFeature(feature, id, tile, geom); + } + } + + if (feature.area()) { + emitFilledTiles(id, feature, sliced); + } + // TODO log stats } - private List> extractGroups(Geometry geom) { - limit length - limit area - enforce orientation + private void emitFilledTiles(long id, FeatureCollector.Feature feature, TiledGeometry sliced) { + /* + * Optimization: large input polygons that generate many filled interior tiles (ie. the ocean), the encoder avoids + * re-encoding if groupInfo and vector tile feature are == to previous values. The feature can have different + * attributes at different zoom levels though, so need to cache each vector tile feature instance by zoom level. + */ + Optional groupInfo = Optional.empty(); + VectorTileEncoder.Feature cachedFeature = null; + int lastZoom = Integer.MIN_VALUE; + + for (TileCoord tile : sliced.getFilledTilesOrderedByZXY()) { + int zoom = tile.z(); + if (zoom != lastZoom) { + cachedFeature = new VectorTileEncoder.Feature(feature.getLayer(), id, FILL, feature.getAttrsAtZoom(zoom)); + lastZoom = zoom; + } + consumer.accept(new RenderedFeature( + tile, + cachedFeature, + feature.getZorder(), + groupInfo + )); + } + } + + private 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 + private 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); + if (!geom.isValid()) { + geom = geom.buffer(0); + if (!geom.isValid()) { + geom = geom.buffer(0); + if (!geom.isValid()) { + LOGGER.warn("Geometry still invalid after 2 buffers " + feature + " on " + tile); + } + } + } + return geom; + } + + private void extractGroups(Geometry geom, List> groups, double minSize) { + if (geom.isEmpty()) { + // ignore + } else if (geom instanceof GeometryCollection) { + for (int i = 0; i < geom.getNumGeometries(); i++) { + extractGroups(geom.getGeometryN(i), groups, minSize); + } + } else if (geom instanceof Polygon polygon) { + extractGroupsFromPolygon(groups, minSize, polygon); + } else if (geom instanceof LinearRing linearRing) { + extractGroups(GeoUtils.JTS_FACTORY.createPolygon(linearRing), groups, minSize); + } else if (geom instanceof LineString lineString) { + if (lineString.getLength() >= minSize) { + groups.add(List.of(lineString.getCoordinateSequence())); + } + } else { + throw new RuntimeException("unrecognized geometry type: " + geom.getGeometryType()); + } + } + + private void extractGroupsFromPolygon(List> groups, double minSize, Polygon polygon) { + CoordinateSequence outer = polygon.getExteriorRing().getCoordinateSequence(); + double outerArea = Area.ofRingSigned(outer); + if (outerArea > 0) { + CoordinateSequences.reverse(outer); + } + if (Math.abs(outerArea) >= minSize) { + List group = new ArrayList<>(1 + polygon.getNumInteriorRing()); + groups.add(group); + group.add(outer); + for (int i = 0; i < polygon.getNumInteriorRing(); i++) { + CoordinateSequence inner = polygon.getInteriorRingN(i).getCoordinateSequence(); + double innerArea = Area.ofRingSigned(inner); + if (innerArea > 0) { + CoordinateSequences.reverse(inner); + } + if (Math.abs(innerArea) >= minSize) { + group.add(inner); + } + } + } } } diff --git a/src/main/java/com/onthegomap/flatmap/TiledGeometry.java b/src/main/java/com/onthegomap/flatmap/TiledGeometry.java index 890c6e67..118da73f 100644 --- a/src/main/java/com/onthegomap/flatmap/TiledGeometry.java +++ b/src/main/java/com/onthegomap/flatmap/TiledGeometry.java @@ -26,7 +26,10 @@ import java.util.EnumSet; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.SortedMap; +import java.util.TreeMap; import java.util.TreeSet; +import org.jetbrains.annotations.NotNull; import org.locationtech.jts.geom.CoordinateSequence; import org.locationtech.jts.geom.impl.PackedCoordinateSequence; import org.slf4j.Logger; @@ -41,7 +44,7 @@ public class TiledGeometry { private static final Logger LOGGER = LoggerFactory.getLogger(TiledGeometry.class); private final Map>> tileContents = new HashMap<>(); - private final Map filledRanges = new HashMap<>(); + private final SortedMap filledRanges = new TreeMap<>(); private final TileExtents.ForZoom extents; private final double buffer; private final int z; @@ -58,18 +61,19 @@ public class TiledGeometry { public static TiledGeometry sliceIntoTiles(List> groups, double buffer, boolean area, int z, TileExtents.ForZoom extents) { - + int worldExtent = 1 << z; TiledGeometry result = new TiledGeometry(extents, buffer, z, area); EnumSet wrapResult = result.sliceWorldCopy(groups, 0); if (wrapResult.contains(Direction.RIGHT)) { - result.sliceWorldCopy(groups, 1); - } else if (wrapResult.contains(Direction.LEFT)) { - result.sliceWorldCopy(groups, -1); + result.sliceWorldCopy(groups, -worldExtent); + } + if (wrapResult.contains(Direction.LEFT)) { + result.sliceWorldCopy(groups, worldExtent); } return result; } - public Iterable getFilledTiles() { + public Iterable getFilledTilesOrderedByZXY() { return () -> filledRanges.entrySet().stream() .mapMulti((entry, next) -> { Column column = entry.getKey(); @@ -125,7 +129,7 @@ public class TiledGeometry { for (int i = 0; i < group.size(); i++) { CoordinateSequence segment = group.get(i); boolean outer = i == 0; - IntObjectMap> xSlices = sliceX(segment, outer); + IntObjectMap> xSlices = sliceX(segment); if (z >= 6 && xSlices.size() >= Math.pow(2, z) - 1) { LOGGER.warn("Feature crosses world at z" + z + ": " + xSlices.size()); } @@ -173,7 +177,7 @@ public class TiledGeometry { } } - private IntObjectMap> sliceX(CoordinateSequence segment, boolean outer) { + private IntObjectMap> sliceX(CoordinateSequence segment) { int maxIndex = 1 << z; double k1 = -buffer; double k2 = 1 + buffer; @@ -197,7 +201,7 @@ public class TiledGeometry { double bx = _bx - x; MutableCoordinateSequence slice = xSlices.get(x); if (slice == null) { - xSlices.put(x, slice = new MutableCoordinateSequence(outer)); + xSlices.put(x, slice = new MutableCoordinateSequence()); List newGeom = newGeoms.get(x); if (newGeom == null) { newGeoms.put(x, newGeom = new ArrayList<>()); @@ -339,7 +343,7 @@ public class TiledGeometry { tiles.add(y); } // x is already relative to tile - ySlices.put(y, slice = MutableCoordinateSequence.newScalingSequence(outer, 0, y, 256)); + ySlices.put(y, slice = MutableCoordinateSequence.newScalingSequence(0, y, 256)); TileCoord tileID = TileCoord.ofXYZ(x, y, z); List toAddTo = inProgressShapes.computeIfAbsent(tileID, tile -> new ArrayList<>()); @@ -450,7 +454,12 @@ public class TiledGeometry { private enum Direction {RIGHT, LEFT} - private static record Column(int z, int x) { + private static record Column(int z, int x) implements Comparable { + @Override + public int compareTo(@NotNull Column o) { + int result = Integer.compare(z, o.z); + return result == 0 ? Integer.compare(x, o.x) : result; + } } } diff --git a/src/main/java/com/onthegomap/flatmap/VectorTileEncoder.java b/src/main/java/com/onthegomap/flatmap/VectorTileEncoder.java index f22add6f..a1886b8e 100644 --- a/src/main/java/com/onthegomap/flatmap/VectorTileEncoder.java +++ b/src/main/java/com/onthegomap/flatmap/VectorTileEncoder.java @@ -100,7 +100,7 @@ public class VectorTileEncoder { private static Geometry decodeCommands(byte geomTypeByte, int[] commands) { VectorTile.Tile.GeomType geomType = Objects.requireNonNull(VectorTile.Tile.GeomType.forNumber(geomTypeByte)); - GeometryFactory gf = GeoUtils.gf; + GeometryFactory gf = GeoUtils.JTS_FACTORY; int x = 0; int y = 0; diff --git a/src/main/java/com/onthegomap/flatmap/collections/CacheByZoom.java b/src/main/java/com/onthegomap/flatmap/collections/CacheByZoom.java new file mode 100644 index 00000000..e03f12c8 --- /dev/null +++ b/src/main/java/com/onthegomap/flatmap/collections/CacheByZoom.java @@ -0,0 +1,30 @@ +package com.onthegomap.flatmap.collections; + +import com.onthegomap.flatmap.CommonParams; +import java.util.function.IntFunction; + +public class CacheByZoom { + + private final int minzoom; + private final Object[] values; + private final IntFunction supplier; + + private CacheByZoom(int minzoom, int maxzoom, IntFunction supplier) { + this.minzoom = minzoom; + values = new Object[maxzoom + 1 - minzoom]; + this.supplier = supplier; + } + + public static CacheByZoom create(CommonParams params, IntFunction supplier) { + return new CacheByZoom<>(params.minzoom(), params.maxzoom(), supplier); + } + + public T get(int zoom) { + @SuppressWarnings("unchecked") T[] casted = (T[]) values; + int off = zoom - minzoom; + if (values[off] != null) { + return casted[off]; + } + return casted[off] = supplier.apply(zoom); + } +} diff --git a/src/main/java/com/onthegomap/flatmap/collections/FeatureGroup.java b/src/main/java/com/onthegomap/flatmap/collections/FeatureGroup.java index 46fbc728..74daf2f4 100644 --- a/src/main/java/com/onthegomap/flatmap/collections/FeatureGroup.java +++ b/src/main/java/com/onthegomap/flatmap/collections/FeatureGroup.java @@ -17,6 +17,7 @@ import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Optional; import java.util.function.Consumer; import java.util.function.Function; import org.msgpack.core.MessageBufferPacker; @@ -93,18 +94,38 @@ public final class FeatureGroup implements Consumer, Iterable } public Function newRenderedFeatureEncoder() { + /* + * Optimization: Re-use the same buffer packer to avoid allocating and resizing new byte arrays for every feature. + */ var packer = MessagePack.newDefaultBufferPacker(); - return feature -> { - layerStats.accept(feature); - return encode(feature, packer); - }; - } - private FeatureSort.Entry encode(RenderedFeature feature, MessageBufferPacker packer) { - return new FeatureSort.Entry( - encodeSortKey(feature), - encodeValue(feature, packer) - ); + /* + * Optimization: Avoid re-encoding values for identical fill geometries (ie. in the ocean) by memoizing based on + * the input vector tile feature. FeatureRenderer ensures that all fill vector tile features use the same instance + * within a zoom level (and filled tiles are ordered by z, x, y). + */ + return new Function<>() { + private VectorTileEncoder.Feature lastFeature = null; + private byte[] lastEncodedValue = null; + + @Override + public FeatureSort.Entry apply(RenderedFeature feature) { + layerStats.accept(feature); + var group = feature.group(); + var thisFeature = feature.vectorTileFeature(); + byte[] encodedValue; + if (group.isEmpty()) { // don't bother memoizing if group is present + encodedValue = encodeValue(thisFeature, group, packer); + } else if (lastFeature == thisFeature) { + encodedValue = lastEncodedValue; + } else { // feature changed, memoize new value + lastFeature = thisFeature; + lastEncodedValue = encodedValue = encodeValue(feature.vectorTileFeature(), feature.group(), packer); + } + + return new FeatureSort.Entry(encodeSortKey(feature), encodedValue); + } + }; } private long encodeSortKey(RenderedFeature feature) { @@ -118,16 +139,16 @@ public final class FeatureGroup implements Consumer, Iterable ); } - private byte[] encodeValue(RenderedFeature feature, MessageBufferPacker packer) { + private byte[] encodeValue(VectorTileEncoder.Feature vectorTileFeature, Optional group, + MessageBufferPacker packer) { packer.clear(); try { - var groupInfoOption = feature.group(); + var groupInfoOption = group; if (groupInfoOption.isPresent()) { var groupInfo = groupInfoOption.get(); packer.packLong(groupInfo.group()); packer.packInt(groupInfo.limit()); } - var vectorTileFeature = feature.vectorTileFeature(); packer.packLong(vectorTileFeature.id()); packer.packByte(vectorTileFeature.geometry().geomType()); var attrs = vectorTileFeature.attrs(); diff --git a/src/main/java/com/onthegomap/flatmap/collections/MutableCoordinateSequence.java b/src/main/java/com/onthegomap/flatmap/collections/MutableCoordinateSequence.java index a28251e8..a65bdb97 100644 --- a/src/main/java/com/onthegomap/flatmap/collections/MutableCoordinateSequence.java +++ b/src/main/java/com/onthegomap/flatmap/collections/MutableCoordinateSequence.java @@ -9,22 +9,15 @@ import org.locationtech.jts.geom.impl.PackedCoordinateSequence; public class MutableCoordinateSequence extends PackedCoordinateSequence { private final DoubleArrayList points = new DoubleArrayList(); - private final boolean inner; - public MutableCoordinateSequence(boolean outer) { + public MutableCoordinateSequence() { super(2, 0); - this.inner = !outer; } - public static MutableCoordinateSequence newScalingSequence(boolean outer, double relX, double relY, double scale) { - return new ScalingSequence(outer, scale, relX, relY); + public static MutableCoordinateSequence newScalingSequence(double relX, double relY, double scale) { + return new ScalingSequence(scale, relX, relY); } - public boolean isInnerRing() { - return inner; - } - - @Override public double getOrdinate(int index, int ordinateIndex) { return points.get((index * 2) + ordinateIndex); @@ -87,8 +80,7 @@ public class MutableCoordinateSequence extends PackedCoordinateSequence { private final double relX; private final double relY; - public ScalingSequence(boolean outer, double scale, double relX, double relY) { - super(outer); + public ScalingSequence(double scale, double relX, double relY) { this.scale = scale; this.relX = relX; this.relY = relY; diff --git a/src/main/java/com/onthegomap/flatmap/geo/GeoUtils.java b/src/main/java/com/onthegomap/flatmap/geo/GeoUtils.java index 0deb9241..4bc91fff 100644 --- a/src/main/java/com/onthegomap/flatmap/geo/GeoUtils.java +++ b/src/main/java/com/onthegomap/flatmap/geo/GeoUtils.java @@ -1,12 +1,14 @@ package com.onthegomap.flatmap.geo; import java.util.Collection; +import java.util.List; import org.locationtech.jts.geom.Coordinate; import org.locationtech.jts.geom.CoordinateSequence; import org.locationtech.jts.geom.CoordinateXY; import org.locationtech.jts.geom.Envelope; import org.locationtech.jts.geom.Geometry; import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.LineString; import org.locationtech.jts.geom.MultiPoint; import org.locationtech.jts.geom.Point; import org.locationtech.jts.geom.impl.PackedCoordinateSequence; @@ -15,8 +17,11 @@ import org.locationtech.jts.io.WKBReader; public class GeoUtils { - public static final GeometryFactory gf = new GeometryFactory(); - public static final WKBReader wkbReader = new WKBReader(gf); + public static final GeometryFactory JTS_FACTORY = new GeometryFactory(); + public static final WKBReader wkbReader = new WKBReader(JTS_FACTORY); + + private static final LineString[] EMPTY_LINE_STRING_ARRAY = new LineString[0]; + private static final Coordinate[] EMPTY_COORD_ARRAY = new Coordinate[0]; private static final double WORLD_RADIUS_METERS = 6_378_137; private static final double WORLD_CIRCUMFERENCE_METERS = Math.PI * 2 * WORLD_RADIUS_METERS; @@ -174,19 +179,23 @@ public class GeoUtils { } public static Point point(double x, double y) { - return gf.createPoint(new CoordinateXY(x, y)); + return JTS_FACTORY.createPoint(new CoordinateXY(x, y)); } public static Point point(Coordinate coord) { - return gf.createPoint(coord); + return JTS_FACTORY.createPoint(coord); } public static MultiPoint multiPoint(Collection coords) { - return gf.createMultiPointFromCoords(coords.toArray(new Coordinate[0])); + return JTS_FACTORY.createMultiPointFromCoords(coords.toArray(EMPTY_COORD_ARRAY)); } public static Geometry multiPoint(double... coords) { assert coords.length % 2 == 0; - return gf.createMultiPoint(new PackedCoordinateSequence.Double(coords, 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)); } } diff --git a/src/main/java/com/onthegomap/flatmap/read/Reader.java b/src/main/java/com/onthegomap/flatmap/read/Reader.java index 0175be24..7d8d8209 100644 --- a/src/main/java/com/onthegomap/flatmap/read/Reader.java +++ b/src/main/java/com/onthegomap/flatmap/read/Reader.java @@ -42,8 +42,7 @@ public abstract class Reader implements Closeable { var featureCollectors = new FeatureCollector.Factory(config); var encoder = writer.newRenderedFeatureEncoder(); FeatureRenderer renderer = new FeatureRenderer( - config, - rendered -> next.accept(encoder.apply(rendered)) + config, encoder, next ); while ((sourceFeature = prev.get()) != null) { featuresRead.incrementAndGet(); diff --git a/src/test/java/com/onthegomap/flatmap/TestUtils.java b/src/test/java/com/onthegomap/flatmap/TestUtils.java index 2d8c84f8..20e4aa08 100644 --- a/src/test/java/com/onthegomap/flatmap/TestUtils.java +++ b/src/test/java/com/onthegomap/flatmap/TestUtils.java @@ -50,27 +50,27 @@ public class TestUtils { } public static Polygon newPolygon(double... coords) { - return GeoUtils.gf.createPolygon(newCoordinateList(coords).toArray(new Coordinate[0])); + return GeoUtils.JTS_FACTORY.createPolygon(newCoordinateList(coords).toArray(new Coordinate[0])); } public static LineString newLineString(double... coords) { - return GeoUtils.gf.createLineString(newCoordinateList(coords).toArray(new Coordinate[0])); + return GeoUtils.JTS_FACTORY.createLineString(newCoordinateList(coords).toArray(new Coordinate[0])); } public static Point newPoint(double x, double y) { - return GeoUtils.gf.createPoint(new CoordinateXY(x, y)); + return GeoUtils.JTS_FACTORY.createPoint(new CoordinateXY(x, y)); } public static MultiPoint newMultiPoint(Point... points) { - return GeoUtils.gf.createMultiPoint(points); + return GeoUtils.JTS_FACTORY.createMultiPoint(points); } public static MultiPolygon newMultiPolygon(Polygon... polys) { - return GeoUtils.gf.createMultiPolygon(polys); + return GeoUtils.JTS_FACTORY.createMultiPolygon(polys); } public static GeometryCollection newGeometryCollection(Geometry... geoms) { - return GeoUtils.gf.createGeometryCollection(geoms); + return GeoUtils.JTS_FACTORY.createGeometryCollection(geoms); } public static Geometry round(Geometry input) { @@ -153,7 +153,7 @@ public class TestUtils { } public static Geometry emptyGeometry() { - return GeoUtils.gf.createGeometryCollection(); + return GeoUtils.JTS_FACTORY.createGeometryCollection(); } public interface GeometryComparision { diff --git a/src/test/java/com/onthegomap/flatmap/VectorTileEncoderTest.java b/src/test/java/com/onthegomap/flatmap/VectorTileEncoderTest.java index 178f71bc..b4b672a4 100644 --- a/src/test/java/com/onthegomap/flatmap/VectorTileEncoderTest.java +++ b/src/test/java/com/onthegomap/flatmap/VectorTileEncoderTest.java @@ -24,7 +24,7 @@ import static com.onthegomap.flatmap.TestUtils.newMultiPoint; import static com.onthegomap.flatmap.TestUtils.newMultiPolygon; import static com.onthegomap.flatmap.TestUtils.newPoint; import static com.onthegomap.flatmap.TestUtils.newPolygon; -import static com.onthegomap.flatmap.geo.GeoUtils.gf; +import static com.onthegomap.flatmap.geo.GeoUtils.JTS_FACTORY; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotSame; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -57,7 +57,7 @@ public class VectorTileEncoderTest { @Test public void testToGeomType() { - Geometry geometry = gf.createLineString(new Coordinate[]{new CoordinateXY(1, 2), new CoordinateXY(3, 4)}); + Geometry geometry = JTS_FACTORY.createLineString(new Coordinate[]{new CoordinateXY(1, 2), new CoordinateXY(3, 4)}); assertEquals((byte) VectorTile.Tile.GeomType.LINESTRING.getNumber(), VectorTileEncoder.encodeGeometry(geometry).geomType()); } @@ -234,12 +234,12 @@ public class VectorTileEncoderTest { @Test public void testRoundTripPoint() { - testRoundTripGeometry(gf.createPoint(new CoordinateXY(1, 2))); + testRoundTripGeometry(JTS_FACTORY.createPoint(new CoordinateXY(1, 2))); } @Test public void testRoundTripMultipoint() { - testRoundTripGeometry(gf.createMultiPointFromCoords(new Coordinate[]{ + testRoundTripGeometry(JTS_FACTORY.createMultiPointFromCoords(new Coordinate[]{ new CoordinateXY(1, 2), new CoordinateXY(3, 4) })); @@ -247,7 +247,7 @@ public class VectorTileEncoderTest { @Test public void testRoundTripLineString() { - testRoundTripGeometry(gf.createLineString(new Coordinate[]{ + testRoundTripGeometry(JTS_FACTORY.createLineString(new Coordinate[]{ new CoordinateXY(1, 2), new CoordinateXY(3, 4) })); @@ -255,8 +255,8 @@ public class VectorTileEncoderTest { @Test public void testRoundTripPolygon() { - testRoundTripGeometry(gf.createPolygon( - gf.createLinearRing(new Coordinate[]{ + testRoundTripGeometry(JTS_FACTORY.createPolygon( + JTS_FACTORY.createLinearRing(new Coordinate[]{ new CoordinateXY(0, 0), new CoordinateXY(4, 0), new CoordinateXY(4, 4), @@ -264,7 +264,7 @@ public class VectorTileEncoderTest { new CoordinateXY(0, 0) }), new LinearRing[]{ - gf.createLinearRing(new Coordinate[]{ + JTS_FACTORY.createLinearRing(new Coordinate[]{ new CoordinateXY(1, 1), new CoordinateXY(1, 2), new CoordinateXY(2, 2), @@ -277,15 +277,15 @@ public class VectorTileEncoderTest { @Test public void testRoundTripMultiPolygon() { - testRoundTripGeometry(gf.createMultiPolygon(new Polygon[]{ - gf.createPolygon(new Coordinate[]{ + testRoundTripGeometry(JTS_FACTORY.createMultiPolygon(new Polygon[]{ + JTS_FACTORY.createPolygon(new Coordinate[]{ new CoordinateXY(0, 0), new CoordinateXY(1, 0), new CoordinateXY(1, 1), new CoordinateXY(0, 1), new CoordinateXY(0, 0) }), - gf.createPolygon(new Coordinate[]{ + JTS_FACTORY.createPolygon(new Coordinate[]{ new CoordinateXY(3, 0), new CoordinateXY(4, 0), new CoordinateXY(4, 1), @@ -308,7 +308,7 @@ public class VectorTileEncoderTest { @Test public void testMultipleFeaturesMultipleLayer() { - Point point = gf.createPoint(new CoordinateXY(0, 0)); + Point point = JTS_FACTORY.createPoint(new CoordinateXY(0, 0)); Map attrs1 = Map.of("a", 1L, "b", 2L); Map attrs2 = Map.of("b", 3L, "c", 2L); byte[] encoded = new VectorTileEncoder().addLayerFeatures("layer1", List.of( @@ -330,7 +330,7 @@ public class VectorTileEncoderTest { } private void testRoundTripAttrs(Map attrs) { - testRoundTrip(gf.createPoint(new CoordinateXY(0, 0)), "layer", attrs, 1); + testRoundTrip(JTS_FACTORY.createPoint(new CoordinateXY(0, 0)), "layer", attrs, 1); } private void testRoundTripGeometry(Geometry input) { diff --git a/src/test/java/com/onthegomap/flatmap/collections/MutableCoordinateSequenceTest.java b/src/test/java/com/onthegomap/flatmap/collections/MutableCoordinateSequenceTest.java index e8f8d23c..e735d66e 100644 --- a/src/test/java/com/onthegomap/flatmap/collections/MutableCoordinateSequenceTest.java +++ b/src/test/java/com/onthegomap/flatmap/collections/MutableCoordinateSequenceTest.java @@ -1,8 +1,6 @@ package com.onthegomap.flatmap.collections; 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.carrotsearch.hppc.DoubleArrayList; import org.junit.jupiter.api.Test; @@ -25,28 +23,22 @@ public class MutableCoordinateSequenceTest { assertEquals(DoubleArrayList.from(expected), DoubleArrayList.from(actual), "copied getX/getY"); } - @Test - public void testOuter() { - assertTrue(new MutableCoordinateSequence(false).isInnerRing()); - assertFalse(new MutableCoordinateSequence(true).isInnerRing()); - } - @Test public void testEmpty() { - var seq = new MutableCoordinateSequence(false); + var seq = new MutableCoordinateSequence(); assertEquals(0, seq.copy().size()); } @Test public void testSingle() { - var seq = new MutableCoordinateSequence(false); + var seq = new MutableCoordinateSequence(); seq.addPoint(1, 2); assertContents(seq, 1, 2); } @Test public void testTwoPoints() { - var seq = new MutableCoordinateSequence(false); + var seq = new MutableCoordinateSequence(); seq.addPoint(1, 2); seq.addPoint(3, 4); assertContents(seq, 1, 2, 3, 4); @@ -54,7 +46,7 @@ public class MutableCoordinateSequenceTest { @Test public void testClose() { - var seq = new MutableCoordinateSequence(false); + var seq = new MutableCoordinateSequence(); seq.addPoint(1, 2); seq.addPoint(3, 4); seq.addPoint(0, 1); @@ -64,7 +56,7 @@ public class MutableCoordinateSequenceTest { @Test public void testScaling() { - var seq = MutableCoordinateSequence.newScalingSequence(true, 1, 2, 3); + var seq = MutableCoordinateSequence.newScalingSequence(1, 2, 3); seq.addPoint(1, 2); seq.addPoint(3, 4); seq.addPoint(0, 1); diff --git a/src/test/java/com/onthegomap/flatmap/read/NaturalEarthReaderTest.java b/src/test/java/com/onthegomap/flatmap/read/NaturalEarthReaderTest.java index 23879a6b..76cb33e1 100644 --- a/src/test/java/com/onthegomap/flatmap/read/NaturalEarthReaderTest.java +++ b/src/test/java/com/onthegomap/flatmap/read/NaturalEarthReaderTest.java @@ -39,7 +39,7 @@ public class NaturalEarthReaderTest { points.add(elem.latLonGeometry()); }).await(); assertEquals(19, points.size()); - var gc = GeoUtils.gf.createGeometryCollection(points.toArray(new Geometry[0])); + var gc = GeoUtils.JTS_FACTORY.createGeometryCollection(points.toArray(new Geometry[0])); var centroid = gc.getCentroid(); assertArrayEquals( new double[]{14.22422, 12.994629}, diff --git a/src/test/java/com/onthegomap/flatmap/read/ShapefileReaderTest.java b/src/test/java/com/onthegomap/flatmap/read/ShapefileReaderTest.java index 36b6489b..0e458f95 100644 --- a/src/test/java/com/onthegomap/flatmap/read/ShapefileReaderTest.java +++ b/src/test/java/com/onthegomap/flatmap/read/ShapefileReaderTest.java @@ -47,7 +47,7 @@ public class ShapefileReaderTest { points.add(elem.latLonGeometry()); }).await(); assertEquals(86, points.size()); - var gc = GeoUtils.gf.createGeometryCollection(points.toArray(new Geometry[0])); + var gc = GeoUtils.JTS_FACTORY.createGeometryCollection(points.toArray(new Geometry[0])); var centroid = gc.getCentroid(); assertEquals(-77.0297995, centroid.getX(), 5, "iter " + i); assertEquals(38.9119684, centroid.getY(), 5, "iter " + i);