geometryTypes) {
+ this(encoding, geometryTypes, null, null, null, null, null, null);
+ }
+
+ public Envelope envelope() {
+ return (bbox == null || bbox.size() != 4) ? GeoUtils.WORLD_LAT_LON_BOUNDS :
+ new Envelope(bbox.get(0), bbox.get(2), bbox.get(1), bbox.get(3));
+ }
+
+ /**
+ * Returns a parquet filter that filters records read to only those where the covering bbox overlaps {@code bounds}
+ * or null if unable to infer that from the metadata.
+ *
+ * If covering bbox metadata is missing from geoparquet metadata, it will try to use bbox.xmin, bbox.xmax,
+ * bbox.ymin, and bbox.ymax if present.
+ */
+ public FilterPredicate bboxFilter(MessageType schema, Bounds bounds) {
+ if (!bounds.isWorld()) {
+ var covering = covering();
+ // if covering metadata missing, use default bbox:{xmin,xmax,ymin,ymax}
+ if (covering == null) {
+ if (hasNumericField(schema, "bbox.xmin") &&
+ hasNumericField(schema, "bbox.xmax") &&
+ hasNumericField(schema, "bbox.ymin") &&
+ hasNumericField(schema, "bbox.ymax")) {
+ covering = new GeoParquetMetadata.Covering(new GeoParquetMetadata.CoveringBbox(
+ List.of("bbox.xmin"),
+ List.of("bbox.ymin"),
+ List.of("bbox.xmax"),
+ List.of("bbox.ymax")
+ ));
+ } else if (hasNumericField(schema, "bbox", "xmin") &&
+ hasNumericField(schema, "bbox", "xmax") &&
+ hasNumericField(schema, "bbox", "ymin") &&
+ hasNumericField(schema, "bbox", "ymax")) {
+ covering = new GeoParquetMetadata.Covering(new GeoParquetMetadata.CoveringBbox(
+ List.of("bbox", "xmin"),
+ List.of("bbox", "ymin"),
+ List.of("bbox", "xmax"),
+ List.of("bbox", "ymax")
+ ));
+ }
+ }
+ if (covering != null) {
+ var latLonBounds = bounds.latLon();
+ // TODO apply projection
+ var coveringBbox = covering.bbox();
+ var coordinateType =
+ schema.getColumnDescription(coveringBbox.xmax().toArray(String[]::new))
+ .getPrimitiveType()
+ .getPrimitiveTypeName();
+ BiFunction, Number, FilterPredicate> gtEq = switch (coordinateType) {
+ case DOUBLE -> (p, v) -> FilterApi.gtEq(Filters.doubleColumn(p), v.doubleValue());
+ case FLOAT -> (p, v) -> FilterApi.gtEq(Filters.floatColumn(p), v.floatValue());
+ default -> throw new UnsupportedOperationException();
+ };
+ BiFunction, Number, FilterPredicate> ltEq = switch (coordinateType) {
+ case DOUBLE -> (p, v) -> FilterApi.ltEq(Filters.doubleColumn(p), v.doubleValue());
+ case FLOAT -> (p, v) -> FilterApi.ltEq(Filters.floatColumn(p), v.floatValue());
+ default -> throw new UnsupportedOperationException();
+ };
+ return FilterApi.and(
+ FilterApi.and(
+ gtEq.apply(coveringBbox.xmax(), latLonBounds.getMinX()),
+ ltEq.apply(coveringBbox.xmin(), latLonBounds.getMaxX())
+ ),
+ FilterApi.and(
+ gtEq.apply(coveringBbox.ymax(), latLonBounds.getMinY()),
+ ltEq.apply(coveringBbox.ymin(), latLonBounds.getMaxY())
+ )
+ );
+ }
+ }
+ return null;
+ }
+ }
+
+ public ColumnMetadata primaryColumnMetadata() {
+ return Objects.requireNonNull(columns.get(primaryColumn),
+ "No geoparquet metadata for primary column " + primaryColumn);
+ }
+
+
+ /**
+ * Extracts geoparquet metadata from the {@code "geo"} key value metadata field for the file, or tries to generate a
+ * default one if missing that uses geometry, wkb_geometry, or wkt_geometry column.
+ */
+ public static GeoParquetMetadata parse(FileMetaData metadata) throws IOException {
+ String string = metadata.getKeyValueMetaData().get("geo");
+ if (string != null) {
+ try {
+ return mapper.readValue(string, GeoParquetMetadata.class);
+ } catch (JsonProcessingException e) {
+ LOGGER.warn("Invalid geoparquet metadata", e);
+ }
+ }
+ // fallback
+ for (var field : metadata.getSchema().asGroupType().getFields()) {
+ if (field.isPrimitive() &&
+ field.asPrimitiveType().getPrimitiveTypeName() == PrimitiveType.PrimitiveTypeName.BINARY) {
+ switch (field.getName()) {
+ case "geometry", "wkb_geometry" -> {
+ return new GeoParquetMetadata("1.0.0", field.getName(), Map.of(
+ field.getName(), new ColumnMetadata("WKB")));
+ }
+ case "wkt_geometry" -> {
+ return new GeoParquetMetadata("1.0.0", field.getName(), Map.of(
+ field.getName(), new ColumnMetadata("WKT")));
+ }
+ default -> {
+ //ignore
+ }
+ }
+ }
+ }
+ throw new IOException(
+ "No valid geometry columns found: " + metadata.getSchema().asGroupType().getFields().stream().map(
+ Type::getName).toList());
+ }
+
+ private static boolean hasNumericField(MessageType root, String... path) {
+ if (root.containsPath(path)) {
+ var type = root.getType(path);
+ if (!type.isPrimitive()) {
+ return false;
+ }
+ var typeName = type.asPrimitiveType().getPrimitiveTypeName();
+ return typeName == PrimitiveType.PrimitiveTypeName.DOUBLE || typeName == PrimitiveType.PrimitiveTypeName.FLOAT;
+ }
+ return false;
+ }
+}
diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/reader/parquet/GeometryReader.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/reader/parquet/GeometryReader.java
new file mode 100644
index 0000000000..67deed9ced
--- /dev/null
+++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/reader/parquet/GeometryReader.java
@@ -0,0 +1,63 @@
+package com.onthegomap.planetiler.reader.parquet;
+
+import com.onthegomap.planetiler.geo.GeoUtils;
+import com.onthegomap.planetiler.geo.GeometryException;
+import com.onthegomap.planetiler.reader.WithTags;
+import com.onthegomap.planetiler.util.FunctionThatThrows;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.locationtech.jts.geom.Geometry;
+
+/**
+ * Decodes geometries from a parquet record based on the {@link GeoParquetMetadata} provided.
+ */
+class GeometryReader {
+ private final Map> converters = new HashMap<>();
+ private final String geometryColumn;
+
+ GeometryReader(GeoParquetMetadata geoparquet) {
+ this.geometryColumn = geoparquet.primaryColumn();
+ for (var entry : geoparquet.columns().entrySet()) {
+ String column = entry.getKey();
+ GeoParquetMetadata.ColumnMetadata columnInfo = entry.getValue();
+ FunctionThatThrows