Skip to content

Commit 78430d7

Browse files
committed
Add --clip option to tile generation [#51]
* pass a GeoJSON feature polygon or multipolygon to clip the entire tileset. * output for country or city sized basemaps looks better when zoomed out. * implement an internal tiled index so there is minimal performance impact.
1 parent 6189325 commit 78430d7

File tree

6 files changed

+306
-3
lines changed

6 files changed

+306
-3
lines changed

tiles/src/main/java/com/protomaps/basemap/Basemap.java

+16-2
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,18 @@
1616
import com.protomaps.basemap.layers.Roads;
1717
import com.protomaps.basemap.layers.Transit;
1818
import com.protomaps.basemap.layers.Water;
19+
import com.protomaps.basemap.postprocess.Clip;
1920
import com.protomaps.basemap.text.FontRegistry;
2021
import java.nio.file.Path;
22+
import java.nio.file.Paths;
2123
import java.util.HashMap;
2224
import java.util.List;
2325
import java.util.Map;
2426

2527

2628
public class Basemap extends ForwardingProfile {
2729

28-
public Basemap(NaturalEarthDb naturalEarthDb, QrankDb qrankDb) {
30+
public Basemap(NaturalEarthDb naturalEarthDb, QrankDb qrankDb, Clip clip) {
2931

3032
var admin = new Boundaries();
3133
registerHandler(admin);
@@ -72,6 +74,10 @@ public Basemap(NaturalEarthDb naturalEarthDb, QrankDb qrankDb) {
7274
registerSourceHandler("osm", earth::processOsm);
7375
registerSourceHandler("osm_land", earth::processPreparedOsm);
7476
registerSourceHandler("ne", earth::processNe);
77+
78+
if (clip != null) {
79+
registerHandler(clip);
80+
}
7581
}
7682

7783
@Override
@@ -155,9 +161,17 @@ static void run(Arguments args) {
155161
FontRegistry fontRegistry = FontRegistry.getInstance();
156162
fontRegistry.setZipFilePath(pgfEncodingZip.toString());
157163

164+
Clip clip = null;
165+
var clipArg = args.getString("clip", "File path to GeoJSON Polygon or MultiPolygon geometry to clip tileset.", "");
166+
if (!clipArg.isEmpty()) {
167+
clip =
168+
Clip.fromGeoJSONFile(args.getStats(), planetiler.config().minzoom(), planetiler.config().maxzoom(),
169+
Paths.get(clipArg));
170+
}
171+
158172
fontRegistry.loadFontBundle("NotoSansDevanagari-Regular", "1", "Devanagari");
159173

160-
planetiler.setProfile(new Basemap(naturalEarthDb, qrankDb)).setOutput(Path.of(area + ".pmtiles"))
174+
planetiler.setProfile(new Basemap(naturalEarthDb, qrankDb, clip)).setOutput(Path.of(area + ".pmtiles"))
161175
.run();
162176
}
163177
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
package com.protomaps.basemap.postprocess;
2+
3+
import static com.onthegomap.planetiler.geo.GeoUtils.WORLD_BOUNDS;
4+
import static com.onthegomap.planetiler.geo.GeoUtils.latLonToWorldCoords;
5+
import static com.onthegomap.planetiler.render.TiledGeometry.getCoveredTiles;
6+
import static com.onthegomap.planetiler.render.TiledGeometry.sliceIntoTiles;
7+
8+
import com.onthegomap.planetiler.ForwardingProfile;
9+
import com.onthegomap.planetiler.Planetiler;
10+
import com.onthegomap.planetiler.VectorTile;
11+
import com.onthegomap.planetiler.geo.*;
12+
import com.onthegomap.planetiler.reader.FileFormatException;
13+
import com.onthegomap.planetiler.reader.geojson.GeoJson;
14+
import com.onthegomap.planetiler.render.TiledGeometry;
15+
import com.onthegomap.planetiler.stats.Stats;
16+
import java.nio.file.Path;
17+
import java.util.*;
18+
import org.locationtech.jts.geom.*;
19+
import org.locationtech.jts.geom.util.AffineTransformation;
20+
import org.locationtech.jts.operation.overlayng.OverlayNG;
21+
import org.locationtech.jts.operation.overlayng.OverlayNGRobust;
22+
23+
public class Clip implements ForwardingProfile.TilePostProcessor {
24+
private final Map<Integer, Map<TileCoord, List<List<CoordinateSequence>>>> boundaryTilesByZoom;
25+
private final Map<Integer, TiledGeometry.CoveredTiles> coveredTilesByZoom;
26+
private final Stats stats;
27+
28+
static final double DEFAULT_TILE_BUFFER = 4.0 / 256.0;
29+
static final double DEFAULT_CLIPPING_BUFFER = 0.00001;
30+
31+
// the geometry must be in world coordinates ( world from 0 to 1 )
32+
public Clip(Stats stats, int minzoom, int maxzoom, Geometry input) {
33+
this.stats = stats;
34+
var clipGeometry = input.buffer(DEFAULT_CLIPPING_BUFFER);
35+
boundaryTilesByZoom = new HashMap<>();
36+
coveredTilesByZoom = new HashMap<>();
37+
try {
38+
for (var i = minzoom; i <= maxzoom; i++) {
39+
var extents = TileExtents.computeFromWorldBounds(i, WORLD_BOUNDS);
40+
double scale = 1 << i;
41+
Geometry scaled = AffineTransformation.scaleInstance(scale, scale).transform(clipGeometry);
42+
this.boundaryTilesByZoom.put(i,
43+
sliceIntoTiles(scaled, 0, DEFAULT_TILE_BUFFER, i, extents.getForZoom(i)).getTileData());
44+
this.coveredTilesByZoom.put(i, getCoveredTiles(scaled, i, extents.getForZoom(i)));
45+
}
46+
} catch (GeometryException e) {
47+
throw new Planetiler.PlanetilerException("Error clipping", e);
48+
}
49+
}
50+
51+
public static Clip fromGeoJSONFile(Stats stats, int minzoom, int maxzoom, Path path) {
52+
var g = GeoJson.from(path);
53+
if (g.count() == 0) {
54+
throw new FileFormatException("Empty clipping geometry");
55+
}
56+
var feature = g.iterator().next();
57+
return new Clip(stats, minzoom, maxzoom, latLonToWorldCoords(feature.geometry()));
58+
}
59+
60+
// Copied from elsewhere in planetiler
61+
private static Polygon reassemblePolygon(List<CoordinateSequence> group) throws GeometryException {
62+
try {
63+
LinearRing first = GeoUtils.JTS_FACTORY.createLinearRing(group.getFirst());
64+
LinearRing[] rest = new LinearRing[group.size() - 1];
65+
for (int j = 1; j < group.size(); j++) {
66+
CoordinateSequence seq = group.get(j);
67+
CoordinateSequences.reverse(seq);
68+
rest[j - 1] = GeoUtils.JTS_FACTORY.createLinearRing(seq);
69+
}
70+
return GeoUtils.JTS_FACTORY.createPolygon(first, rest);
71+
} catch (IllegalArgumentException e) {
72+
throw new GeometryException("reassemble_polygon_failed", "Could not build polygon", e);
73+
}
74+
}
75+
76+
// Copied from elsewhere in Planetiler
77+
static Geometry reassemblePolygons(List<List<CoordinateSequence>> groups) throws GeometryException {
78+
int numGeoms = groups.size();
79+
if (numGeoms == 1) {
80+
return reassemblePolygon(groups.getFirst());
81+
} else {
82+
Polygon[] polygons = new Polygon[numGeoms];
83+
for (int i = 0; i < numGeoms; i++) {
84+
polygons[i] = reassemblePolygon(groups.get(i));
85+
}
86+
return GeoUtils.JTS_FACTORY.createMultiPolygon(polygons);
87+
}
88+
}
89+
90+
private boolean nonDegenerateGeometry(Geometry geom) {
91+
return !geom.isEmpty() && geom.getNumGeometries() > 0;
92+
}
93+
94+
private Geometry fixGeometry(Geometry geom) throws GeometryException {
95+
if (geom instanceof Polygonal) {
96+
geom = GeoUtils.snapAndFixPolygon(geom, stats, "clip");
97+
return geom.reverse();
98+
}
99+
return geom;
100+
}
101+
102+
private void addToFeatures(List<VectorTile.Feature> features, VectorTile.Feature feature, Geometry geom) {
103+
if (nonDegenerateGeometry(geom)) {
104+
if (geom instanceof GeometryCollection) {
105+
for (int i = 0; i < geom.getNumGeometries(); i++) {
106+
features.add(feature.copyWithNewGeometry(geom.getGeometryN(i)));
107+
}
108+
} else {
109+
features.add(feature.copyWithNewGeometry(geom));
110+
}
111+
}
112+
}
113+
114+
@Override
115+
public Map<String, List<VectorTile.Feature>> postProcessTile(TileCoord tile,
116+
Map<String, List<VectorTile.Feature>> layers) throws GeometryException {
117+
118+
var inCovering =
119+
this.coveredTilesByZoom.containsKey(tile.z()) && this.coveredTilesByZoom.get(tile.z()).test(tile.x(), tile.y());
120+
121+
if (!inCovering)
122+
return Map.of();
123+
124+
var inBoundary =
125+
this.boundaryTilesByZoom.containsKey(tile.z()) && this.boundaryTilesByZoom.get(tile.z()).containsKey(tile);
126+
127+
if (!inBoundary)
128+
return layers;
129+
130+
List<List<CoordinateSequence>> coords = boundaryTilesByZoom.get(tile.z()).get(tile);
131+
var clippingPoly = reassemblePolygons(coords);
132+
clippingPoly = GeoUtils.fixPolygon(clippingPoly);
133+
clippingPoly.reverse();
134+
Map<String, List<VectorTile.Feature>> output = new HashMap<>();
135+
136+
for (Map.Entry<String, List<VectorTile.Feature>> layer : layers.entrySet()) {
137+
List<VectorTile.Feature> clippedFeatures = new ArrayList<>();
138+
for (var feature : layer.getValue()) {
139+
try {
140+
var clippedGeom =
141+
OverlayNGRobust.overlay(feature.geometry().decode(), clippingPoly, OverlayNG.INTERSECTION);
142+
if (nonDegenerateGeometry(clippedGeom)) {
143+
addToFeatures(clippedFeatures, feature, fixGeometry(clippedGeom));
144+
}
145+
} catch (GeometryException e) {
146+
e.log(stats, "clip", "Failed to clip geometry");
147+
}
148+
}
149+
if (!clippedFeatures.isEmpty())
150+
output.put(layer.getKey(), clippedFeatures);
151+
}
152+
return output;
153+
}
154+
}

tiles/src/test/java/com/protomaps/basemap/layers/LayerTest.java

+1-1
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ abstract class LayerTest {
2424
List.of(new NaturalEarthDb.NeAdmin1StateProvince("California", "US-CA", "Q2", 5.0, 8.0)),
2525
List.of(new NaturalEarthDb.NePopulatedPlace("San Francisco", "Q3", 9.0, 2))
2626
);
27-
final Basemap profile = new Basemap(naturalEarthDb, null);
27+
final Basemap profile = new Basemap(naturalEarthDb, null, null);
2828

2929
static void assertFeatures(int zoom, List<Map<String, Object>> expected, Iterable<FeatureCollector.Feature> actual) {
3030
var expectedList = expected.stream().toList();
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
package com.protomaps.basemap.postprocess;
2+
3+
import static com.onthegomap.planetiler.TestUtils.newLineString;
4+
import static com.onthegomap.planetiler.TestUtils.newPolygon;
5+
import static org.junit.jupiter.api.Assertions.*;
6+
7+
import com.onthegomap.planetiler.VectorTile;
8+
import com.onthegomap.planetiler.geo.GeometryException;
9+
import com.onthegomap.planetiler.geo.TileCoord;
10+
import com.onthegomap.planetiler.reader.FileFormatException;
11+
import com.onthegomap.planetiler.stats.Stats;
12+
import java.nio.file.Path;
13+
import java.util.ArrayList;
14+
import java.util.List;
15+
import java.util.Map;
16+
import org.junit.jupiter.api.Test;
17+
18+
class ClipTest {
19+
private final Stats stats = Stats.inMemory();
20+
21+
@Test
22+
void testLoadGeoJSON() {
23+
Path cwd = Path.of("").toAbsolutePath();
24+
Path pathFromRoot = Path.of("tiles", "src", "test", "resources", "clip.geojson");
25+
var clip = Clip.fromGeoJSONFile(stats, 0, 0, cwd.resolveSibling(pathFromRoot));
26+
assertNotNull(clip);
27+
}
28+
29+
@Test
30+
void testLoadNonJSON() {
31+
Path cwd = Path.of("").toAbsolutePath();
32+
Path pathFromRoot = Path.of("tiles", "src", "test", "resources", "empty.geojson");
33+
assertThrows(FileFormatException.class, () -> {
34+
Clip.fromGeoJSONFile(stats, 0, 0, cwd.resolveSibling(pathFromRoot));
35+
});
36+
}
37+
38+
@Test
39+
void testClipLine() throws GeometryException {
40+
List<VectorTile.Feature> unclipped = new ArrayList<>();
41+
unclipped.add(new VectorTile.Feature("layer", 1,
42+
// a horizontal line in the across the middle of the 0,0,0 tile.
43+
VectorTile.encodeGeometry(newLineString(0, 128, 256, 128)),
44+
Map.of("foo", "bar")
45+
));
46+
47+
// a rectangle that is 50% of the earths width, centered at null island.
48+
var n = new Clip(stats, 0, 0, newPolygon(0.25, 0.25, 0.75, 0.25, 0.75, 0.75, 0.25, 0.75, 0.25, 0.25));
49+
var clipped = n.postProcessTile(TileCoord.ofXYZ(0, 0, 0), Map.of("layer", unclipped));
50+
51+
assertEquals(1, clipped.size());
52+
assertEquals(1, clipped.get("layer").size());
53+
assertEquals(newLineString(64, 128, 192, 128), clipped.get("layer").getFirst().geometry().decode());
54+
}
55+
56+
@Test
57+
void testClipLineMulti() throws GeometryException {
58+
List<VectorTile.Feature> unclipped = new ArrayList<>();
59+
unclipped.add(new VectorTile.Feature("layer", 1,
60+
// a V shape that enters and leaves the clipping square
61+
VectorTile.encodeGeometry(newLineString(32, 128, 128, 224, 224, 128)),
62+
Map.of("foo", "bar")
63+
));
64+
65+
// a rectangle that is 50% of the earths width, centered at null island.
66+
var n = new Clip(stats, 0, 0, newPolygon(0.25, 0.25, 0.75, 0.25, 0.75, 0.75, 0.25, 0.75, 0.25, 0.25));
67+
var clipped = n.postProcessTile(TileCoord.ofXYZ(0, 0, 0), Map.of("layer", unclipped));
68+
69+
assertEquals(1, clipped.size());
70+
assertEquals(2, clipped.get("layer").size());
71+
assertEquals(newLineString(64, 160, 96, 192), clipped.get("layer").get(0).geometry().decode());
72+
assertEquals(newLineString(160, 192, 192, 160), clipped.get("layer").get(1).geometry().decode());
73+
}
74+
75+
@Test
76+
void testClipPolygon() throws GeometryException {
77+
List<VectorTile.Feature> unclipped = new ArrayList<>();
78+
unclipped.add(new VectorTile.Feature("layer", 1,
79+
// a V shape that enters and leaves the clipping square
80+
VectorTile.encodeGeometry(newPolygon(32, 160, 96, 160, 96, 224, 32, 224, 32, 160)),
81+
Map.of("foo", "bar")
82+
));
83+
84+
// a rectangle that is 50% of the earths width, centered at null island.
85+
var n = new Clip(stats, 0, 0, newPolygon(0.25, 0.25, 0.75, 0.25, 0.75, 0.75, 0.25, 0.75, 0.25, 0.25));
86+
var clipped = n.postProcessTile(TileCoord.ofXYZ(0, 0, 0), Map.of("layer", unclipped));
87+
88+
assertEquals(1, clipped.size());
89+
assertEquals(1, clipped.get("layer").size());
90+
assertEquals(newPolygon(64, 160, 96, 160, 96, 192, 64, 192, 64, 160),
91+
clipped.get("layer").getFirst().geometry().decode());
92+
}
93+
94+
@Test
95+
void testClipBelowMinZoom() throws GeometryException {
96+
List<VectorTile.Feature> unclipped = new ArrayList<>();
97+
unclipped.add(new VectorTile.Feature("layer", 1,
98+
VectorTile.encodeGeometry(newLineString(0, 128, 256, 128)),
99+
Map.of("foo", "bar")
100+
));
101+
102+
var n = new Clip(stats, 1, 1, newPolygon(0.25, 0.25, 0.75, 0.25, 0.75, 0.75, 0.25, 0.75, 0.25, 0.25));
103+
var clipped = n.postProcessTile(TileCoord.ofXYZ(0, 0, 0), Map.of("layer", unclipped));
104+
assertEquals(0, clipped.size());
105+
}
106+
107+
@Test
108+
void testClipWhollyOutside() throws GeometryException {
109+
List<VectorTile.Feature> unclipped = new ArrayList<>();
110+
unclipped.add(new VectorTile.Feature("layer", 1,
111+
VectorTile.encodeGeometry(newLineString(0, 1, 5, 1)),
112+
Map.of("foo", "bar")
113+
));
114+
115+
var n = new Clip(stats, 0, 0, newPolygon(0.25, 0.25, 0.75, 0.25, 0.75, 0.75, 0.25, 0.75, 0.25, 0.25));
116+
var clipped = n.postProcessTile(TileCoord.ofXYZ(0, 0, 0), Map.of("layer", unclipped));
117+
assertEquals(0, clipped.size());
118+
}
119+
120+
@Test
121+
void testClipInInterior() throws GeometryException {
122+
List<VectorTile.Feature> unclipped = new ArrayList<>();
123+
unclipped.add(new VectorTile.Feature("layer", 1,
124+
VectorTile.encodeGeometry(newLineString(0, 1, 5, 1)),
125+
Map.of("foo", "bar")
126+
));
127+
128+
var n = new Clip(stats, 0, 3, newPolygon(0.25, 0.25, 0.75, 0.25, 0.75, 0.75, 0.25, 0.75, 0.25, 0.25));
129+
var clipped = n.postProcessTile(TileCoord.ofXYZ(3, 3, 3), Map.of("layer", unclipped));
130+
assertEquals(1, clipped.size());
131+
assertEquals(1, clipped.get("layer").size());
132+
}
133+
}

tiles/src/test/resources/clip.geojson

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"type":"Feature","properties":{},"geometry":{"type":"Polygon","coordinates":[[[7.417252411949448,43.73567091721708],[7.42905253797835,43.73567091721708],[7.42905253797835,43.7275042719568],[7.417252411949448,43.7275042719568],[7.417252411949448,43.73567091721708]]]}}
+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{}

0 commit comments

Comments
 (0)