diff --git a/NOTICE.md b/NOTICE.md index df12a817..0261f32f 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -25,6 +25,7 @@ The `planetiler-core` module includes the following software: - com.github.jnr:jnr-ffi (Apache license) - org.roaringbitmap:RoaringBitmap (Apache license) - org.projectnessie.cel:cel-tools (Apache license) + - mil.nga.geopackage:geopackage (MIT license) - Adapted code: - `DouglasPeuckerSimplifier` from [JTS](https://github.com/locationtech/jts) (EDL) - `OsmMultipolygon` from [imposm3](https://github.com/omniscale/imposm3) (Apache license) diff --git a/planetiler-core/pom.xml b/planetiler-core/pom.xml index 765b7764..8eec1c05 100644 --- a/planetiler-core/pom.xml +++ b/planetiler-core/pom.xml @@ -20,6 +20,7 @@ 2.19.0 0.16.0 3.21.12 + 6.6.0 @@ -138,6 +139,11 @@ guava 31.1-jre + + mil.nga.geopackage + geopackage + ${geopackage.version} + diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/Planetiler.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/Planetiler.java index 2551c1a6..0ba01557 100644 --- a/planetiler-core/src/main/java/com/onthegomap/planetiler/Planetiler.java +++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/Planetiler.java @@ -7,6 +7,7 @@ import com.onthegomap.planetiler.config.Arguments; import com.onthegomap.planetiler.config.MbtilesMetadata; import com.onthegomap.planetiler.config.PlanetilerConfig; import com.onthegomap.planetiler.mbtiles.MbtilesWriter; +import com.onthegomap.planetiler.reader.GeoPackageReader; import com.onthegomap.planetiler.reader.NaturalEarthReader; import com.onthegomap.planetiler.reader.ShapefileReader; import com.onthegomap.planetiler.reader.osm.OsmInputFile; @@ -337,6 +338,31 @@ public class Planetiler { })); } + /** + * Adds a new OGC GeoPackage source that will be processed when {@link #run()} is called. + *

+ * If the file does not exist and {@code download=true} argument is set, then the file will first be downloaded from + * {@code defaultUrl}. + *

+ * To override the location of the {@code geopackage} file, set {@code name_path=newpath.gpkg} in the arguments and to + * override the download URL set {@code name_url=http://url/of/file.gpkg}. + * + * @param name string to use in stats and logs to identify this stage + * @param defaultPath path to the input file to use if {@code name_path} key is not set through arguments + * @param defaultUrl remote URL that the file to download if {@code download=true} argument is set and {@code + * name_url} argument is not set + * @return this runner instance for chaining + * @see GeoPackageReader + * @see Downloader + */ + public Planetiler addGeoPackageSource(String name, Path defaultPath, String defaultUrl) { + Path path = getPath(name, "geopackage", defaultPath, defaultUrl); + return addStage(name, "Process features in " + path, + ifSourceUsed(name, + () -> GeoPackageReader.process(name, List.of(path), featureGroup, config, profile, stats))); + } + + /** * Adds a new Natural Earth sqlite file source that will be processed when {@link #run()} is called. *

diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/reader/GeoPackageReader.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/reader/GeoPackageReader.java new file mode 100644 index 00000000..15a42b65 --- /dev/null +++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/reader/GeoPackageReader.java @@ -0,0 +1,109 @@ +package com.onthegomap.planetiler.reader; + +import com.onthegomap.planetiler.Profile; +import com.onthegomap.planetiler.collection.FeatureGroup; +import com.onthegomap.planetiler.config.PlanetilerConfig; +import com.onthegomap.planetiler.stats.Stats; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.List; +import java.util.function.Consumer; +import mil.nga.geopackage.GeoPackage; +import mil.nga.geopackage.GeoPackageManager; +import mil.nga.geopackage.features.user.FeatureColumns; +import mil.nga.geopackage.features.user.FeatureDao; +import mil.nga.geopackage.geom.GeoPackageGeometryData; +import org.geotools.geometry.jts.JTS; +import org.geotools.geometry.jts.WKBReader; +import org.geotools.referencing.CRS; +import org.locationtech.jts.geom.Geometry; +import org.opengis.referencing.crs.CoordinateReferenceSystem; +import org.opengis.referencing.operation.MathTransform; + +/** + * Utility that reads {@link SourceFeature SourceFeatures} from the vector geometries contained in a GeoPackage file. + */ +public class GeoPackageReader extends SimpleReader { + + private final GeoPackage geoPackage; + + GeoPackageReader(String sourceName, Path input) { + super(sourceName); + + geoPackage = GeoPackageManager.open(false, input.toFile()); + } + + /** + * Renders map features for all elements from an OGC GeoPackage based on the mapping logic defined in {@code + * profile}. + * + * @param sourceName string ID for this reader to use in logs and stats + * @param sourcePaths paths to the {@code .gpkg} files on disk + * @param writer consumer for rendered features + * @param config user-defined parameters controlling number of threads and log interval + * @param profile logic that defines what map features to emit for each source feature + * @param stats to keep track of counters and timings + * @throws IllegalArgumentException if a problem occurs reading the input file + */ + public static void process(String sourceName, List sourcePaths, FeatureGroup writer, PlanetilerConfig config, + Profile profile, Stats stats) { + SourceFeatureProcessor.processFiles( + sourceName, + sourcePaths, + path -> new GeoPackageReader(sourceName, path), + writer, config, profile, stats + ); + } + + @Override + public long getFeatureCount() { + long numFeatures = 0; + + for (String name : geoPackage.getFeatureTables()) { + FeatureDao features = geoPackage.getFeatureDao(name); + numFeatures += features.count(); + } + return numFeatures; + } + + @Override + public void readFeatures(Consumer next) throws Exception { + CoordinateReferenceSystem latLonCRS = CRS.decode("EPSG:4326"); + long id = 0; + + for (var featureName : geoPackage.getFeatureTables()) { + FeatureDao features = geoPackage.getFeatureDao(featureName); + + MathTransform transform = CRS.findMathTransform( + CRS.decode("EPSG:" + features.getSrsId()), + latLonCRS); + + for (var feature : features.queryForAll()) { + GeoPackageGeometryData geometryData = feature.getGeometry(); + if (geometryData == null) { + continue; + } + + Geometry featureGeom = (new WKBReader()).read(geometryData.getWkb()); + Geometry latLonGeom = (transform.isIdentity()) ? featureGeom : JTS.transform(featureGeom, transform); + + FeatureColumns columns = feature.getColumns(); + SimpleFeature geom = SimpleFeature.create(latLonGeom, new HashMap<>(columns.columnCount()), + sourceName, featureName, ++id); + + for (int i = 0; i < columns.columnCount(); ++i) { + if (i != columns.getGeometryIndex()) { + geom.setTag(columns.getColumnName(i), feature.getValue(i)); + } + } + + next.accept(geom); + } + } + } + + @Override + public void close() { + geoPackage.close(); + } +} diff --git a/planetiler-core/src/test/java/com/onthegomap/planetiler/PlanetilerTests.java b/planetiler-core/src/test/java/com/onthegomap/planetiler/PlanetilerTests.java index d9ff4eb9..1681c22f 100644 --- a/planetiler-core/src/test/java/com/onthegomap/planetiler/PlanetilerTests.java +++ b/planetiler-core/src/test/java/com/onthegomap/planetiler/PlanetilerTests.java @@ -1667,6 +1667,7 @@ class PlanetilerTests { .addOsmSource("osm", tempOsm) .addNaturalEarthSource("ne", TestUtils.pathToResource("natural_earth_vector.sqlite")) .addShapefileSource("shapefile", TestUtils.pathToResource("shapefile.zip")) + .addGeoPackageSource("geopackage", TestUtils.pathToResource("geopackage.gpkg"), null) .setOutput("mbtiles", mbtiles) .run(); @@ -1743,12 +1744,52 @@ class PlanetilerTests { } } + @ParameterizedTest + @ValueSource(strings = { + "", + "--write-threads=2 --process-threads=2 --feature-read-threads=2 --threads=4", + }) + void testPlanetilerRunnerGeoPackage(String args) throws Exception { + Path mbtiles = tempDir.resolve("output.mbtiles"); + + Planetiler.create(Arguments.fromArgs((args + " --tmpdir=" + tempDir.resolve("data")).split("\\s+"))) + .setProfile(new Profile.NullProfile() { + @Override + public void processFeature(SourceFeature source, FeatureCollector features) { + features.point("stations") + .setZoomRange(0, 14) + .setAttr("name", source.getString("name")); + } + }) + .addGeoPackageSource("geopackage", TestUtils.pathToResource("geopackage.gpkg"), null) + .setOutput("mbtiles", mbtiles) + .run(); + + try (Mbtiles db = Mbtiles.newReadOnlyDatabase(mbtiles)) { + Set uniqueNames = new HashSet<>(); + long featureCount = 0; + var tileMap = TestUtils.getTileMap(db); + for (var tile : tileMap.values()) { + for (var feature : tile) { + feature.geometry().validate(); + featureCount++; + uniqueNames.add((String) feature.attrs().get("name")); + } + } + + assertTrue(featureCount > 0); + assertEquals(86, uniqueNames.size()); + assertTrue(uniqueNames.contains("Van Dörn Street")); + } + } + private void runWithProfile(Path tempDir, Profile profile, boolean force) throws Exception { Planetiler.create(Arguments.of("tmpdir", tempDir, "force", Boolean.toString(force))) .setProfile(profile) .addOsmSource("osm", TestUtils.pathToResource("monaco-latest.osm.pbf")) .addNaturalEarthSource("ne", TestUtils.pathToResource("natural_earth_vector.sqlite")) .addShapefileSource("shapefile", TestUtils.pathToResource("shapefile.zip")) + .addGeoPackageSource("geopackage", TestUtils.pathToResource("geopackage.gpkg"), null) .setOutput("mbtiles", tempDir.resolve("output.mbtiles")) .run(); } diff --git a/planetiler-core/src/test/java/com/onthegomap/planetiler/reader/GeoPackageReaderTest.java b/planetiler-core/src/test/java/com/onthegomap/planetiler/reader/GeoPackageReaderTest.java new file mode 100644 index 00000000..e8594137 --- /dev/null +++ b/planetiler-core/src/test/java/com/onthegomap/planetiler/reader/GeoPackageReaderTest.java @@ -0,0 +1,53 @@ +package com.onthegomap.planetiler.reader; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.onthegomap.planetiler.TestUtils; +import com.onthegomap.planetiler.collection.IterableOnce; +import com.onthegomap.planetiler.geo.GeoUtils; +import com.onthegomap.planetiler.stats.Stats; +import com.onthegomap.planetiler.worker.WorkerPipeline; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.locationtech.jts.geom.Geometry; + +class GeoPackageReaderTest { + + @Test + @Timeout(30) + void testReadGeoPackage() { + Path path = TestUtils.pathToResource("geopackage.gpkg"); + + try ( + var reader = new GeoPackageReader("test", path) + ) { + for (int i = 1; i <= 2; i++) { + assertEquals(86, reader.getFeatureCount()); + List points = new ArrayList<>(); + List names = new ArrayList<>(); + WorkerPipeline.start("test", Stats.inMemory()) + .readFromTiny("files", List.of(Path.of("dummy-path"))) + .addWorker("geopackage", 1, (IterableOnce p, Consumer next) -> reader.readFeatures(next)) + .addBuffer("reader_queue", 100, 1) + .sinkToConsumer("counter", 1, elem -> { + assertTrue(elem.getTag("name") instanceof String); + assertEquals("test", elem.getSource()); + assertEquals("stations", elem.getSourceLayer()); + points.add(elem.latLonGeometry()); + names.add(elem.getTag("name").toString()); + }).await(); + assertEquals(86, points.size()); + assertTrue(names.contains("Van Dörn Street")); + var gc = GeoUtils.JTS_FACTORY.createGeometryCollection(points.toArray(new Geometry[0])); + var centroid = gc.getCentroid(); + assertEquals(-77.0297995, centroid.getX(), 5, "iter " + i); + assertEquals(38.9119684, centroid.getY(), 5, "iter " + i); + } + } + } +} diff --git a/planetiler-core/src/test/resources/geopackage.gpkg b/planetiler-core/src/test/resources/geopackage.gpkg new file mode 100644 index 00000000..04cc66ee Binary files /dev/null and b/planetiler-core/src/test/resources/geopackage.gpkg differ diff --git a/planetiler-custommap/README.md b/planetiler-custommap/README.md index e3bb2cfe..e38e219c 100644 --- a/planetiler-custommap/README.md +++ b/planetiler-custommap/README.md @@ -57,7 +57,8 @@ examples: [...] A description that tells planetiler how to read geospatial objects with tags from an input file. - `type` - Enum representing the file format of the data source, one - of [`osm`](https://wiki.openstreetmap.org/wiki/PBF_Format) or [`shapefile`](https://en.wikipedia.org/wiki/Shapefile) + of [`osm`](https://wiki.openstreetmap.org/wiki/PBF_Format), [`shapefile`](https://en.wikipedia.org/wiki/Shapefile), + or [`geopackage`](https://www.geopackage.org/). - `local_path` - Local path to the file to use, inferred from `url` if missing. Can be a string or [expression](#expression) that can reference [argument values](#arguments). - `url` - Location to download the file from if not present at `local_path`. diff --git a/planetiler-custommap/planetiler.schema.json b/planetiler-custommap/planetiler.schema.json index 79b6b1b4..d6faf868 100644 --- a/planetiler-custommap/planetiler.schema.json +++ b/planetiler-custommap/planetiler.schema.json @@ -40,7 +40,8 @@ "description": "File format of the data source", "enum": [ "osm", - "shapefile" + "shapefile", + "geopackage" ] }, "url": { diff --git a/planetiler-custommap/src/main/java/com/onthegomap/planetiler/custommap/ConfiguredMapMain.java b/planetiler-custommap/src/main/java/com/onthegomap/planetiler/custommap/ConfiguredMapMain.java index 5377bac9..0e0677e2 100644 --- a/planetiler-custommap/src/main/java/com/onthegomap/planetiler/custommap/ConfiguredMapMain.java +++ b/planetiler-custommap/src/main/java/com/onthegomap/planetiler/custommap/ConfiguredMapMain.java @@ -71,6 +71,7 @@ public class ConfiguredMapMain { switch (sourceType) { case OSM -> planetiler.addOsmSource(source.id(), localPath, source.url()); case SHAPEFILE -> planetiler.addShapefileSource(source.id(), localPath, source.url()); + case GEOPACKAGE -> planetiler.addGeoPackageSource(source.id(), localPath, source.url()); default -> throw new IllegalArgumentException("Unhandled source type for " + source.id() + ": " + sourceType); } } diff --git a/planetiler-custommap/src/main/java/com/onthegomap/planetiler/custommap/configschema/DataSourceType.java b/planetiler-custommap/src/main/java/com/onthegomap/planetiler/custommap/configschema/DataSourceType.java index b128f136..39e12c3f 100644 --- a/planetiler-custommap/src/main/java/com/onthegomap/planetiler/custommap/configschema/DataSourceType.java +++ b/planetiler-custommap/src/main/java/com/onthegomap/planetiler/custommap/configschema/DataSourceType.java @@ -6,5 +6,7 @@ public enum DataSourceType { @JsonProperty("osm") OSM, @JsonProperty("shapefile") - SHAPEFILE + SHAPEFILE, + @JsonProperty("geopackage") + GEOPACKAGE } diff --git a/planetiler-custommap/src/test/resources/validSchema/road_motorway.yml b/planetiler-custommap/src/test/resources/validSchema/road_motorway.yml index eb928576..558e65d0 100644 --- a/planetiler-custommap/src/test/resources/validSchema/road_motorway.yml +++ b/planetiler-custommap/src/test/resources/validSchema/road_motorway.yml @@ -9,6 +9,9 @@ sources: osm: type: osm url: geofabrik:rhode-island + gpkg: + type: geopackage + url: https://example.com/geopackage.gpkg tag_mappings: bridge: boolean # input=bridge, output=bridge, type=boolean layer: long