no.ecc.vectortile.VectorTileEncoder Maven / Gradle / Ivy
Show all versions of graphhopper-web-bundle Show documentation
/*****************************************************************
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
****************************************************************/
package no.ecc.vectortile;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.locationtech.jts.algorithm.Area;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.Envelope;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.GeometryCollection;
import org.locationtech.jts.geom.GeometryFactory;
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.TopologyException;
import org.locationtech.jts.geom.prep.PreparedGeometry;
import org.locationtech.jts.geom.prep.PreparedGeometryFactory;
import org.locationtech.jts.io.ParseException;
import org.locationtech.jts.io.WKTReader;
import org.locationtech.jts.simplify.DouglasPeuckerSimplifier;
import org.locationtech.jts.simplify.TopologyPreservingSimplifier;
import vector_tile.VectorTile;
import vector_tile.VectorTile.Tile.GeomType;
/**
* This is a copy of https://github.com/ElectronicChartCentre/java-vector-tile/commit/15e2e9b127729a00c52ced3a11fd1e9a45b462b1
* We use this copy because we want to avoid the non-standard no.ecc Maven repository
*/
public class VectorTileEncoder {
private final Map layers = new LinkedHashMap();
private final int extent;
private final double minimumLength;
private final double minimumArea;
protected final Geometry clipGeometry;
protected final Envelope clipEnvelope;
protected final PreparedGeometry clipGeometryPrepared;
private final boolean autoScale;
private long autoincrement;
private final boolean autoincrementIds;
private final double simplificationDistanceTolerance;
private final GeometryFactory gf = new GeometryFactory();
/**
* Create a {@link VectorTileEncoder} with the default extent of 4096 and
* clip buffer of 8.
*/
public VectorTileEncoder() {
this(4096, 8, true);
}
/**
* Create a {@link VectorTileEncoder} with the given extent and a clip
* buffer of 8.
*
* @param extent a int to specify vector tile extent. 4096 is a good value.
*/
public VectorTileEncoder(int extent) {
this(extent, 8, true);
}
public VectorTileEncoder(int extent, int clipBuffer, boolean autoScale) {
this(extent, clipBuffer, autoScale, false);
}
public VectorTileEncoder(int extent, int clipBuffer, boolean autoScale, boolean autoincrementIds) {
this(extent, clipBuffer, autoScale, autoincrementIds, -1.0);
}
/**
* Create a {@link VectorTileEncoder} with the given extent value.
*
* The extent value control how detailed the coordinates are encoded in the
* vector tile. 4096 is a good default, 256 can be used to reduce density.
*
* The clip buffer value control how large the clipping area is outside of the
* tile for geometries. 0 means that the clipping is done at the tile border. 8
* is a good default.
*
* @param extent
* a int with extent value. 4096 is a good value.
* @param clipBuffer
* a int with clip buffer size for geometries. 8 is a good value.
* @param autoScale
* when true, the encoder expects coordinates in the 0..255 range and
* will scale them automatically to the 0..extent-1 range before
* encoding. when false, the encoder expects coordinates in the
* 0..extent-1 range.
* @param autoincrementIds
* when true the vector tile feature id is auto incremented when using
* {@link #addFeature(String, Map, Geometry)}
* @param simplificationDistanceTolerance
* a positive double representing the distance tolerance to be used
* for non-points before (optional) scaling and encoding. A value
* <=0 will prevent simplifying geometry. 0.1 seems to be a good
* value when {@code autoScale} is turned on.
*/
public VectorTileEncoder(int extent, int clipBuffer, boolean autoScale, boolean autoincrementIds, double simplificationDistanceTolerance) {
this.extent = extent;
this.autoScale = autoScale;
this.minimumLength = autoScale ? (256.0 / extent) : 1.0;
this.minimumArea = this.minimumLength * this.minimumLength;
this.autoincrementIds = autoincrementIds;
this.autoincrement = 1;
this.simplificationDistanceTolerance = simplificationDistanceTolerance;
final int size = autoScale ? 256 : extent;
clipGeometry = createTileEnvelope(clipBuffer, size);
clipEnvelope = clipGeometry.getEnvelopeInternal();
clipGeometryPrepared = PreparedGeometryFactory.prepare(clipGeometry);
}
private static Geometry createTileEnvelope(int buffer, int size) {
Coordinate[] coords = new Coordinate[5];
coords[0] = new Coordinate(0 - buffer, size + buffer);
coords[1] = new Coordinate(size + buffer, size + buffer);
coords[2] = new Coordinate(size + buffer, 0 - buffer);
coords[3] = new Coordinate(0 - buffer, 0 - buffer);
coords[4] = coords[0];
return new GeometryFactory().createPolygon(coords);
}
public void addFeature(String layerName, Map attributes, Geometry geometry) {
this.addFeature(layerName, attributes, geometry, this.autoincrementIds ? this.autoincrement++ : -1);
}
/**
* Add a feature with layer name (typically feature type name), some attributes
* and a Geometry. The Geometry must be in "pixel" space 0,0 upper left and
* 256,256 lower right.
*
* For optimization, geometries will be clipped and simplified. Features with
* geometries outside of the tile will be skipped.
*
* @param layerName a {@link String} with the vector tile layer name.
* @param attributes a {@link Map} with the vector tile feature attributes.
* @param geometry a {@link Geometry} for the vector tile feature.
* @param id a long with the vector tile feature id field.
*/
public void addFeature(String layerName, Map attributes, Geometry geometry, long id) {
// skip small Polygon/LineString.
if (geometry instanceof MultiPolygon && geometry.getArea() < minimumArea) {
return;
}
if (geometry instanceof Polygon && geometry.getArea() < minimumArea) {
return;
}
if (geometry instanceof LineString && geometry.getLength() < minimumLength) {
return;
}
// special handling of GeometryCollection. subclasses are not handled here.
if (geometry.getClass().equals(GeometryCollection.class)) {
for (int i = 0; i < geometry.getNumGeometries(); i++) {
Geometry subGeometry = geometry.getGeometryN(i);
// keeping the id. any better suggestion?
addFeature(layerName, attributes, subGeometry, id);
}
return;
}
// About to simplify and clip. Looks like simplification before clipping is
// faster than clipping before simplification
// simplify non-points
if (simplificationDistanceTolerance > 0.0 && !(geometry instanceof Point)) {
if (geometry instanceof LineString || geometry instanceof MultiLineString) {
geometry = DouglasPeuckerSimplifier.simplify(geometry, simplificationDistanceTolerance);
} else if (geometry instanceof Polygon || geometry instanceof MultiPolygon) {
Geometry simplified = DouglasPeuckerSimplifier.simplify(geometry, simplificationDistanceTolerance);
// extra check to prevent polygon converted to line
if (simplified instanceof Polygon || simplified instanceof MultiPolygon) {
geometry = simplified;
} else {
geometry = TopologyPreservingSimplifier.simplify(geometry, simplificationDistanceTolerance);
}
} else {
geometry = TopologyPreservingSimplifier.simplify(geometry, simplificationDistanceTolerance);
}
}
// clip geometry
if (geometry instanceof Point) {
if (!clipCovers(geometry)) {
return;
}
} else {
geometry = clipGeometry(geometry);
}
// no need to add empty geometry
if (geometry == null || geometry.isEmpty()) {
return;
}
Layer layer = layers.get(layerName);
if (layer == null) {
layer = new Layer();
layers.put(layerName, layer);
}
Feature feature = new Feature();
feature.geometry = geometry;
feature.id = id;
this.autoincrement = Math.max(this.autoincrement, id + 1);
for (Map.Entry e : attributes.entrySet()) {
// skip attribute without value
if (e.getValue() == null) {
continue;
}
feature.tags.add(layer.key(e.getKey()));
feature.tags.add(layer.value(e.getValue()));
}
layer.features.add(feature);
}
/**
* A short circuit clip to the tile extent (tile boundary + buffer) for
* points to improve performance. This method can be overridden to change
* clipping behavior. See also {@link #clipGeometry(Geometry)}.
*
* @param geom a {@link Geometry} to check for "covers"
* @return a boolean true when the current clip geometry covers the given geom.
*/
protected boolean clipCovers(Geometry geom) {
if (geom instanceof Point) {
Point p = (Point) geom;
return clipGeometry.getEnvelopeInternal().covers(p.getCoordinate());
}
return clipEnvelope.covers(geom.getEnvelopeInternal());
}
/**
* Clip geometry according to buffer given at construct time. This method
* can be overridden to change clipping behavior. See also
* {@link #clipCovers(Geometry)}.
*
* @param geometry a {@link Geometry} to check for intersection with the current clip geometry
* @return a boolean true when current clip geometry intersects with the given geometry.
*/
protected Geometry clipGeometry(Geometry geometry) {
try {
if (clipEnvelope.contains(geometry.getEnvelopeInternal())) {
return geometry;
}
Geometry original = geometry;
geometry = clipGeometry.intersection(original);
// some times a intersection is returned as an empty geometry.
// going via wkt fixes the problem.
if (geometry.isEmpty() && clipGeometryPrepared.intersects(original)) {
Geometry originalViaWkt = new WKTReader().read(original.toText());
geometry = clipGeometry.intersection(originalViaWkt);
}
return geometry;
} catch (TopologyException e) {
// could not intersect. original geometry will be used instead.
return geometry;
} catch (ParseException e1) {
// could not encode/decode WKT. original geometry will be used
// instead.
return geometry;
}
}
/**
* @return a byte array with the vector tile
*/
public byte[] encode() {
VectorTile.Tile.Builder tile = VectorTile.Tile.newBuilder();
for (Map.Entry e : layers.entrySet()) {
String layerName = e.getKey();
Layer layer = e.getValue();
VectorTile.Tile.Layer.Builder tileLayer = VectorTile.Tile.Layer.newBuilder();
tileLayer.setVersion(2);
tileLayer.setName(layerName);
tileLayer.addAllKeys(layer.keys());
for (Object value : layer.values()) {
VectorTile.Tile.Value.Builder tileValue = VectorTile.Tile.Value.newBuilder();
if (value instanceof String) {
tileValue.setStringValue((String) value);
} else if (value instanceof Integer) {
tileValue.setSintValue(((Integer) value).intValue());
} else if (value instanceof Long) {
tileValue.setSintValue(((Long) value).longValue());
} else if (value instanceof Float) {
tileValue.setFloatValue(((Float) value).floatValue());
} else if (value instanceof Double) {
tileValue.setDoubleValue(((Double) value).doubleValue());
} else if (value instanceof BigDecimal) {
tileValue.setStringValue(value.toString());
} else if (value instanceof Number) {
tileValue.setDoubleValue(((Number) value).doubleValue());
} else if (value instanceof Boolean) {
tileValue.setBoolValue(((Boolean) value).booleanValue());
} else {
tileValue.setStringValue(value.toString());
}
tileLayer.addValues(tileValue.build());
}
tileLayer.setExtent(extent);
for (Feature feature : layer.features) {
Geometry geometry = feature.geometry;
VectorTile.Tile.Feature.Builder featureBuilder = VectorTile.Tile.Feature.newBuilder();
featureBuilder.addAllTags(feature.tags);
if (feature.id >= 0) {
featureBuilder.setId(feature.id);
}
GeomType geomType = toGeomType(geometry);
x = 0;
y = 0;
List commands = commands(geometry);
// skip features with no geometry commands
if (commands.isEmpty()) {
continue;
}
// Extra step to parse and check validity and try to repair. Probably expensive.
if (simplificationDistanceTolerance > 0.0 && geomType == GeomType.POLYGON) {
double scale = autoScale ? (extent / 256.0) : 1.0;
Geometry decodedGeometry = VectorTileDecoder.decodeGeometry(gf, geomType, commands, scale);
if (!isValid(decodedGeometry)) {
// Invalid. Try more simplification and without preserving topology.
geometry = DouglasPeuckerSimplifier.simplify(geometry, simplificationDistanceTolerance * 2.0);
if (geometry.isEmpty()) {
continue;
}
geomType = toGeomType(geometry);
x = 0;
y = 0;
commands = commands(geometry);
}
}
featureBuilder.setType(geomType);
featureBuilder.addAllGeometry(commands);
tileLayer.addFeatures(featureBuilder.build());
}
tile.addLayers(tileLayer.build());
}
return tile.build().toByteArray();
}
private static final boolean isValid(Geometry geometry) {
try {
return geometry.isValid();
} catch (RuntimeException e) {
return false;
}
}
static VectorTile.Tile.GeomType toGeomType(Geometry geometry) {
if (geometry instanceof Point) {
return VectorTile.Tile.GeomType.POINT;
}
if (geometry instanceof MultiPoint) {
return VectorTile.Tile.GeomType.POINT;
}
if (geometry instanceof LineString) {
return VectorTile.Tile.GeomType.LINESTRING;
}
if (geometry instanceof MultiLineString) {
return VectorTile.Tile.GeomType.LINESTRING;
}
if (geometry instanceof Polygon) {
return VectorTile.Tile.GeomType.POLYGON;
}
if (geometry instanceof MultiPolygon) {
return VectorTile.Tile.GeomType.POLYGON;
}
return VectorTile.Tile.GeomType.UNKNOWN;
}
static boolean shouldClosePath(Geometry geometry) {
return (geometry instanceof Polygon) || (geometry instanceof LinearRing);
}
List commands(Geometry geometry) {
if (geometry instanceof MultiLineString) {
return commands((MultiLineString) geometry);
}
if (geometry instanceof Polygon) {
return commands((Polygon) geometry);
}
if (geometry instanceof MultiPolygon) {
return commands((MultiPolygon) geometry);
}
return commands(geometry.getCoordinates(), shouldClosePath(geometry), geometry instanceof MultiPoint);
}
List commands(MultiLineString mls) {
List commands = new ArrayList();
for (int i = 0; i < mls.getNumGeometries(); i++) {
final List geomCommands =
commands(mls.getGeometryN(i).getCoordinates(), false);
if (geomCommands.size() > 3) {
// if the geometry consists of all identical points (after Math.round()) commands
// returns a single move_to command, which is not valid according to the vector tile
// specifications.
// (https://github.com/mapbox/vector-tile-spec/tree/master/2.1#4343-linestring-geometry-type)
commands.addAll(geomCommands);
}
}
return commands;
}
List commands(MultiPolygon mp) {
List commands = new ArrayList();
for (int i = 0; i < mp.getNumGeometries(); i++) {
Polygon polygon = (Polygon) mp.getGeometryN(i);
commands.addAll(commands(polygon));
}
return commands;
}
List commands(Polygon polygon) {
List commands = new ArrayList();
// According to the vector tile specification, the exterior ring of a polygon
// must be in clockwise order, while the interior ring in counter-clockwise order.
// In the tile coordinate system, Y axis is positive down.
//
// However, in geographic coordinate system, Y axis is positive up.
// Therefore, we must reverse the coordinates.
// So, the code below will make sure that exterior ring is in counter-clockwise order
// and interior ring in clockwise order.
LineString exteriorRing = polygon.getExteriorRing();
if (Area.ofRingSigned(exteriorRing.getCoordinates()) > 0) {
exteriorRing = exteriorRing.reverse();
}
commands.addAll(commands(exteriorRing.getCoordinates(), true));
for (int i = 0; i < polygon.getNumInteriorRing(); i++) {
LineString interiorRing = polygon.getInteriorRingN(i);
if (Area.ofRingSigned(interiorRing.getCoordinates()) < 0) {
interiorRing = interiorRing.reverse();
}
commands.addAll(commands(interiorRing.getCoordinates(), true));
}
return commands;
}
private int x = 0;
private int y = 0;
/**
* // // // Ex.: MoveTo(3, 6), LineTo(8, 12), LineTo(20, 34), ClosePath //
* Encoded as: [ 9 3 6 18 5 6 12 22 15 ] // == command type 7 (ClosePath),
* length 1 // ===== relative LineTo(+12, +22) == LineTo(20, 34) // ===
* relative LineTo(+5, +6) == LineTo(8, 12) // == [00010 010] = command type
* 2 (LineTo), length 2 // === relative MoveTo(+3, +6) // == [00001 001] =
* command type 1 (MoveTo), length 1 // Commands are encoded as uint32
* varints, vertex parameters are // encoded as sint32 varints (zigzag).
* Vertex parameters are // also encoded as deltas to the previous position.
* The original // position is (0,0)
*
* @param cs
* @return
*/
List commands(Coordinate[] cs, boolean closePathAtEnd) {
return commands(cs, closePathAtEnd, false);
}
List commands(Coordinate[] cs, boolean closePathAtEnd, boolean multiPoint) {
if (cs.length == 0) {
return Collections.emptyList();
}
List r = new ArrayList();
int lineToIndex = 0;
int lineToLength = 0;
double scale = autoScale ? (extent / 256.0) : 1.0;
for (int i = 0; i < cs.length; i++) {
Coordinate c = cs[i];
if (i == 0) {
r.add(commandAndLength(Command.MoveTo, multiPoint ? cs.length : 1));
}
int _x = (int) Math.round(c.x * scale);
int _y = (int) Math.round(c.y * scale);
// prevent point equal to the previous
if (i > 0 && _x == x && _y == y) {
lineToLength--;
continue;
}
// prevent double closing
if (closePathAtEnd && cs.length > 1 && i == (cs.length - 1) && cs[0].equals(c)) {
lineToLength--;
continue;
}
// delta, then zigzag
r.add(zigZagEncode(_x - x));
r.add(zigZagEncode(_y - y));
x = _x;
y = _y;
if (i == 0 && cs.length > 1 && !multiPoint) {
// can length be too long?
lineToIndex = r.size();
lineToLength = cs.length - 1;
r.add(commandAndLength(Command.LineTo, lineToLength));
}
}
// update LineTo length
if (lineToIndex > 0) {
if (lineToLength == 0) {
// remove empty LineTo
r.remove(lineToIndex);
} else {
// update LineTo with new length
r.set(lineToIndex, commandAndLength(Command.LineTo, lineToLength));
}
}
if (closePathAtEnd) {
r.add(commandAndLength(Command.ClosePath, 1));
}
return r;
}
static int commandAndLength(int command, int repeat) {
return repeat << 3 | command;
}
static int zigZagEncode(int n) {
// https://developers.google.com/protocol-buffers/docs/encoding#types
return (n << 1) ^ (n >> 31);
}
private static final class Layer {
final List features = new ArrayList();
private final Map keys = new LinkedHashMap();
private final Map