/* ****************************************************************
* 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 com.onthegomap.planetiler;
import com.carrotsearch.hppc.IntArrayList;
import com.google.common.primitives.Ints;
import com.google.protobuf.InvalidProtocolBufferException;
import com.onthegomap.planetiler.collection.FeatureGroup;
import com.onthegomap.planetiler.geo.GeoUtils;
import com.onthegomap.planetiler.geo.GeometryException;
import com.onthegomap.planetiler.geo.GeometryType;
import com.onthegomap.planetiler.geo.MutableCoordinateSequence;
import com.onthegomap.planetiler.util.Hilbert;
import com.onthegomap.planetiler.util.LayerAttrStats;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.TreeMap;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import javax.annotation.concurrent.NotThreadSafe;
import org.locationtech.jts.algorithm.Orientation;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.CoordinateSequence;
import org.locationtech.jts.geom.Geometry;
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.Puntal;
import org.locationtech.jts.geom.impl.CoordinateArraySequence;
import org.locationtech.jts.geom.impl.PackedCoordinateSequence;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import vector_tile.VectorTileProto;
/**
* Encodes a single output tile containing JTS {@link Geometry} features into the compact binary Mapbox Vector Tile
* format.
*
* This class is copied from VectorTileEncoder.java
* and VectorTileDecoder.java
* and modified to decouple geometry encoding from vector tile encoding so that encoded commands can be stored in the
* sorted feature map prior to encoding vector tiles. The internals are also refactored to improve performance by using
* hppc primitive collections.
*
* @see Mapbox Vector Tile Specification
*/
@NotThreadSafe
public class VectorTile {
public static final long NO_FEATURE_ID = 0;
private static final Logger LOGGER = LoggerFactory.getLogger(VectorTile.class);
// TODO make these configurable
private static final int EXTENT = 4096;
private static final double SIZE = 256d;
// use a treemap to ensure that layers are encoded in a consistent order
private final Map layers = new TreeMap<>();
private LayerAttrStats.Updater.ForZoom layerStatsTracker = LayerAttrStats.Updater.ForZoom.NOOP;
private static int[] getCommands(Geometry input, int scale) {
var encoder = new CommandEncoder(scale);
encoder.accept(input);
return encoder.result.toArray();
}
/**
* Scales a geometry down by a factor of {@code 2^scale} without materializing an intermediate JTS geometry and
* returns the encoded result.
*/
private static int[] unscale(int[] commands, int scale, GeometryType geomType) {
IntArrayList result = new IntArrayList();
int geometryCount = commands.length;
int length = 0;
int command = 0;
int i = 0;
int inX = 0, inY = 0;
int outX = 0, outY = 0;
int startX = 0, startY = 0;
double scaleFactor = Math.pow(2, -scale);
int lengthIdx = 0;
int moveToIdx = 0;
int pointsInShape = 0;
boolean first = true;
while (i < geometryCount) {
if (length <= 0) {
length = commands[i++];
lengthIdx = result.size();
result.add(length);
command = length & ((1 << 3) - 1);
length = length >> 3;
}
if (length > 0) {
if (command == Command.MOVE_TO.value) {
// degenerate geometry, remove it from output entirely
if (!first && pointsInShape < geomType.minPoints()) {
int prevCommand = result.get(lengthIdx);
result.elementsCount = moveToIdx;
result.add(prevCommand);
// reset deltas
outX = startX;
outY = startY;
}
// keep track of size of next shape...
pointsInShape = 0;
startX = outX;
startY = outY;
moveToIdx = result.size() - 1;
}
first = false;
if (command == Command.CLOSE_PATH.value) {
pointsInShape++;
length--;
continue;
}
int dx = commands[i++];
int dy = commands[i++];
length--;
dx = zigZagDecode(dx);
dy = zigZagDecode(dy);
inX = inX + dx;
inY = inY + dy;
int nextX = (int) Math.round(inX * scaleFactor);
int nextY = (int) Math.round(inY * scaleFactor);
if (nextX == outX && nextY == outY && command == Command.LINE_TO.value) {
int commandLength = result.get(lengthIdx) - 8;
if (commandLength < 8) {
// get rid of lineto section if empty
result.elementsCount = lengthIdx;
} else {
result.set(lengthIdx, commandLength);
}
} else {
pointsInShape++;
int dxOut = nextX - outX;
int dyOut = nextY - outY;
result.add(
zigZagEncode(dxOut),
zigZagEncode(dyOut)
);
outX = nextX;
outY = nextY;
}
}
}
// degenerate geometry, remove it from output entirely
if (pointsInShape < geomType.minPoints()) {
result.elementsCount = moveToIdx;
}
return result.toArray();
}
static int zigZagEncode(int n) {
// https://developers.google.com/protocol-buffers/docs/encoding#types
return (n << 1) ^ (n >> 31);
}
private static int zigZagDecode(int n) {
// https://developers.google.com/protocol-buffers/docs/encoding#types
return ((n >> 1) ^ (-(n & 1)));
}
private static Geometry decodeCommands(GeometryType geomType, int[] commands, int scale) throws GeometryException {
try {
GeometryFactory gf = GeoUtils.JTS_FACTORY;
double SCALE = (EXTENT << scale) / SIZE;
int x = 0;
int y = 0;
List allCoordSeqs = new ArrayList<>();
MutableCoordinateSequence currentCoordSeq = null;
int geometryCount = commands.length;
int length = 0;
int command = 0;
int i = 0;
while (i < geometryCount) {
if (length <= 0) {
length = commands[i++];
command = length & ((1 << 3) - 1);
length = length >> 3;
assert geomType != GeometryType.POINT || i == 1 : "Invalid multipoint, command found at index %d, expected 0"
.formatted(i);
assert geomType != GeometryType.POINT ||
(length * 2 + 1 == geometryCount) : "Invalid multipoint: int[%d] length=%d".formatted(geometryCount,
length);
}
if (length > 0) {
if (command == Command.MOVE_TO.value) {
currentCoordSeq = new MutableCoordinateSequence();
allCoordSeqs.add(currentCoordSeq);
} else {
assert currentCoordSeq != null;
}
if (command == Command.CLOSE_PATH.value) {
if (geomType != GeometryType.POINT && !currentCoordSeq.isEmpty()) {
currentCoordSeq.closeRing();
}
length--;
continue;
}
int dx = commands[i++];
int dy = commands[i++];
length--;
dx = zigZagDecode(dx);
dy = zigZagDecode(dy);
x = x + dx;
y = y + dy;
currentCoordSeq.forceAddPoint(x / SCALE, y / SCALE);
}
}
Geometry geometry = null;
boolean outerCCW = false;
switch (geomType) {
case LINE -> {
List lineStrings = new ArrayList<>(allCoordSeqs.size());
for (MutableCoordinateSequence coordSeq : allCoordSeqs) {
if (coordSeq.size() <= 1) {
continue;
}
lineStrings.add(gf.createLineString(coordSeq));
}
if (lineStrings.size() == 1) {
geometry = lineStrings.getFirst();
} else if (lineStrings.size() > 1) {
geometry = gf.createMultiLineString(lineStrings.toArray(new LineString[0]));
}
}
case POINT -> {
CoordinateSequence cs = new PackedCoordinateSequence.Double(allCoordSeqs.size(), 2, 0);
for (int j = 0; j < allCoordSeqs.size(); j++) {
MutableCoordinateSequence coordSeq = allCoordSeqs.get(j);
cs.setOrdinate(j, 0, coordSeq.getX(0));
cs.setOrdinate(j, 1, coordSeq.getY(0));
}
if (cs.size() == 1) {
geometry = gf.createPoint(cs);
} else if (cs.size() > 1) {
geometry = gf.createMultiPoint(cs);
}
}
case POLYGON -> {
List> polygonRings = new ArrayList<>();
List ringsForCurrentPolygon = new ArrayList<>();
boolean first = true;
for (MutableCoordinateSequence coordSeq : allCoordSeqs) {
// skip hole with too few coordinates
if (ringsForCurrentPolygon.size() > 0 && coordSeq.size() < 2) {
continue;
}
LinearRing ring = gf.createLinearRing(coordSeq);
boolean ccw = Orientation.isCCW(coordSeq);
if (first) {
first = false;
outerCCW = ccw;
assert outerCCW : "outer ring is not counter-clockwise";
}
if (ccw == outerCCW) {
ringsForCurrentPolygon = new ArrayList<>();
polygonRings.add(ringsForCurrentPolygon);
}
ringsForCurrentPolygon.add(ring);
}
List polygons = new ArrayList<>();
for (List rings : polygonRings) {
LinearRing shell = rings.getFirst();
LinearRing[] holes = rings.subList(1, rings.size()).toArray(new LinearRing[rings.size() - 1]);
polygons.add(gf.createPolygon(shell, holes));
}
if (polygons.size() == 1) {
geometry = polygons.getFirst();
}
if (polygons.size() > 1) {
geometry = gf.createMultiPolygon(GeometryFactory.toPolygonArray(polygons));
}
}
default -> {
}
}
if (geometry == null) {
geometry = GeoUtils.EMPTY_GEOMETRY;
}
return geometry;
} catch (IllegalArgumentException e) {
throw new GeometryException("decode_vector_tile", "Unable to decode geometry", e);
}
}
/**
* Parses a binary-encoded vector tile protobuf into a list of features.
*
* Does not decode geometries, but clients can call {@link VectorGeometry#decode()} to decode a JTS {@link Geometry}
* if needed.
*
* If {@code encoded} is compressed, clients must decompress it first.
*
* @param encoded encoded vector tile protobuf
* @return list of features on that tile
* @throws IllegalStateException if decoding fails
* @throws IndexOutOfBoundsException if a tag's key or value refers to an index that does not exist in the keys/values
* array for a layer
*/
public static List decode(byte[] encoded) {
try {
VectorTileProto.Tile tile = VectorTileProto.Tile.parseFrom(encoded);
List features = new ArrayList<>();
for (VectorTileProto.Tile.Layer layer : tile.getLayersList()) {
String layerName = layer.getName();
assert layer.getExtent() == 4096;
List keys = layer.getKeysList();
List