Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
36e46ab
Recoded Claude-provided plan for Overture addition
migurski Dec 28, 2025
28524b8
Added script to download Overture data as Parquet
migurski Dec 28, 2025
00b9401
Updated Overture plan with some manual edits
migurski Dec 29, 2025
cb02f31
Implemented --overture CLI flag and got it working with just land & w…
migurski Dec 29, 2025
c9ee800
Expanded Overture theme=base to landuse layer
migurski Dec 30, 2025
65fd1fb
Added Overture theme=buildings to buildings layer
migurski Dec 30, 2025
d16a5d9
Added Overture theme=transportation to roads layer
migurski Dec 30, 2025
82eee1b
Added Overture theme=places to pois layer
migurski Dec 30, 2025
c6819af
Added first failing Overture POI tests
migurski Dec 30, 2025
5e77576
Added feature finder script to assist with Overture test cases
migurski Dec 30, 2025
95bbe7a
Sped up feature finder with RTree
migurski Dec 30, 2025
a123a2b
Added more failing Overture POI tests
migurski Dec 30, 2025
f39a97a
Replaced failing OGR SQL queries that were too long
migurski Dec 30, 2025
725656a
Added failing Overture POI test
migurski Dec 30, 2025
4ac6f0b
Sped up feature finder with more parallel ops
migurski Dec 30, 2025
045380d
Added numerous failing RoadsTest cases
migurski Dec 31, 2025
d57e774
Added failing trunk and motorway tests
migurski Dec 31, 2025
fc66900
Added test for all classes of link roads
migurski Dec 31, 2025
89d5d71
Switched from raw tags to assigned kind in POI zooms to prepare for O…
migurski Dec 31, 2025
b853d50
Passed all Overture POI tests by applying new rules
migurski Dec 31, 2025
2ed573d
Added QRank for Overture
migurski Dec 31, 2025
4dbf500
Added basic kind assignments for Overture roads
migurski Dec 31, 2025
cf129c5
Added basic zoom assignments for Overture roads
migurski Dec 31, 2025
93ff106
Add comprehensive tests for Overture roads oneway/is_link extraction …
migurski Dec 31, 2025
e0f1b30
Implement Overture roads line splitting for partial bridge/tunnel/one…
migurski Dec 31, 2025
8511405
Fix Linear.splitAtFractions() to preserve intermediate vertices for c…
migurski Dec 31, 2025
d114227
Reimplemented Linear.java using existing JTS linear referencing support
migurski Dec 31, 2025
f86f81d
Fixed split geometry tests to correctly handle world coordinates
migurski Dec 31, 2025
5048106
Corrected oneway=yes to get arrows showing up
migurski Dec 31, 2025
7839ce3
Minor cleanup
migurski Dec 31, 2025
6f90bad
...
migurski Dec 31, 2025
e6c9dce
Shortened some superlong function bodies
migurski Jan 1, 2026
cd2c77b
Migrated OSM minZoom to highwayZoomsIndex
migurski Jan 1, 2026
c24d9d2
Separated remaining zoom-related rules for OSM roads
migurski Jan 1, 2026
784deb3
Claude's version of Places.java
migurski Jan 1, 2026
8ebf9db
Organized Places rules to separate kinds and zooms indexes
migurski Jan 1, 2026
7262047
kindRank can come from osmKindsIndex (for now!) and zoomsIndex, but n…
migurski Jan 1, 2026
c1149b0
Building parts look nice
migurski Jan 3, 2026
e02778d
Added rendering and tests for theme=transportation/type=segment rail …
migurski Jan 3, 2026
9cec3fb
Delete outdated implementation plan
migurski Jan 4, 2026
6c91ab3
Added basic Overture landcover
migurski Jan 5, 2026
ddd8947
Replaced Python scripts with https://gist.github.com/migurski/8765492…
migurski Jan 6, 2026
055ec6b
Bumped version to 4.14
migurski Jan 6, 2026
0fe3068
Applied suggestion from https://github.com/protomaps/basemaps/pull/539
migurski Jan 12, 2026
804729f
Applied SonarQube suggestions for high-priority issues
migurski Jan 13, 2026
8b30585
Applied SonarQube suggestions for high-priority issues
migurski Jan 13, 2026
4f10714
Applied SonarQube suggestions for high-priority issues
migurski Jan 13, 2026
7e92e50
Applied SonarQube suggestions for medium-priority issues
migurski Jan 13, 2026
7b71dc7
Applied SonarQube suggestions for low-priority issues
migurski Jan 13, 2026
daa710a
Linted
migurski Jan 13, 2026
01821cc
Improved Landcover test coverage
migurski Jan 13, 2026
e33842c
Applied SonarQube suggestions for low-priority issues
migurski Jan 13, 2026
0088a65
Applied SonarQube suggestions for TODO issues
migurski Jan 14, 2026
92f765b
Trying to eke out a little bit more test coverage
migurski Jan 14, 2026
614301e
Removing early returns for MultiExpressions that include default values
migurski Jan 14, 2026
a459963
Added Overture water tests
migurski Jan 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
Tiles 4.14.0
------
- Add Overture data source support [#541]

Tiles 4.13.6
------
- Translate POI min_zoom= assignments to MultiExpression rules [#539]
Expand Down
68 changes: 57 additions & 11 deletions tiles/src/main/java/com/protomaps/basemap/Basemap.java
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ public class Basemap extends ForwardingProfile {

private static final Logger LOGGER = LoggerFactory.getLogger(Basemap.class);

public static final String SRC_OVERTURE = "protomaps:overture";

public Basemap(QrankDb qrankDb, CountryCoder countryCoder, Clip clip,
String layer) {

Expand All @@ -46,37 +48,43 @@ public Basemap(QrankDb qrankDb, CountryCoder countryCoder, Clip clip,
var buildings = new Buildings();
registerHandler(buildings);
registerSourceHandler("osm", buildings::processOsm);
registerSourceHandler(SRC_OVERTURE, buildings::processOverture);
}

if (layer.isEmpty() || layer.equals(Landuse.LAYER_NAME)) {
var landuse = new Landuse();
registerHandler(landuse);
registerSourceHandler("osm", landuse::processOsm);
registerSourceHandler(SRC_OVERTURE, landuse::processOverture);
}

if (layer.isEmpty() || layer.equals(Landcover.LAYER_NAME)) {
var landcover = new Landcover();
registerHandler(landcover);
registerSourceHandler("landcover", landcover::processLandcover);
registerSourceHandler("ne", landcover::processNe);
registerSourceHandler(SRC_OVERTURE, landcover::processOverture);
}

if (layer.isEmpty() || layer.equals(Places.LAYER_NAME)) {
var place = new Places(countryCoder);
registerHandler(place);
registerSourceHandler("osm", place::processOsm);
registerSourceHandler(SRC_OVERTURE, place::processOverture);
}

if (layer.isEmpty() || layer.equals(Pois.LAYER_NAME)) {
var poi = new Pois(qrankDb);
registerHandler(poi);
registerSourceHandler("osm", poi::processOsm);
registerSourceHandler(SRC_OVERTURE, poi::processOverture);
}

if (layer.isEmpty() || layer.equals(Roads.LAYER_NAME)) {
var roads = new Roads(countryCoder);
registerHandler(roads);
registerSourceHandler("osm", roads::processOsm);
registerSourceHandler(SRC_OVERTURE, roads::processOverture);
}

if (layer.isEmpty() || layer.equals(Transit.LAYER_NAME)) {
Expand All @@ -91,6 +99,7 @@ public Basemap(QrankDb qrankDb, CountryCoder countryCoder, Clip clip,
registerSourceHandler("osm", water::processOsm);
registerSourceHandler("osm_water", water::processPreparedOsm);
registerSourceHandler("ne", water::processNe);
registerSourceHandler(SRC_OVERTURE, water::processOverture);
}

if (layer.isEmpty() || layer.equals(Earth.LAYER_NAME)) {
Expand All @@ -100,6 +109,7 @@ public Basemap(QrankDb qrankDb, CountryCoder countryCoder, Clip clip,
registerSourceHandler("osm", earth::processOsm);
registerSourceHandler("osm_land", earth::processPreparedOsm);
registerSourceHandler("ne", earth::processNe);
registerSourceHandler(SRC_OVERTURE, earth::processOverture);
}

if (clip != null) {
Expand All @@ -119,7 +129,7 @@ public String description() {

@Override
public String version() {
return "4.13.6";
return "4.14.0";
}

@Override
Expand Down Expand Up @@ -173,6 +183,7 @@ private static void printHelp() {
--help, -h Show this help message and exit
--area=<name> Geofabrik area name to download, or filename in data/sources/
(default: monaco, e.g., us/california, washington)
--overture=<path> Path to Overture Maps Parquet file (mutually exclusive with --area)
--maxzoom=<n> Maximum zoom level (default: 15)
--layer=<name> Process only a single layer (optional)
Valid values: boundaries, buildings, landuse, landcover,
Expand All @@ -198,6 +209,7 @@ private static void printHelp() {
""", basemap.name(), basemap.version(), basemap.description()));
}

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

Expand All @@ -209,17 +221,39 @@ static void run(Arguments args) throws IOException {

var countryCoder = CountryCoder.fromJarResource();

String area = args.getString("area", "Geofabrik area name to download, or filename in data/sources/", "monaco");
String area = args.getString("area", "Geofabrik area name to download, or filename in data/sources/", "");
String overtureFile = args.getString("overture", "Path to Overture Maps Parquet file", "");

if (!area.isEmpty() && !overtureFile.isEmpty()) {
LOGGER.error("Error: Cannot specify both --area and --overture");
System.exit(1);
}
if (area.isEmpty() && overtureFile.isEmpty()) {
area = "monaco"; // default
}

var planetiler = Planetiler.create(args)
.addNaturalEarthSource("ne", nePath, neUrl)
.addOsmSource("osm", Path.of("data", "sources", area + ".osm.pbf"), "geofabrik:" + area)
.addShapefileSource("osm_water", sourcesDir.resolve("water-polygons-split-3857.zip"),
"https://osmdata.openstreetmap.de/download/water-polygons-split-3857.zip")
.addShapefileSource("osm_land", sourcesDir.resolve("land-polygons-split-3857.zip"),
"https://osmdata.openstreetmap.de/download/land-polygons-split-3857.zip")
.addGeoPackageSource("landcover", sourcesDir.resolve("daylight-landcover.gpkg"),
"https://r2-public.protomaps.com/datasets/daylight-landcover.gpkg");
.addNaturalEarthSource("ne", nePath, neUrl);

if (!overtureFile.isEmpty()) {
// Add Overture Parquet source
planetiler.addParquetSource(SRC_OVERTURE,
List.of(Path.of(overtureFile)),
false, // not Hive partitioned dirname, just a single file
fields -> fields.get("id"),
fields -> fields.get("type") // source layer
);
} else {
// Add OSM and GeoPackage sources
planetiler
.addOsmSource("osm", Path.of("data", "sources", area + ".osm.pbf"), "geofabrik:" + area)
.addShapefileSource("osm_water", sourcesDir.resolve("water-polygons-split-3857.zip"),
"https://osmdata.openstreetmap.de/download/water-polygons-split-3857.zip")
.addShapefileSource("osm_land", sourcesDir.resolve("land-polygons-split-3857.zip"),
"https://osmdata.openstreetmap.de/download/land-polygons-split-3857.zip")
.addGeoPackageSource("landcover", sourcesDir.resolve("daylight-landcover.gpkg"),
"https://r2-public.protomaps.com/datasets/daylight-landcover.gpkg");
}

Path pgfEncodingZip = sourcesDir.resolve("pgf-encoding.zip");
Path qrankCsv = sourcesDir.resolve("qrank.csv.gz");
Expand Down Expand Up @@ -263,8 +297,20 @@ static void run(Arguments args) throws IOException {

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

String outputName;
if (!overtureFile.isEmpty()) {
String filename = Path.of(overtureFile).getFileName().toString();
if (filename.endsWith(".parquet")) {
outputName = filename.substring(0, filename.length() - ".parquet".length());
} else {
outputName = filename;
}
} else {
outputName = area;
}

planetiler.setProfile(new Basemap(qrankDb, countryCoder, clip, layer))
.setOutput(Path.of(area + ".pmtiles"))
.setOutput(Path.of(outputName + ".pmtiles"))
.run();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ public static Expression without(String... arguments) {
* Creates an {@link Expression} that matches when a numeric tag value is within a specified range.
*
* <p>
* The lower bound is inclusive. The upper bound, if provided, is exclusive.
* The lower bound is inclusive. The upper bound is exclusive.
* </p>
*
* <p>
Expand Down
113 changes: 113 additions & 0 deletions tiles/src/main/java/com/protomaps/basemap/geometry/Linear.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package com.protomaps.basemap.geometry;

import java.util.*;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.LineString;
import org.locationtech.jts.linearref.LengthIndexedLine;

/**
* Utility class for linear geometry operations, particularly splitting LineStrings at fractional positions.
*/
public class Linear {

private Linear() {
// Utility class, prevent instantiation
}

/**
* Represents a segment of a line with fractional start/end positions.
*/
public static class Segment {
public final double start; // Fractional position 0.0-1.0
public final double end; // Fractional position 0.0-1.0

public Segment(double start, double end) {
this.start = start;
this.end = end;
}
}

/**
* Split a LineString at fractional positions and return list of split LineStrings. Preserves all intermediate
* vertices between split points to maintain curve geometry.
*
* @param line The LineString to split
* @param splitPoints List of fractional positions (0.0-1.0) where to split
* @return List of LineString segments
*/
public static List<LineString> splitAtFractions(LineString line, List<Double> splitPoints) {
if (splitPoints.isEmpty()) {
return List.of(line);
}

// Sort split points and remove duplicates, ensure 0.0 and 1.0 are included
Set<Double> pointSet = new TreeSet<>();
pointSet.add(0.0);
pointSet.addAll(splitPoints);
pointSet.add(1.0);

List<Double> points = new ArrayList<>(pointSet);
List<LineString> segments = new ArrayList<>();

// Use JTS LengthIndexedLine for efficient extraction
double totalLength = line.getLength();
LengthIndexedLine indexedLine = new LengthIndexedLine(line);

// For each pair of split points, create a segment preserving intermediate vertices
for (int i = 0; i < points.size() - 1; i++) {
double startFrac = points.get(i);
double endFrac = points.get(i + 1);

double startLength = startFrac * totalLength;
double endLength = endFrac * totalLength;

Geometry segment = indexedLine.extractLine(startLength, endLength);
if (segment instanceof LineString ls && ls.getNumPoints() >= 2) {
segments.add(ls);
}
}

return segments;
}

/**
* Create list of Segments representing the split ranges between all split points.
*
* @param splitPoints List of fractional positions (0.0-1.0) where to split
* @return List of Segment objects with start/end fractions
*/
public static List<Segment> createSegments(List<Double> splitPoints) {
if (splitPoints.isEmpty()) {
return List.of(new Segment(0.0, 1.0));
}

// Sort split points and remove duplicates, ensure 0.0 and 1.0 are included
Set<Double> pointSet = new TreeSet<>();
pointSet.add(0.0);
pointSet.addAll(splitPoints);
pointSet.add(1.0);

List<Double> points = new ArrayList<>(pointSet);
List<Segment> segments = new ArrayList<>();

for (int i = 0; i < points.size() - 1; i++) {
segments.add(new Segment(points.get(i), points.get(i + 1)));
}

return segments;
}

/**
* Check if a segment defined by [segStart, segEnd] overlaps with a range [rangeStart, rangeEnd].
*
* @param segStart Start of segment (0.0-1.0)
* @param segEnd End of segment (0.0-1.0)
* @param rangeStart Start of range (0.0-1.0)
* @param rangeEnd End of range (0.0-1.0)
* @return true if the segment overlaps with the range
*/
public static boolean overlaps(double segStart, double segEnd, double rangeStart, double rangeEnd) {
// Segments overlap if they share any fractional position
return segEnd > rangeStart && segStart < rangeEnd;
}
}
23 changes: 23 additions & 0 deletions tiles/src/main/java/com/protomaps/basemap/layers/Buildings.java
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,29 @@ public void processOsm(SourceFeature sf, FeatureCollector features) {
}
}

public void processOverture(SourceFeature sf, FeatureCollector features) {
// Filter by type field - Overture theme
if (!"buildings".equals(sf.getString("theme"))) {
return;
}

// Ignore type=building_part for now
if (!"building".equals(sf.getString("type")) && !"building_part".equals(sf.getString("type"))) {
return;
}

features.polygon(this.name())
//.setId(FeatureId.create(sf))
// Core Tilezen schema properties
.setAttr("kind", "building")
// Core OSM tags for different kinds of places
//.setAttrWithMinzoom("layer", Parse.parseIntOrNull(sf.getString("layer")), 13)
// NOTE: Height is quantized by zoom in a post-process step
//.setAttr(HEIGHT_KEY, height.height())
.setAttr("sort_rank", 400)
.setZoomRange(11, 15);
}

@Override
public List<VectorTile.Feature> postProcess(int zoom, List<VectorTile.Feature> items) throws GeometryException {
if (zoom == 15) {
Expand Down
15 changes: 15 additions & 0 deletions tiles/src/main/java/com/protomaps/basemap/layers/Earth.java
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,21 @@ public void processOsm(SourceFeature sf, FeatureCollector features) {
}
}

public void processOverture(SourceFeature sf, FeatureCollector features) {
String type = sf.getString("type");

// Filter by type field - Overture base theme land
if (!"land".equals(type)) {
return;
}

features.polygon(LAYER_NAME)
.setAttr("kind", "earth")
.setPixelTolerance(PIXEL_TOLERANCE)
.setMinZoom(6)
.setBufferPixels(8);
}

@Override
public List<VectorTile.Feature> postProcess(int zoom, List<VectorTile.Feature> items) throws GeometryException {
return FeatureMerge.mergeNearbyPolygons(items, MIN_AREA, MIN_AREA, 0.5, BUFFER);
Expand Down
37 changes: 37 additions & 0 deletions tiles/src/main/java/com/protomaps/basemap/layers/Landcover.java
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,43 @@ public void processLandcover(SourceFeature sf, FeatureCollector features) {
.setPixelTolerance(Earth.PIXEL_TOLERANCE);
}

public void processOverture(SourceFeature sf, FeatureCollector features) {
String type = sf.getString("type");
String kind = sf.getString("subtype");

// Filter by type field - Overture base theme land
if (!"land_cover".equals(type)) {
return;
}

// Map base_layers.ts from https://docs.overturemaps.org/schema/reference/base/land_cover/
if ("grass".equals(kind)) {
kind = "grassland";
} else if ("barren".equals(kind)) {
kind = "barren";
} else if ("urban".equals(kind)) {
kind = "urban_area";
} else if ("crop".equals(kind)) {
kind = "farmland";
} else if ("snow".equals(kind)) {
kind = "glacier";
} else if ("shrub".equals(kind)) {
kind = "scrub";
} else if ("forest".equals(kind) || "mangrove".equals(kind) || "moss".equals(kind) || "wetland".equals(kind)) {
kind = "forest";
}

// polygons are disjoint and non-overlapping, but order them in archive in consistent way
Integer sortKey = sortKeyMapping.getOrDefault(kind, 6);

features.polygon(LAYER_NAME)
.setAttr("kind", kind)
.setZoomRange(0, 7)
.setSortKey(sortKey)
.setMinPixelSize(1.0)
.setPixelTolerance(Earth.PIXEL_TOLERANCE);
}

public static final String LAYER_NAME = "landcover";

@Override
Expand Down
Loading
Loading