Skip to content

Commit 11da474

Browse files
authored
Add Overture data source support (#541)
Add preliminary support for Overture as a data source. * Recoded Claude-provided plan for Overture addition * Added script to download Overture data as Parquet * Updated Overture plan with some manual edits * Implemented --overture CLI flag and got it working with just land & water at first * Expanded Overture theme=base to landuse layer * Added Overture theme=buildings to buildings layer * Added Overture theme=transportation to roads layer * Added Overture theme=places to pois layer * Added first failing Overture POI tests * Added feature finder script to assist with Overture test cases * Sped up feature finder with RTree * Added more failing Overture POI tests * Replaced failing OGR SQL queries that were too long * Added failing Overture POI test * Sped up feature finder with more parallel ops * Added numerous failing RoadsTest cases * Added failing trunk and motorway tests * Added test for all classes of link roads * Switched from raw tags to assigned kind in POI zooms to prepare for Overture * Passed all Overture POI tests by applying new rules * Added QRank for Overture * Added basic kind assignments for Overture roads * Added basic zoom assignments for Overture roads * Add comprehensive tests for Overture roads oneway/is_link extraction and line splitting - Updated 6 existing tests to include access_restrictions and road_flags data - Added 6 new splitting tests for partial bridge/tunnel/oneway/level application - Tests use simple geometries (0,0)-(1,0) for easy verification - All 12 tests failing as expected (property extraction and splitting not yet implemented) - References real Overture feature IDs and OSM way IDs in comments * Implement Overture roads line splitting for partial bridge/tunnel/oneway/level properties Major Changes: - Created com.protomaps.basemap.geometry.Linear utility class for line splitting operations - Rewrote Roads.processOverture() to handle fractional 'between' ranges from Overture data - Implemented collectSplitPoints() to gather all split positions from road_flags, access_restrictions, and level_rules - Implemented extractSegmentProperties() to determine which properties apply to each split segment - Added emitRoadFeature() to create features with custom split geometries Results: - 15/21 tests now passing (6 original property extraction tests now pass) - 6 splitting tests create correct features with correct attributes and geometries - Only remaining issue: cosmetic Norm{} wrapper in test assertions (geometries are actually correct) Implementation handles: - Partial bridges via road_flags with 'is_bridge' flag - Partial tunnels via road_flags with 'is_tunnel' flag - Partial oneway restrictions via access_restrictions with heading='backward' - Partial level changes via level_rules - Overlapping property ranges (e.g., bridge + oneway on same segment) - Multiple split points creating 2-5 output features per input feature * Fix Linear.splitAtFractions() to preserve intermediate vertices for curved roads - Add comprehensive unit tests for line splitting with curves - Rewrite Linear.splitAtFractions() to preserve all vertices between split points - Add coordinate transformation from lat/lon to world coordinates before emitting - All 9 new Linear tests pass, roads render correctly with curves preserved * Reimplemented Linear.java using existing JTS linear referencing support * Fixed split geometry tests to correctly handle world coordinates * Corrected oneway=yes to get arrows showing up * Shortened some superlong function bodies * Migrated OSM minZoom to highwayZoomsIndex * Separated remaining zoom-related rules for OSM roads * Claude's version of Places.java * Organized Places rules to separate kinds and zooms indexes * kindRank can come from osmKindsIndex (for now!) and zoomsIndex, but not overtureKindsIndex * Building parts look nice * Added rendering and tests for theme=transportation/type=segment rail and water * Added basic Overture landcover * Replaced Python scripts with https://gist.github.com/migurski/8765492ef831457d7c21c20f4e454e1b * Bumped version to 4.14 * Improved Landcover test coverage * Added Overture water tests * Read bounds from GeoParquet metadata and pass to tile archive When using --overture with a Parquet file, the basemap now reads the bounding box from the GeoParquet metadata and uses it to set the bounds in the output PMTiles archive. This enables "fit bounds" functionality on maps.protomaps.com to zoom to the precise area covered by the data. The bounds are only applied when no --bounds argument is provided, allowing users to still override with manual bounds if needed. * Add test coverage for GeoParquet bounds extraction Refactors bounds reading logic into a separate testable method and adds comprehensive test coverage including valid bounds extraction, missing file handling, and invalid file handling. Includes test GeoParquet file with Alcatraz Island buildings.
1 parent c2ec15f commit 11da474

20 files changed

Lines changed: 2653 additions & 541 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
Tiles 4.14.0
2+
------
3+
- Add experimental Overture data source support [#541]
4+
15
Styles 5.7.1
26
------
37
- Fix appearance of highway and major road tunnel dashed strokes [#558]

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

Lines changed: 97 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
package com.protomaps.basemap;
22

3+
import blue.strategic.parquet.ParquetReader;
34
import com.onthegomap.planetiler.ForwardingProfile;
45
import com.onthegomap.planetiler.Planetiler;
56
import com.onthegomap.planetiler.config.Arguments;
7+
import com.onthegomap.planetiler.reader.parquet.GeoParquetMetadata;
68
import com.onthegomap.planetiler.util.Downloader;
79
import com.protomaps.basemap.feature.CountryCoder;
810
import com.protomaps.basemap.feature.QrankDb;
@@ -24,6 +26,9 @@
2426
import java.util.HashMap;
2527
import java.util.List;
2628
import java.util.Map;
29+
import org.apache.parquet.ParquetReadOptions;
30+
import org.apache.parquet.hadoop.ParquetFileReader;
31+
import org.locationtech.jts.geom.Envelope;
2732
import org.slf4j.Logger;
2833
import org.slf4j.LoggerFactory;
2934

@@ -46,37 +51,43 @@ public Basemap(QrankDb qrankDb, CountryCoder countryCoder, Clip clip,
4651
var buildings = new Buildings();
4752
registerHandler(buildings);
4853
registerSourceHandler("osm", buildings::processOsm);
54+
registerSourceHandler("pm:overture", buildings::processOverture);
4955
}
5056

5157
if (layer.isEmpty() || layer.equals(Landuse.LAYER_NAME)) {
5258
var landuse = new Landuse();
5359
registerHandler(landuse);
5460
registerSourceHandler("osm", landuse::processOsm);
61+
registerSourceHandler("pm:overture", landuse::processOverture);
5562
}
5663

5764
if (layer.isEmpty() || layer.equals(Landcover.LAYER_NAME)) {
5865
var landcover = new Landcover();
5966
registerHandler(landcover);
6067
registerSourceHandler("landcover", landcover::processLandcover);
6168
registerSourceHandler("ne", landcover::processNe);
69+
registerSourceHandler("pm:overture", landcover::processOverture);
6270
}
6371

6472
if (layer.isEmpty() || layer.equals(Places.LAYER_NAME)) {
6573
var place = new Places(countryCoder);
6674
registerHandler(place);
6775
registerSourceHandler("osm", place::processOsm);
76+
registerSourceHandler("pm:overture", place::processOverture);
6877
}
6978

7079
if (layer.isEmpty() || layer.equals(Pois.LAYER_NAME)) {
7180
var poi = new Pois(qrankDb);
7281
registerHandler(poi);
7382
registerSourceHandler("osm", poi::processOsm);
83+
registerSourceHandler("pm:overture", poi::processOverture);
7484
}
7585

7686
if (layer.isEmpty() || layer.equals(Roads.LAYER_NAME)) {
7787
var roads = new Roads(countryCoder);
7888
registerHandler(roads);
7989
registerSourceHandler("osm", roads::processOsm);
90+
registerSourceHandler("pm:overture", roads::processOverture);
8091
}
8192

8293
if (layer.isEmpty() || layer.equals(Transit.LAYER_NAME)) {
@@ -91,6 +102,7 @@ public Basemap(QrankDb qrankDb, CountryCoder countryCoder, Clip clip,
91102
registerSourceHandler("osm", water::processOsm);
92103
registerSourceHandler("osm_water", water::processPreparedOsm);
93104
registerSourceHandler("ne", water::processNe);
105+
registerSourceHandler("pm:overture", water::processOverture);
94106
}
95107

96108
if (layer.isEmpty() || layer.equals(Earth.LAYER_NAME)) {
@@ -100,6 +112,7 @@ public Basemap(QrankDb qrankDb, CountryCoder countryCoder, Clip clip,
100112
registerSourceHandler("osm", earth::processOsm);
101113
registerSourceHandler("osm_land", earth::processPreparedOsm);
102114
registerSourceHandler("ne", earth::processNe);
115+
registerSourceHandler("pm:overture", earth::processOverture);
103116
}
104117

105118
if (clip != null) {
@@ -119,7 +132,7 @@ public String description() {
119132

120133
@Override
121134
public String version() {
122-
return "4.13.7";
135+
return "4.14.0";
123136
}
124137

125138
@Override
@@ -149,6 +162,32 @@ public Map<String, String> extraArchiveMetadata() {
149162
return result;
150163
}
151164

165+
/**
166+
* Extracts bounds from GeoParquet metadata and returns them as a formatted string.
167+
*
168+
* @param parquetPath Path to the GeoParquet file
169+
* @return Bounds string in format "minX,minY,maxX,maxY" if successful, empty otherwise
170+
*/
171+
static java.util.Optional<String> extractBoundsFromGeoParquet(Path parquetPath) {
172+
try {
173+
var inputFile = ParquetReader.makeInputFile(parquetPath.toFile());
174+
try (var reader = ParquetFileReader.open(inputFile, ParquetReadOptions.builder().build())) {
175+
var fileMetadata = reader.getFooter().getFileMetaData();
176+
var geoparquet = GeoParquetMetadata.parse(fileMetadata);
177+
Envelope bounds = geoparquet.primaryColumnMetadata().envelope();
178+
179+
if (bounds != null && !bounds.isNull() && bounds.getArea() > 0) {
180+
String boundsStr = String.format("%f,%f,%f,%f",
181+
bounds.getMinX(), bounds.getMinY(), bounds.getMaxX(), bounds.getMaxY());
182+
return java.util.Optional.of(boundsStr);
183+
}
184+
}
185+
} catch (Exception e) {
186+
LOGGER.warn("Failed to read bounds from GeoParquet file: {}", e.getMessage());
187+
}
188+
return java.util.Optional.empty();
189+
}
190+
152191
public static void main(String[] args) throws IOException {
153192
// Check for help flag
154193
for (String arg : args) {
@@ -183,6 +222,7 @@ private static void printHelp() {
183222
--help, -h Show this help message and exit
184223
--area=<name> Geofabrik area name to download, or filename in data/sources/
185224
(default: monaco, e.g., us/california, washington)
225+
--overture=<path> Path to Overture Maps Parquet file (mutually exclusive with --area)
186226
--maxzoom=<n> Maximum zoom level (default: 15)
187227
--layer=<name> Process only a single layer (optional)
188228
Valid values: boundaries, buildings, landuse, landcover,
@@ -210,6 +250,7 @@ private static void printHelp() {
210250
""", basemap.name(), basemap.version(), basemap.description()));
211251
}
212252

253+
@java.lang.SuppressWarnings("java:S5738")
213254
static void run(Arguments args) throws IOException {
214255
args = args.orElse(Arguments.of("maxzoom", 15));
215256

@@ -221,17 +262,50 @@ static void run(Arguments args) throws IOException {
221262

222263
var countryCoder = CountryCoder.fromJarResource();
223264

224-
String area = args.getString("area", "Geofabrik area name to download, or filename in data/sources/", "monaco");
265+
String area = args.getString("area", "Geofabrik area name to download, or filename in data/sources/", "");
266+
String overtureFile = args.getString("overture", "Path to Overture Maps Parquet file", "");
267+
268+
if (!area.isEmpty() && !overtureFile.isEmpty()) {
269+
LOGGER.error("Error: Cannot specify both --area and --overture");
270+
System.exit(1);
271+
}
272+
if (area.isEmpty() && overtureFile.isEmpty()) {
273+
area = "monaco"; // default
274+
}
275+
276+
// Extract bounds from GeoParquet metadata if using Overture source
277+
if (!overtureFile.isEmpty() && args.getString("bounds", "", "").isEmpty()) {
278+
Path parquetPath = Path.of(overtureFile);
279+
var boundsOpt = extractBoundsFromGeoParquet(parquetPath);
280+
if (boundsOpt.isPresent()) {
281+
String boundsStr = boundsOpt.get();
282+
LOGGER.info("Setting bounds from GeoParquet metadata: {}", boundsStr);
283+
args = args.orElse(Arguments.of("bounds", boundsStr));
284+
}
285+
}
225286

226287
var planetiler = Planetiler.create(args)
227-
.addNaturalEarthSource("ne", nePath, neUrl)
228-
.addOsmSource("osm", Path.of("data", "sources", area + ".osm.pbf"), "geofabrik:" + area)
229-
.addShapefileSource("osm_water", sourcesDir.resolve("water-polygons-split-3857.zip"),
230-
"https://osmdata.openstreetmap.de/download/water-polygons-split-3857.zip")
231-
.addShapefileSource("osm_land", sourcesDir.resolve("land-polygons-split-3857.zip"),
232-
"https://osmdata.openstreetmap.de/download/land-polygons-split-3857.zip")
233-
.addGeoPackageSource("landcover", sourcesDir.resolve("daylight-landcover.gpkg"),
234-
"https://r2-public.protomaps.com/datasets/daylight-landcover.gpkg");
288+
.addNaturalEarthSource("ne", nePath, neUrl);
289+
290+
if (!overtureFile.isEmpty()) {
291+
// Add Overture Parquet source
292+
planetiler.addParquetSource("pm:overture",
293+
List.of(Path.of(overtureFile)),
294+
false, // not Hive partitioned dirname, just a single file
295+
fields -> fields.get("id"),
296+
fields -> fields.get("type") // source layer
297+
);
298+
} else {
299+
// Add OSM and GeoPackage sources
300+
planetiler
301+
.addOsmSource("osm", Path.of("data", "sources", area + ".osm.pbf"), "geofabrik:" + area)
302+
.addShapefileSource("osm_water", sourcesDir.resolve("water-polygons-split-3857.zip"),
303+
"https://osmdata.openstreetmap.de/download/water-polygons-split-3857.zip")
304+
.addShapefileSource("osm_land", sourcesDir.resolve("land-polygons-split-3857.zip"),
305+
"https://osmdata.openstreetmap.de/download/land-polygons-split-3857.zip")
306+
.addGeoPackageSource("landcover", sourcesDir.resolve("daylight-landcover.gpkg"),
307+
"https://r2-public.protomaps.com/datasets/daylight-landcover.gpkg");
308+
}
235309

236310
Path pgfEncodingZip = sourcesDir.resolve("pgf-encoding.zip");
237311
Path qrankCsv = sourcesDir.resolve("qrank.csv.gz");
@@ -281,8 +355,20 @@ static void run(Arguments args) throws IOException {
281355

282356
fontRegistry.loadFontBundle("NotoSansDevanagari-Regular", "1", "Devanagari");
283357

358+
String outputName;
359+
if (!overtureFile.isEmpty()) {
360+
String filename = Path.of(overtureFile).getFileName().toString();
361+
if (filename.endsWith(".parquet")) {
362+
outputName = filename.substring(0, filename.length() - ".parquet".length());
363+
} else {
364+
outputName = filename;
365+
}
366+
} else {
367+
outputName = area;
368+
}
369+
284370
planetiler.setProfile(new Basemap(qrankDb, countryCoder, clip, layer))
285-
.setOutput(Path.of(area + ".pmtiles"))
371+
.setOutput(Path.of(outputName + ".pmtiles"))
286372
.run();
287373
}
288374
}

tiles/src/main/java/com/protomaps/basemap/feature/Matcher.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ public static Expression without(String... arguments) {
155155
* Creates an {@link Expression} that matches when a numeric tag value is within a specified range.
156156
*
157157
* <p>
158-
* The lower bound is inclusive. The upper bound, if provided, is exclusive.
158+
* The lower bound is inclusive. The upper bound is exclusive.
159159
* </p>
160160
*
161161
* <p>
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
package com.protomaps.basemap.geometry;
2+
3+
import java.util.*;
4+
import org.locationtech.jts.geom.Geometry;
5+
import org.locationtech.jts.geom.LineString;
6+
import org.locationtech.jts.linearref.LengthIndexedLine;
7+
8+
/**
9+
* Utility class for linear geometry operations, particularly splitting LineStrings at fractional positions.
10+
*/
11+
public class Linear {
12+
13+
private Linear() {
14+
// Utility class, prevent instantiation
15+
}
16+
17+
/**
18+
* Represents a segment of a line with fractional start/end positions.
19+
*/
20+
public static class Segment {
21+
public final double start; // Fractional position 0.0-1.0
22+
public final double end; // Fractional position 0.0-1.0
23+
24+
public Segment(double start, double end) {
25+
this.start = start;
26+
this.end = end;
27+
}
28+
}
29+
30+
/**
31+
* Split a LineString at fractional positions and return list of split LineStrings. Preserves all intermediate
32+
* vertices between split points to maintain curve geometry.
33+
*
34+
* @param line The LineString to split
35+
* @param splitPoints List of fractional positions (0.0-1.0) where to split
36+
* @return List of LineString segments
37+
*/
38+
public static List<LineString> splitAtFractions(LineString line, List<Double> splitPoints) {
39+
if (splitPoints.isEmpty()) {
40+
return List.of(line);
41+
}
42+
43+
// Sort split points and remove duplicates, ensure 0.0 and 1.0 are included
44+
Set<Double> pointSet = new TreeSet<>();
45+
pointSet.add(0.0);
46+
pointSet.addAll(splitPoints);
47+
pointSet.add(1.0);
48+
49+
List<Double> points = new ArrayList<>(pointSet);
50+
List<LineString> segments = new ArrayList<>();
51+
52+
// Use JTS LengthIndexedLine for efficient extraction
53+
double totalLength = line.getLength();
54+
LengthIndexedLine indexedLine = new LengthIndexedLine(line);
55+
56+
// For each pair of split points, create a segment preserving intermediate vertices
57+
for (int i = 0; i < points.size() - 1; i++) {
58+
double startFrac = points.get(i);
59+
double endFrac = points.get(i + 1);
60+
61+
double startLength = startFrac * totalLength;
62+
double endLength = endFrac * totalLength;
63+
64+
Geometry segment = indexedLine.extractLine(startLength, endLength);
65+
if (segment instanceof LineString ls && ls.getNumPoints() >= 2) {
66+
segments.add(ls);
67+
}
68+
}
69+
70+
return segments;
71+
}
72+
73+
/**
74+
* Create list of Segments representing the split ranges between all split points.
75+
*
76+
* @param splitPoints List of fractional positions (0.0-1.0) where to split
77+
* @return List of Segment objects with start/end fractions
78+
*/
79+
public static List<Segment> createSegments(List<Double> splitPoints) {
80+
if (splitPoints.isEmpty()) {
81+
return List.of(new Segment(0.0, 1.0));
82+
}
83+
84+
// Sort split points and remove duplicates, ensure 0.0 and 1.0 are included
85+
Set<Double> pointSet = new TreeSet<>();
86+
pointSet.add(0.0);
87+
pointSet.addAll(splitPoints);
88+
pointSet.add(1.0);
89+
90+
List<Double> points = new ArrayList<>(pointSet);
91+
List<Segment> segments = new ArrayList<>();
92+
93+
for (int i = 0; i < points.size() - 1; i++) {
94+
segments.add(new Segment(points.get(i), points.get(i + 1)));
95+
}
96+
97+
return segments;
98+
}
99+
100+
/**
101+
* Check if a segment defined by [segStart, segEnd] overlaps with a range [rangeStart, rangeEnd].
102+
*
103+
* @param segStart Start of segment (0.0-1.0)
104+
* @param segEnd End of segment (0.0-1.0)
105+
* @param rangeStart Start of range (0.0-1.0)
106+
* @param rangeEnd End of range (0.0-1.0)
107+
* @return true if the segment overlaps with the range
108+
*/
109+
public static boolean overlaps(double segStart, double segEnd, double rangeStart, double rangeEnd) {
110+
// Segments overlap if they share any fractional position
111+
return segEnd > rangeStart && segStart < rangeEnd;
112+
}
113+
}

tiles/src/main/java/com/protomaps/basemap/layers/Buildings.java

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,29 @@ public void processOsm(SourceFeature sf, FeatureCollector features) {
137137
}
138138
}
139139

140+
public void processOverture(SourceFeature sf, FeatureCollector features) {
141+
// Filter by type field - Overture theme
142+
if (!"buildings".equals(sf.getString("theme"))) {
143+
return;
144+
}
145+
146+
// Ignore non-building types
147+
if (!"building".equals(sf.getString("type")) && !"building_part".equals(sf.getString("type"))) {
148+
return;
149+
}
150+
151+
features.polygon(this.name())
152+
//.setId(FeatureId.create(sf))
153+
// Core Tilezen schema properties
154+
.setAttr("kind", "building")
155+
// Core OSM tags for different kinds of places
156+
//.setAttrWithMinzoom("layer", Parse.parseIntOrNull(sf.getString("layer")), 13)
157+
// NOTE: Height is quantized by zoom in a post-process step
158+
//.setAttr(HEIGHT_KEY, height.height())
159+
.setAttr("sort_rank", 400)
160+
.setZoomRange(11, 15);
161+
}
162+
140163
@Override
141164
public List<VectorTile.Feature> postProcess(int zoom, List<VectorTile.Feature> items) throws GeometryException {
142165
if (zoom == 15) {

0 commit comments

Comments
 (0)