Skip to content

Commit

Permalink
Normalize merged fill polygons (#1173)
Browse files Browse the repository at this point in the history
  • Loading branch information
msbarry authored Feb 11, 2025
1 parent 9bb4c54 commit 9345224
Show file tree
Hide file tree
Showing 3 changed files with 168 additions and 38 deletions.
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
package com.onthegomap.planetiler;

import static com.onthegomap.planetiler.VectorTile.ALL;
import static com.onthegomap.planetiler.VectorTile.VectorGeometry.getSide;
import static com.onthegomap.planetiler.VectorTile.VectorGeometry.segmentCrossesTile;

import com.carrotsearch.hppc.IntArrayList;
import com.carrotsearch.hppc.IntObjectMap;
import com.carrotsearch.hppc.IntStack;
Expand Down Expand Up @@ -479,20 +483,27 @@ private static Geometry buffer(double buffer, Geometry merged) {
*/
private static void extractPolygons(Geometry geom, List<Polygon> result, double minArea, double minHoleArea) {
if (geom instanceof Polygon poly) {
if (Area.ofRing(poly.getExteriorRing().getCoordinateSequence()) > minArea) {
double outerArea = Area.ofRing(poly.getExteriorRing().getCoordinateSequence());
if (outerArea > minArea) {
int innerRings = poly.getNumInteriorRing();
if (minHoleArea > 0 && innerRings > 0) {
List<LinearRing> rings = new ArrayList<>(innerRings);
for (int i = 0; i < innerRings; i++) {
LinearRing innerRing = poly.getInteriorRingN(i);
if (Area.ofRing(innerRing.getCoordinateSequence()) > minArea) {
rings.add(innerRing);
}
}
if (rings.size() != innerRings) {
poly = GeoUtils.createPolygon(poly.getExteriorRing(), rings);
List<LinearRing> rings = innerRings == 0 ? List.of() : new ArrayList<>(innerRings);
for (int i = 0; i < innerRings; i++) {
LinearRing innerRing = poly.getInteriorRingN(i);
if (minHoleArea <= 0 || Area.ofRing(innerRing.getCoordinateSequence()) > minArea) {
rings.add(innerRing);
}
}
LinearRing exteriorRing = poly.getExteriorRing();
/* optimization: when merged polygon fill the entire tile, replace it with a canonical fill geometry to ensure
* that filled tiles are byte-for-byte equivalent. This allows archives that deduplicate tiles to better compress
* large filled areas like the ocean. */
double fillBuffer = isFill(outerArea, exteriorRing);
if (fillBuffer >= 0) {
exteriorRing = createFill(fillBuffer);
}
if (rings.size() != innerRings || exteriorRing != poly.getExteriorRing()) {
poly = GeoUtils.createPolygon(exteriorRing, rings);
}
result.add(poly);
}
} else if (geom instanceof GeometryCollection) {
Expand All @@ -502,6 +513,42 @@ private static void extractPolygons(Geometry geom, List<Polygon> result, double
}
}

private static final double NOT_FILL = -1;

/** If {@ocde exteriorRing} fills the entire tile, return the number of pixels that it overhangs, otherwise -1. */
private static double isFill(double outerArea, LinearRing exteriorRing) {
if (outerArea < 256 * 256) {
return NOT_FILL;
}
double proposedBuffer = (Math.sqrt(outerArea) - 256) / 2;
double min = -(proposedBuffer * 0.9);
double max = 256 + proposedBuffer * 0.9;
int visited = 0;
var cs = exteriorRing.getCoordinateSequence();
int nextSide = getSide(cs.getX(0), cs.getY(0), min, max);
for (int i = 0; i < cs.size() - 1; i++) {
int side = nextSide;
visited |= side;
nextSide = getSide(cs.getX(i + 1), cs.getY(i + 1), min, max);
if (segmentCrossesTile(side, nextSide)) {
return NOT_FILL;
}
}
return visited == ALL ? proposedBuffer : NOT_FILL;
}

private static LinearRing createFill(double buffer) {
double min = -buffer;
double max = buffer + 256;
return GeoUtils.JTS_FACTORY.createLinearRing(GeoUtils.coordinateSequence(
min, min,
max, min,
max, max,
min, max,
min, min
));
}

/** Returns a map from index in {@code geometries} to index of every other geometry within {@code minDist}. */
private static IntObjectMap<IntArrayList> extractAdjacencyList(List<Geometry> geometries, double minDist) {
STRtree envelopeIndex = new STRtree();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,14 @@
*/
@NotThreadSafe
public class VectorTile {

public static final int LEFT = 1;
public static final int RIGHT = 1 << 1;
public static final int TOP = 1 << 2;
public static final int BOTTOM = 1 << 3;
public static final int INSIDE = 0;
public static final int ALL = TOP | LEFT | RIGHT | BOTTOM;

public static final long NO_FEATURE_ID = 0;

private static final Logger LOGGER = LoggerFactory.getLogger(VectorTile.class);
Expand Down Expand Up @@ -734,12 +742,6 @@ public VectorGeometry finish() {
*/
public record VectorGeometry(int[] commands, GeometryType geomType, int scale) {

private static final int LEFT = 1;
private static final int RIGHT = 1 << 1;
private static final int TOP = 1 << 2;
private static final int BOTTOM = 1 << 3;
private static final int INSIDE = 0;
private static final int ALL = TOP | LEFT | RIGHT | BOTTOM;
private static final VectorGeometry EMPTY_POINT = new VectorGeometry(new int[0], GeometryType.POINT, 0);

public VectorGeometry {
Expand All @@ -748,34 +750,40 @@ public record VectorGeometry(int[] commands, GeometryType geomType, int scale) {
}
}

private static int getSide(int x, int y, int extent) {
int result = INSIDE;
if (x < 0) {
result |= LEFT;
} else if (x > extent) {
result |= RIGHT;
}
if (y < 0) {
result |= TOP;
} else if (y > extent) {
result |= BOTTOM;
}
return result;
/**
* Returns {@link #LEFT} or {@link #RIGHT} bitwise OR'd with {@link #TOP} or {@link #BOTTOM} to indicate which side
* of the rectangle from {@code (x=min, y=min)} to {@code (x=max, y=max)} the {@code (x,y)} point falls in.
*/
public static int getSide(int x, int y, int min, int max) {
return (x < min ? LEFT : x > max ? RIGHT : INSIDE) |
(y < min ? TOP : y > max ? BOTTOM : INSIDE);
}

/**
* Returns {@link #LEFT} or {@link #RIGHT} bitwise OR'd with {@link #TOP} or {@link #BOTTOM} to indicate which side
* of the rectangle from {@code (x=min, y=min)} to {@code (x=max, y=max)} the {@code (x,y)} point falls in.
*/
public static int getSide(double x, double y, double min, double max) {
return (x < min ? LEFT : x > max ? RIGHT : INSIDE) |
(y < min ? TOP : y > max ? BOTTOM : INSIDE);
}

private static boolean slanted(int x1, int y1, int x2, int y2) {
return x1 != x2 && y1 != y2;
}

private static boolean segmentCrossesTile(int x1, int y1, int x2, int y2, int extent) {
return (y1 >= 0 || y2 >= 0) &&
(y1 <= extent || y2 <= extent) &&
(x1 >= 0 || x2 >= 0) &&
(x1 <= extent || x2 <= extent);

/**
* Returns {@code false} if the segment from a point in {@code side1} to {@code side2} is definitely outside of the
* rectangle, or true if it might cross the inside where {@code side1} and {@code side2} are from
* {@link #getSide(int, int, int, int)}.
*/
public static boolean segmentCrossesTile(int side1, int side2) {
return (side1 & side2) == 0;
}

private static boolean isSegmentInvalid(boolean allowEdges, int x1, int y1, int x2, int y2, int extent) {
boolean crossesTile = segmentCrossesTile(x1, y1, x2, y2, extent);
boolean crossesTile = segmentCrossesTile(getSide(x1, y1, 0, extent), getSide(x2, y2, 0, extent));
if (allowEdges) {
return crossesTile && slanted(x1, y1, x2, y2);
} else {
Expand Down Expand Up @@ -904,14 +912,14 @@ public boolean isFillOrEdge(boolean allowEdges) {
if (command == Command.MOVE_TO.value) {
firstX = nextX;
firstY = nextY;
if ((visited = getSide(firstX, firstY, extent)) == INSIDE) {
if ((visited = getSide(firstX, firstY, 0, extent)) == INSIDE) {
return false;
}
} else {
if (isSegmentInvalid(allowEdges, x, y, nextX, nextY, extent)) {
return false;
}
visited |= getSide(nextX, nextY, extent);
visited |= getSide(nextX, nextY, 0, extent);
}
y = nextY;
x = nextX;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import static com.onthegomap.planetiler.TestUtils.*;
import static com.onthegomap.planetiler.util.Gzip.gunzip;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.fail;

import com.carrotsearch.hppc.IntArrayList;
import com.carrotsearch.hppc.IntObjectMap;
Expand Down Expand Up @@ -965,4 +966,78 @@ void testIssue700BufferUnionUnbufferFailure(String path) throws IOException, Par
FeatureMerge.bufferUnionUnbuffer(0.5, geometries, Stats.inMemory());
}
}

@Test
void mergeFillPolygonsNormalizes() throws GeometryException {
assertEquals(
List.of(
rectangle(-2, 258)
),
FeatureMerge.mergeNearbyPolygons(
List.of(
feature(1, rectangle(-2, -2, 200, 258), Map.of()),
feature(2, rectangle(180, -2, 258, 258), Map.of())
),
0,
0,
0,
0
).stream().map(feature -> {
try {
return feature.geometry().decode();
} catch (GeometryException e) {
return fail(e);
}
}).toList()
);
}

@Test
void mergeNormalizeOuterRing() throws GeometryException {
var result = FeatureMerge.mergeNearbyPolygons(
List.of(
feature(1, rectangle(-2, -2, 10, 258), Map.of()),
feature(1, rectangle(-2, -2, 258, 10), Map.of()),
feature(1, rectangle(246, -2, 258, 258), Map.of()),
feature(1, rectangle(-2, 246, 258, 258), Map.of())
),
0,
0,
0,
0
);
Polygon poly = (Polygon) result.getFirst().geometry().decode();
assertEquals(rectangle(-2, 258).getExteriorRing(), poly.getExteriorRing());
assertEquals(1, poly.getNumInteriorRing());
assertTopologicallyEquivalentFeature(rectangle(10, 246).getExteriorRing().reverse(), poly.getInteriorRingN(0));
}

@Test
void mergeFillPolygonsDoesNotNormalizeIrregularFill() throws GeometryException {
assertEquivalentFeatures(
List.of(
feature(1, newPolygon(
-2, -2,
200, -2,
200, -1,
258, -1,
258, 257,
200, 257,
200, 258,
-2, 258,
-2, -2
), Map.of())
),
FeatureMerge.mergeNearbyPolygons(
List.of(
feature(1, rectangle(-2, -2, 200, 258), Map.of()),
feature(2, rectangle(180, -1, 258, 257), Map.of())
),
0,
0,
0,
0
)
);
}
}

0 comments on commit 9345224

Please sign in to comment.