better invalid geometry handling

pull/1/head
Mike Barry 2021-05-23 15:06:26 -04:00
rodzic d990784606
commit 2800c339f8
8 zmienionych plików z 219 dodań i 172 usunięć

Wyświetl plik

@ -23,6 +23,8 @@ import com.carrotsearch.hppc.IntArrayList;
import com.google.common.primitives.Ints;
import com.google.protobuf.InvalidProtocolBufferException;
import com.onthegomap.flatmap.geo.GeoUtils;
import com.onthegomap.flatmap.geo.GeometryException;
import com.onthegomap.flatmap.geo.TileCoord;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
@ -98,137 +100,145 @@ public class VectorTileEncoder {
return ((n >> 1) ^ (-(n & 1)));
}
private static Geometry decodeCommands(byte geomTypeByte, int[] commands) {
VectorTile.Tile.GeomType geomType = Objects.requireNonNull(VectorTile.Tile.GeomType.forNumber(geomTypeByte));
GeometryFactory gf = GeoUtils.JTS_FACTORY;
int x = 0;
int y = 0;
private static Geometry decodeCommands(byte geomTypeByte, int[] commands) throws GeometryException {
try {
VectorTile.Tile.GeomType geomType = Objects.requireNonNull(VectorTile.Tile.GeomType.forNumber(geomTypeByte));
GeometryFactory gf = GeoUtils.JTS_FACTORY;
int x = 0;
int y = 0;
List<DoubleArrayList> coordsList = new ArrayList<>();
DoubleArrayList coords = null;
List<DoubleArrayList> coordsList = new ArrayList<>();
DoubleArrayList coords = null;
int geometryCount = commands.length;
int length = 0;
int command = 0;
int i = 0;
while (i < geometryCount) {
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;
}
if (length > 0) {
if (command == Command.MOVE_TO.value) {
coords = new DoubleArrayList();
coordsList.add(coords);
} else {
Objects.requireNonNull(coords);
if (length <= 0) {
length = commands[i++];
command = length & ((1 << 3) - 1);
length = length >> 3;
}
if (command == Command.CLOSE_PATH.value) {
if (geomType != VectorTile.Tile.GeomType.POINT && !coords.isEmpty()) {
coords.add(coords.get(0), coords.get(1));
if (length > 0) {
if (command == Command.MOVE_TO.value) {
coords = new DoubleArrayList();
coordsList.add(coords);
} else {
Objects.requireNonNull(coords);
}
if (command == Command.CLOSE_PATH.value) {
if (geomType != VectorTile.Tile.GeomType.POINT && !coords.isEmpty()) {
coords.add(coords.get(0), coords.get(1));
}
length--;
continue;
}
int dx = commands[i++];
int dy = commands[i++];
length--;
continue;
dx = zigZagDecode(dx);
dy = zigZagDecode(dy);
x = x + dx;
y = y + dy;
coords.add(x / SCALE, y / SCALE);
}
int dx = commands[i++];
int dy = commands[i++];
length--;
dx = zigZagDecode(dx);
dy = zigZagDecode(dy);
x = x + dx;
y = y + dy;
coords.add(x / SCALE, y / SCALE);
}
Geometry geometry = null;
boolean outerCCW = false;
switch (geomType) {
case LINESTRING:
List<LineString> lineStrings = new ArrayList<>(coordsList.size());
for (DoubleArrayList cs : coordsList) {
if (cs.size() <= 2) {
continue;
}
lineStrings.add(gf.createLineString(toCs(cs)));
}
if (lineStrings.size() == 1) {
geometry = lineStrings.get(0);
} else if (lineStrings.size() > 1) {
geometry = gf.createMultiLineString(lineStrings.toArray(new LineString[0]));
}
break;
case POINT:
CoordinateSequence cs = new PackedCoordinateSequence.Double(coordsList.size(), 2, 0);
for (int j = 0; j < coordsList.size(); j++) {
cs.setOrdinate(j, 0, coordsList.get(j).get(0));
cs.setOrdinate(j, 1, coordsList.get(j).get(1));
}
if (cs.size() == 1) {
geometry = gf.createPoint(cs);
} else if (cs.size() > 1) {
geometry = gf.createMultiPoint(cs);
}
break;
case POLYGON:
List<List<LinearRing>> polygonRings = new ArrayList<>();
List<LinearRing> ringsForCurrentPolygon = new ArrayList<>();
boolean first = true;
for (DoubleArrayList clist : coordsList) {
// skip hole with too few coordinates
if (ringsForCurrentPolygon.size() > 0 && clist.size() < 4) {
continue;
}
LinearRing ring = gf.createLinearRing(toCs(clist));
boolean ccw = Orientation.isCCW(ring.getCoordinates());
if (first) {
first = false;
outerCCW = ccw;
assert outerCCW;
}
if (ccw == outerCCW) {
ringsForCurrentPolygon = new ArrayList<>();
polygonRings.add(ringsForCurrentPolygon);
}
ringsForCurrentPolygon.add(ring);
}
List<Polygon> polygons = new ArrayList<>();
for (List<LinearRing> rings : polygonRings) {
LinearRing shell = rings.get(0);
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.get(0);
}
if (polygons.size() > 1) {
geometry = gf.createMultiPolygon(GeometryFactory.toPolygonArray(polygons));
}
break;
default:
break;
}
if (geometry == null) {
geometry = gf.createGeometryCollection(new Geometry[0]);
}
return geometry;
} catch (IllegalArgumentException e) {
throw new GeometryException("Unable to decode geometry", e);
}
Geometry geometry = null;
boolean outerCCW = false;
switch (geomType) {
case LINESTRING:
List<LineString> lineStrings = new ArrayList<>(coordsList.size());
for (DoubleArrayList cs : coordsList) {
if (cs.size() <= 2) {
continue;
}
lineStrings.add(gf.createLineString(toCs(cs)));
}
if (lineStrings.size() == 1) {
geometry = lineStrings.get(0);
} else if (lineStrings.size() > 1) {
geometry = gf.createMultiLineString(lineStrings.toArray(new LineString[0]));
}
break;
case POINT:
CoordinateSequence cs = new PackedCoordinateSequence.Double(coordsList.size(), 2, 0);
for (int j = 0; j < coordsList.size(); j++) {
cs.setOrdinate(j, 0, coordsList.get(j).get(0));
cs.setOrdinate(j, 1, coordsList.get(j).get(1));
}
if (cs.size() == 1) {
geometry = gf.createPoint(cs);
} else if (cs.size() > 1) {
geometry = gf.createMultiPoint(cs);
}
break;
case POLYGON:
List<List<LinearRing>> polygonRings = new ArrayList<>();
List<LinearRing> ringsForCurrentPolygon = new ArrayList<>();
boolean first = true;
for (DoubleArrayList clist : coordsList) {
// skip hole with too few coordinates
if (ringsForCurrentPolygon.size() > 0 && clist.size() < 4) {
continue;
}
LinearRing ring = gf.createLinearRing(toCs(clist));
boolean ccw = Orientation.isCCW(ring.getCoordinates());
if (first) {
first = false;
outerCCW = ccw;
assert outerCCW;
}
if (ccw == outerCCW) {
ringsForCurrentPolygon = new ArrayList<>();
polygonRings.add(ringsForCurrentPolygon);
}
ringsForCurrentPolygon.add(ring);
}
List<Polygon> polygons = new ArrayList<>();
for (List<LinearRing> rings : polygonRings) {
LinearRing shell = rings.get(0);
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.get(0);
}
if (polygons.size() > 1) {
geometry = gf.createMultiPolygon(GeometryFactory.toPolygonArray(polygons));
}
break;
default:
break;
}
if (geometry == null) {
geometry = gf.createGeometryCollection(new Geometry[0]);
}
return geometry;
}
public static List<Feature> decode(byte[] encoded) {
return decode(TileCoord.ofXYZ(0, 0, 0), encoded);
}
public static List<Feature> decode(TileCoord tileID, byte[] encoded) {
try {
VectorTile.Tile tile = VectorTile.Tile.parseFrom(encoded);
List<Feature> features = new ArrayList<>();
@ -267,13 +277,17 @@ public class VectorTileEncoder {
Object value = values.get(feature.getTags(tagIdx++));
attrs.put(key, value);
}
Geometry geometry = decodeCommands(feature.getType(), feature.getGeometryList());
features.add(new Feature(
layerName,
feature.getId(),
encodeGeometry(geometry),
attrs
));
try {
Geometry geometry = decodeCommands(feature.getType(), feature.getGeometryList());
features.add(new Feature(
layerName,
feature.getId(),
encodeGeometry(geometry),
attrs
));
} catch (GeometryException e) {
LOGGER.warn("Error decoding " + tileID + ": " + e);
}
}
}
return features;
@ -282,7 +296,7 @@ public class VectorTileEncoder {
}
}
private static Geometry decodeCommands(GeomType type, List<Integer> geometryList) {
private static Geometry decodeCommands(GeomType type, List<Integer> geometryList) throws GeometryException {
return decodeCommands((byte) type.getNumber(), geometryList.stream().mapToInt(i -> i).toArray());
}
@ -387,7 +401,7 @@ public class VectorTileEncoder {
public static record VectorGeometry(int[] commands, byte geomType) {
public Geometry decode() {
public Geometry decode() throws GeometryException {
return decodeCommands(geomType, commands);
}

Wyświetl plik

@ -1,6 +1,7 @@
package com.onthegomap.flatmap.render;
import com.onthegomap.flatmap.geo.GeoUtils;
import com.onthegomap.flatmap.geo.GeometryException;
import java.util.ArrayList;
import java.util.List;
import org.jetbrains.annotations.NotNull;
@ -73,14 +74,16 @@ class CoordinateSequenceExtractor {
List<LineString> lineStrings = new ArrayList<>();
for (List<CoordinateSequence> inner : geoms) {
for (CoordinateSequence coordinateSequence : inner) {
lineStrings.add(GeoUtils.JTS_FACTORY.createLineString(coordinateSequence));
if (coordinateSequence.size() > 1) {
lineStrings.add(GeoUtils.JTS_FACTORY.createLineString(coordinateSequence));
}
}
}
return lineStrings.size() == 1 ? lineStrings.get(0) : GeoUtils.createMultiLineString(lineStrings);
}
@NotNull
static Geometry reassemblePolygons(List<List<CoordinateSequence>> groups) {
static Geometry reassemblePolygons(List<List<CoordinateSequence>> groups) throws GeometryException {
int numGeoms = groups.size();
if (numGeoms == 1) {
return reassemblePolygon(groups.get(0));
@ -93,15 +96,19 @@ class CoordinateSequenceExtractor {
}
}
private static Polygon reassemblePolygon(List<CoordinateSequence> 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);
private static Polygon reassemblePolygon(List<CoordinateSequence> group) throws GeometryException {
try {
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);
} catch (IllegalArgumentException e) {
throw new GeometryException("Could not build polygon", e);
}
return GeoUtils.JTS_FACTORY.createPolygon(first, rest);
}
static Geometry reassemblePoints(List<List<CoordinateSequence>> result) {

Wyświetl plik

@ -172,15 +172,13 @@ public class FeatureRenderer {
if (feature.area()) {
geom = CoordinateSequenceExtractor.reassemblePolygons(geoms);
geom = GeoUtils.snapAndFixPolygon(geom, tilePrecision);
// JTS utilities "fix" the geometry to be clockwise outer/CCW inner
geom = geom.reverse();
} else {
geom = CoordinateSequenceExtractor.reassembleLineStrings(geoms);
}
if (!geom.isEmpty()) {
// JTS utilities "fix" the geometry to be clockwise outer/CCW inner
if (feature.area()) {
geom = geom.reverse();
}
emitFeature(feature, id, attrs, tile, geom, null);
}
} catch (GeometryException e) {

Wyświetl plik

@ -231,20 +231,20 @@ class TiledGeometry {
IntObjectMap<MutableCoordinateSequence> xSlices = new GHIntObjectHashMap<>();
int end = segment.size() - 1;
for (int i = 0; i < end; i++) {
double _ax = segment.getX(i);
double ax = segment.getX(i);
double ay = segment.getY(i);
double _bx = segment.getX(i + 1);
double bx = segment.getX(i + 1);
double by = segment.getY(i + 1);
double minX = Math.min(_ax, _bx);
double maxX = Math.max(_ax, _bx);
double minX = Math.min(ax, bx);
double maxX = Math.max(ax, bx);
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;
double bx = _bx - x;
double axTile = ax - x;
double bxTile = bx - x;
MutableCoordinateSequence slice = xSlices.get(x);
if (slice == null) {
xSlices.put(x, slice = new MutableCoordinateSequence());
@ -257,27 +257,27 @@ class TiledGeometry {
boolean exited = false;
if (ax < k1) {
if (axTile < k1) {
// ---|--> | (line enters the clip region from the left)
if (bx > k1) {
intersectX(slice, ax, ay, bx, by, k1);
if (bxTile > k1) {
intersectX(slice, axTile, ay, bxTile, by, k1);
}
} else if (ax > k2) {
} else if (axTile > k2) {
// | <--|--- (line enters the clip region from the right)
if (bx < k2) {
intersectX(slice, ax, ay, bx, by, k2);
if (bxTile < k2) {
intersectX(slice, axTile, ay, bxTile, by, k2);
}
} else {
slice.addPoint(ax, ay);
slice.addPoint(axTile, ay);
}
if (bx < k1 && ax >= k1) {
if (bxTile < k1 && axTile >= k1) {
// <--|--- | or <--|-----|--- (line exits the clip region on the left)
intersectX(slice, ax, ay, bx, by, k1);
intersectX(slice, axTile, ay, bxTile, by, k1);
exited = true;
}
if (bx > k2 && ax <= k2) {
if (bxTile > k2 && axTile <= k2) {
// | ---|--> or ---|-----|--> (line exits the clip region on the right)
intersectX(slice, ax, ay, bx, by, k2);
intersectX(slice, axTile, ay, bxTile, by, k2);
exited = true;
}
@ -287,16 +287,16 @@ class TiledGeometry {
}
}
// add the last point
double _ax = segment.getX(segment.size() - 1);
double ax = segment.getX(segment.size() - 1);
double ay = segment.getY(segment.size() - 1);
int startX = (int) Math.floor(_ax - neighborBuffer);
int endX = (int) Math.floor(_ax + neighborBuffer);
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;
double axTile = ax - x;
MutableCoordinateSequence slice = xSlices.get(x);
if (slice != null && ax >= k1 && ax <= k2) {
slice.addPoint(ax, ay);
if (slice != null && axTile >= k1 && axTile <= k2) {
slice.addPoint(axTile, ay);
}
}

Wyświetl plik

@ -8,6 +8,7 @@ import static org.junit.jupiter.api.Assertions.fail;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.onthegomap.flatmap.geo.GeoUtils;
import com.onthegomap.flatmap.geo.GeometryException;
import com.onthegomap.flatmap.geo.TileCoord;
import com.onthegomap.flatmap.write.Mbtiles;
import java.io.ByteArrayInputStream;
@ -176,13 +177,21 @@ public class TestUtils {
Map<TileCoord, List<ComparableFeature>> tiles = new TreeMap<>();
for (var tile : getAllTiles(db)) {
var bytes = gunzip(tile.bytes());
var decoded = VectorTileEncoder.decode(bytes).stream()
.map(feature -> feature(feature.geometry().decode(), feature.attrs())).toList();
var decoded = VectorTileEncoder.decode(tile.tile(), bytes).stream()
.map(feature -> feature(decodeSilently(feature.geometry()), feature.attrs())).toList();
tiles.put(tile.tile(), decoded);
}
return tiles;
}
public static Geometry decodeSilently(VectorTileEncoder.VectorGeometry geom) {
try {
return geom.decode();
} catch (GeometryException e) {
throw new RuntimeException(e);
}
}
public static Set<Mbtiles.TileEntry> getAllTiles(Mbtiles db) throws SQLException {
Set<Mbtiles.TileEntry> result = new HashSet<>();
try (Statement statement = db.connection().createStatement()) {

Wyświetl plik

@ -19,6 +19,7 @@
package com.onthegomap.flatmap;
import static com.onthegomap.flatmap.TestUtils.TRANSFORM_TO_TILE;
import static com.onthegomap.flatmap.TestUtils.decodeSilently;
import static com.onthegomap.flatmap.TestUtils.newGeometryCollection;
import static com.onthegomap.flatmap.TestUtils.newMultiPoint;
import static com.onthegomap.flatmap.TestUtils.newMultiPolygon;
@ -30,6 +31,7 @@ import static org.junit.jupiter.api.Assertions.assertNotSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.google.common.primitives.Ints;
import com.onthegomap.flatmap.geo.TileCoord;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@ -118,7 +120,7 @@ public class VectorTileEncoderTest {
byte[] encoded = vtm.encode();
assertNotSame(0, encoded.length);
var decoded = VectorTileEncoder.decode(encoded);
var decoded = VectorTileEncoder.decode(TileCoord.ofXYZ(0, 0, 0), encoded);
assertEquals(List
.of(new VectorTileEncoder.Feature("DEPCNT", 1, VectorTileEncoder.encodeGeometry(newPoint(3, 6)), Map.of(
"key1", "value1",
@ -209,7 +211,7 @@ public class VectorTileEncoderTest {
var features = VectorTileEncoder.decode(encoded);
assertEquals(1, features.size());
MultiPolygon mp2 = (MultiPolygon) features.get(0).geometry().decode();
MultiPolygon mp2 = (MultiPolygon) decodeSilently(features.get(0).geometry());
assertEquals(mp.getNumGeometries(), mp2.getNumGeometries());
}
@ -339,7 +341,7 @@ public class VectorTileEncoderTest {
private void testRoundTrip(Geometry input, String layer, Map<String, Object> attrs, long id) {
VectorTileEncoder.VectorGeometry encodedGeom = VectorTileEncoder.encodeGeometry(input);
Geometry output = encodedGeom.decode();
Geometry output = decodeSilently(encodedGeom);
assertTrue(input.equalsExact(output), "\n" + input + "\n!=\n" + output);
byte[] encoded = new VectorTileEncoder().addLayerFeatures(layer, List.of(
@ -354,6 +356,6 @@ public class VectorTileEncoderTest {
}
private void assertSameGeometries(List<Geometry> expected, List<VectorTileEncoder.Feature> actual) {
assertEquals(expected, actual.stream().map(d -> d.geometry().decode()).toList());
assertEquals(expected, actual.stream().map(d -> decodeSilently(d.geometry())).toList());
}
}

Wyświetl plik

@ -1,5 +1,6 @@
package com.onthegomap.flatmap.collections;
import static com.onthegomap.flatmap.TestUtils.decodeSilently;
import static com.onthegomap.flatmap.TestUtils.newPoint;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
@ -69,10 +70,10 @@ public class FeatureGroupTest {
private Map<Integer, Map<String, List<Feature>>> getFeatures() {
Map<Integer, Map<String, List<Feature>>> map = new TreeMap<>();
for (FeatureGroup.TileFeatures tile : features) {
for (var feature : VectorTileEncoder.decode(tile.getTile().encode())) {
for (var feature : VectorTileEncoder.decode(tile.coord(), tile.getTile().encode())) {
map.computeIfAbsent(tile.coord().encoded(), (i) -> new TreeMap<>())
.computeIfAbsent(feature.layer(), l -> new ArrayList<>())
.add(new Feature(feature.attrs(), feature.geometry().decode()));
.add(new Feature(feature.attrs(), decodeSilently(feature.geometry())));
}
}
return map;

Wyświetl plik

@ -2,6 +2,7 @@ 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.decodeSilently;
import static com.onthegomap.flatmap.TestUtils.emptyGeometry;
import static com.onthegomap.flatmap.TestUtils.newLineString;
import static com.onthegomap.flatmap.TestUtils.newMultiLineString;
@ -60,7 +61,7 @@ public class FeatureRendererTest {
private Map<TileCoord, Collection<Geometry>> renderGeometry(FeatureCollector.Feature feature) {
Map<TileCoord, Collection<Geometry>> result = new TreeMap<>();
new FeatureRenderer(config, rendered -> result.computeIfAbsent(rendered.tile(), tile -> new HashSet<>())
.add(rendered.vectorTileFeature().geometry().decode())).renderFeature(feature);
.add(decodeSilently(rendered.vectorTileFeature().geometry()))).renderFeature(feature);
result.values().forEach(gs -> gs.forEach(TestUtils::validateGeometry));
return result;
}
@ -70,7 +71,7 @@ public class FeatureRendererTest {
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())));
.forEach(gs -> gs.forEach(f -> TestUtils.validateGeometry(decodeSilently(f.vectorTileFeature().geometry()))));
return result;
}
@ -456,6 +457,21 @@ public class FeatureRendererTest {
), renderGeometry(feature));
}
@Test
public void testLineStringCollapsesToPointWithRounding() {
var eps = Z14_WIDTH / 4096;
var pixel = Z14_WIDTH / 256;
var feature = lineFeature(newLineString(
0.5 + pixel * 10, 0.5 + pixel * 10,
0.5 + pixel * 10 + eps / 3, 0.5 + pixel * 10
))
.setMinPixelSize(1)
.setZoomRange(14, 14)
.setBufferPixels(0)
.setPixelToleranceAtAllZooms(0);
assertExactSameFeatures(Map.of(), renderGeometry(feature));
}
/*
* POLYGON TESTS
*/