diff --git a/.run/tdtiles-create.run.xml b/.run/tdtiles-create.run.xml
new file mode 100644
index 000000000..e795524c2
--- /dev/null
+++ b/.run/tdtiles-create.run.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.run/tdtiles-dev.run.xml b/.run/tdtiles-dev.run.xml
new file mode 100644
index 000000000..e5bc2600c
--- /dev/null
+++ b/.run/tdtiles-dev.run.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.run/tdtiles-serve.run.xml b/.run/tdtiles-serve.run.xml
new file mode 100644
index 000000000..67337bd73
--- /dev/null
+++ b/.run/tdtiles-serve.run.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/baremaps-cli/src/main/java/org/apache/baremaps/cli/Baremaps.java b/baremaps-cli/src/main/java/org/apache/baremaps/cli/Baremaps.java
index e931ff656..0422684c4 100644
--- a/baremaps-cli/src/main/java/org/apache/baremaps/cli/Baremaps.java
+++ b/baremaps-cli/src/main/java/org/apache/baremaps/cli/Baremaps.java
@@ -28,6 +28,7 @@
import org.apache.baremaps.cli.geocoder.Geocoder;
import org.apache.baremaps.cli.iploc.IpLoc;
import org.apache.baremaps.cli.map.Map;
+import org.apache.baremaps.cli.tdtiles.TdTiles;
import org.apache.baremaps.cli.workflow.Workflow;
import org.apache.logging.log4j.Level;
import org.apache.logging.log4j.core.config.Configurator;
@@ -38,7 +39,7 @@
@Command(name = "baremaps", description = "A toolkit for producing vector tiles.",
versionProvider = VersionProvider.class, subcommands = {Workflow.class, Database.class,
- Map.class, Geocoder.class, IpLoc.class},
+ Map.class, TdTiles.class, Geocoder.class, IpLoc.class},
sortOptions = false)
public class Baremaps implements Callable {
diff --git a/baremaps-cli/src/main/java/org/apache/baremaps/cli/tdtiles/Serve.java b/baremaps-cli/src/main/java/org/apache/baremaps/cli/tdtiles/Serve.java
new file mode 100644
index 000000000..522f2678e
--- /dev/null
+++ b/baremaps-cli/src/main/java/org/apache/baremaps/cli/tdtiles/Serve.java
@@ -0,0 +1,96 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.baremaps.cli.tdtiles;
+
+
+
+import static org.apache.baremaps.utils.ObjectMapperUtils.objectMapper;
+
+import com.linecorp.armeria.common.HttpHeaderNames;
+import com.linecorp.armeria.common.HttpMethod;
+import com.linecorp.armeria.server.Server;
+import com.linecorp.armeria.server.annotation.JacksonResponseConverterFunction;
+import com.linecorp.armeria.server.cors.CorsService;
+import com.linecorp.armeria.server.file.FileService;
+import com.linecorp.armeria.server.file.HttpFile;
+import java.util.concurrent.Callable;
+import org.apache.baremaps.cli.Options;
+import org.apache.baremaps.server.TdTilesResources;
+import org.apache.baremaps.utils.PostgresUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Mixin;
+import picocli.CommandLine.Option;
+
+@Command(name = "serve", description = "Start a 3d tile server.")
+public class Serve implements Callable {
+
+ private static final Logger logger = LoggerFactory.getLogger(Serve.class);
+
+ @Mixin
+ private Options options;
+
+ @Option(names = {"--database"}, paramLabel = "DATABASE",
+ description = "The JDBC url of Postgres.", required = true)
+ private String database;
+
+ @Option(names = {"--host"}, paramLabel = "HOST", description = "The host of the server.")
+ private String host = "localhost";
+
+ @Option(names = {"--port"}, paramLabel = "PORT", description = "The port of the server.")
+ private int port = 9000;
+
+ @Override
+ public Integer call() throws Exception {
+ var objectMapper = objectMapper();
+ var datasource = PostgresUtils.createDataSource(database);
+
+ var serverBuilder = Server.builder();
+ serverBuilder.http(port);
+
+ var jsonResponseConverter = new JacksonResponseConverterFunction(objectMapper);
+ serverBuilder.annotatedService(new TdTilesResources(datasource), jsonResponseConverter);
+
+ var index = HttpFile.of(ClassLoader.getSystemClassLoader(), "/tdtiles/index.html");
+ serverBuilder.service("/", index.asService());
+ serverBuilder.serviceUnder("/", FileService.of(ClassLoader.getSystemClassLoader(), "/tdtiles"));
+
+ serverBuilder.decorator(CorsService.builderForAnyOrigin()
+ .allowRequestMethods(HttpMethod.GET, HttpMethod.POST, HttpMethod.PUT, HttpMethod.DELETE,
+ HttpMethod.OPTIONS, HttpMethod.HEAD)
+ .allowRequestHeaders(HttpHeaderNames.ORIGIN, HttpHeaderNames.CONTENT_TYPE,
+ HttpHeaderNames.ACCEPT, HttpHeaderNames.AUTHORIZATION)
+ .allowCredentials()
+ .exposeHeaders(HttpHeaderNames.LOCATION)
+ .newDecorator());
+
+ serverBuilder.disableServerHeader();
+ serverBuilder.disableDateHeader();
+
+ var server = serverBuilder.build();
+
+ var startFuture = server.start();
+ startFuture.join();
+
+ var shutdownFuture = server.closeOnJvmShutdown();
+ shutdownFuture.join();
+
+ return 0;
+ }
+}
diff --git a/baremaps-cli/src/main/java/org/apache/baremaps/cli/tdtiles/TdTiles.java b/baremaps-cli/src/main/java/org/apache/baremaps/cli/tdtiles/TdTiles.java
new file mode 100644
index 000000000..f378cc108
--- /dev/null
+++ b/baremaps-cli/src/main/java/org/apache/baremaps/cli/tdtiles/TdTiles.java
@@ -0,0 +1,33 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.baremaps.cli.tdtiles;
+
+
+
+import picocli.CommandLine;
+import picocli.CommandLine.Command;
+
+@Command(name = "tdtiles", description = "3d Tiles commands.", subcommands = {Serve.class},
+ sortOptions = false)
+public class TdTiles implements Runnable {
+
+ @Override
+ public void run() {
+ CommandLine.usage(this, System.out);
+ }
+}
diff --git a/baremaps-core/pom.xml b/baremaps-core/pom.xml
index 2b40f1306..679ccb5b8 100644
--- a/baremaps-core/pom.xml
+++ b/baremaps-core/pom.xml
@@ -66,6 +66,15 @@ limitations under the License.
de.bytefish
pgbulkinsert
+
+ de.javagl
+ jgltf-model
+
+
+ de.javagl
+ jgltf-model-builder
+ ${version.lib.jgltf}
+
it.unimi.dsi
fastutil
diff --git a/baremaps-core/src/main/java/org/apache/baremaps/tdtiles/GltfBuilder.java b/baremaps-core/src/main/java/org/apache/baremaps/tdtiles/GltfBuilder.java
new file mode 100644
index 000000000..336a072cc
--- /dev/null
+++ b/baremaps-core/src/main/java/org/apache/baremaps/tdtiles/GltfBuilder.java
@@ -0,0 +1,354 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.baremaps.tdtiles;
+
+
+
+import de.javagl.jgltf.model.NodeModel;
+import de.javagl.jgltf.model.creation.GltfModelBuilder;
+import de.javagl.jgltf.model.creation.MaterialBuilder;
+import de.javagl.jgltf.model.creation.MeshPrimitiveBuilder;
+import de.javagl.jgltf.model.impl.*;
+import de.javagl.jgltf.model.io.v2.GltfAssetV2;
+import de.javagl.jgltf.model.io.v2.GltfAssetWriterV2;
+import de.javagl.jgltf.model.io.v2.GltfAssetsV2;
+import de.javagl.jgltf.model.v2.MaterialModelV2;
+import java.io.*;
+import java.nio.FloatBuffer;
+import java.nio.IntBuffer;
+import java.util.*;
+import org.apache.baremaps.tdtiles.building.Building;
+import org.apache.commons.lang3.ArrayUtils;
+import org.locationtech.jts.geom.*;
+import org.locationtech.jts.geomgraph.Edge;
+import org.locationtech.jts.math.Vector3D;
+import org.locationtech.jts.triangulate.DelaunayTriangulationBuilder;
+
+public class GltfBuilder {
+
+ /**
+ * Create a node from a building.
+ *
+ * @param building
+ * @return
+ */
+ public static NodeModel createNode(Building building, float tolerance) {
+
+ // Tessellate the vector data
+ DelaunayTriangulationBuilder delaunayTriangulationBuilder = new DelaunayTriangulationBuilder();
+ delaunayTriangulationBuilder.setSites(building.geometry());
+ delaunayTriangulationBuilder.setTolerance(tolerance);
+ Geometry triangulation = delaunayTriangulationBuilder.getTriangles(new GeometryFactory());
+ if (triangulation.getNumGeometries() == 0) {
+ return new DefaultNodeModel();
+ }
+
+ // Compute a translation for the origin of the building. If the building is too far from the
+ // origin, it will
+ // suffer from jittering. https://help.agi.com/AGIComponents/html/BlogPrecisionsPrecisions.htm
+ float[] translation = cartesian3FromDegrees((float) building.geometry().getCoordinates()[0].y,
+ (float) building.geometry().getCoordinates()[0].x, 0);
+
+ // Generate the 3d vertices, indices and normals
+ List vertices = new ArrayList<>();
+ List indices = new ArrayList<>();
+ List normals = new ArrayList<>();
+ createRoof(building, translation, triangulation, vertices, indices);
+ HashSet edges = getExteriorEdges(triangulation);
+ createWalls(building, translation, vertices, indices, edges);
+ createNormals(vertices, normals);
+
+ // Create a mesh from the vertices, indices and normals
+ MeshPrimitiveBuilder meshPrimitiveBuilder =
+ MeshPrimitiveBuilder.create();
+ meshPrimitiveBuilder.setIntIndicesAsShort(
+ IntBuffer.wrap(ArrayUtils.toPrimitive(indices.toArray(new Integer[0]), 0)));
+ meshPrimitiveBuilder.addPositions3D(
+ FloatBuffer.wrap(ArrayUtils.toPrimitive(vertices.toArray(new Float[0]), 0.0F)));
+ meshPrimitiveBuilder.addNormals3D(
+ FloatBuffer.wrap(ArrayUtils.toPrimitive(normals.toArray(new Float[0]), 0.0F)));
+ DefaultMeshPrimitiveModel meshPrimitiveModel =
+ meshPrimitiveBuilder.build();
+
+ // Create a material, and assign it to the mesh primitive
+ MaterialBuilder materialBuilder = MaterialBuilder.create();
+ materialBuilder.setBaseColorFactor(1f, 1f, 1f, 1.0f);
+ materialBuilder.setDoubleSided(false);
+ MaterialModelV2 materialModel = materialBuilder.build();
+ materialModel.setMetallicFactor(0.0f);
+ materialModel.setOcclusionStrength(0.0f);
+ materialModel.setRoughnessFactor(1.0f);
+ meshPrimitiveModel.setMaterialModel(materialModel);
+
+ // Create a mesh with the mesh primitive
+ DefaultMeshModel meshModel = new DefaultMeshModel();
+ meshModel.addMeshPrimitiveModel(meshPrimitiveModel);
+
+ // Create a node with the mesh
+ DefaultNodeModel nodeModel = new DefaultNodeModel();
+ nodeModel.addMeshModel(meshModel);
+ nodeModel.setTranslation(translation);
+
+ return nodeModel;
+ }
+
+ /**
+ * Compute the normal for each vertex in the vertices array. Vertices are grouped by 3, so we can
+ * compute the normal for each triangle. The normal is the normalized cross product of the two
+ * vectors of the triangle.
+ *
+ * @param vertices
+ * @param normals
+ */
+ private static void createNormals(List vertices, List normals) {
+ for (int i = 0; i < vertices.size(); i += 9) {
+ float[] v1 = new float[] {vertices.get(i), vertices.get(i + 1), vertices.get(i + 2)};
+ float[] v2 = new float[] {vertices.get(i + 3), vertices.get(i + 4), vertices.get(i + 5)};
+ float[] v3 = new float[] {vertices.get(i + 6), vertices.get(i + 7), vertices.get(i + 8)};
+ float[] u = new float[] {v2[0] - v1[0], v2[1] - v1[1], v2[2] - v1[2]};
+ float[] v = new float[] {v3[0] - v1[0], v3[1] - v1[1], v3[2] - v1[2]};
+ float[] normal = new float[] {u[1] * v[2] - u[2] * v[1], u[2] * v[0] - u[0] * v[2],
+ u[0] * v[1] - u[1] * v[0]};
+ float length =
+ (float) Math.sqrt(normal[0] * normal[0] + normal[1] * normal[1] + normal[2] * normal[2]);
+ normal[0] /= length;
+ normal[1] /= length;
+ normal[2] /= length;
+ normals.add(normal[0]);
+ normals.add(normal[1]);
+ normals.add(normal[2]);
+ normals.add(normal[0]);
+ normals.add(normal[1]);
+ normals.add(normal[2]);
+ normals.add(normal[0]);
+ normals.add(normal[1]);
+ normals.add(normal[2]);
+ }
+ }
+
+ /**
+ * Iterate over the edges and extrude them to create the walls based on the build height.
+ *
+ * @param building
+ * @param translation
+ * @param vertices
+ * @param indices
+ * @param edges the exterior edges
+ */
+ private static void createWalls(Building building, float[] translation, List vertices,
+ List indices, HashSet edges) {
+ for (Edge edge : edges) {
+ Coordinate[] v = edge.getCoordinates();
+
+ float[] pos0 = cartesian3FromDegrees((float) v[0].getY(), (float) v[0].getX(), 0);
+ pos0[0] -= translation[0];
+ pos0[1] -= translation[1];
+ pos0[2] -= translation[2];
+ float[] pos1 = cartesian3FromDegrees((float) v[1].getY(), (float) v[1].getX(), 0);
+ pos1[0] -= translation[0];
+ pos1[1] -= translation[1];
+ pos1[2] -= translation[2];
+ float[] pos2 =
+ cartesian3FromDegrees((float) v[0].getY(), (float) v[0].getX(), building.height());
+ pos2[0] -= translation[0];
+ pos2[1] -= translation[1];
+ pos2[2] -= translation[2];
+ float[] pos3 =
+ cartesian3FromDegrees((float) v[1].getY(), (float) v[1].getX(), building.height());
+ pos3[0] -= translation[0];
+ pos3[1] -= translation[1];
+ pos3[2] -= translation[2];
+
+ indices.add(vertices.size() / 3);
+ vertices.add(pos0[0]);
+ vertices.add(pos0[1]);
+ vertices.add(pos0[2]);
+ indices.add(vertices.size() / 3);
+ vertices.add(pos1[0]);
+ vertices.add(pos1[1]);
+ vertices.add(pos1[2]);
+ indices.add(vertices.size() / 3);
+ vertices.add(pos2[0]);
+ vertices.add(pos2[1]);
+ vertices.add(pos2[2]);
+
+ indices.add(vertices.size() / 3);
+ vertices.add(pos2[0]);
+ vertices.add(pos2[1]);
+ vertices.add(pos2[2]);
+ indices.add(vertices.size() / 3);
+ vertices.add(pos1[0]);
+ vertices.add(pos1[1]);
+ vertices.add(pos1[2]);
+ indices.add(vertices.size() / 3);
+ vertices.add(pos3[0]);
+ vertices.add(pos3[1]);
+ vertices.add(pos3[2]);
+ }
+ }
+
+ /**
+ * Get a list of exterior edges from the building geometry. The exterior edges are the ones that
+ * are not shared by two triangles.
+ *
+ * @param triangulation
+ * @return
+ */
+ private static HashSet getExteriorEdges(Geometry triangulation) {
+ HashSet edges = new HashSet<>(triangulation.getNumGeometries() * 3, 1.0f);
+ for (int i = 0; i < triangulation.getNumGeometries(); i++) {
+ Geometry triangle = triangulation.getGeometryN(i);
+ Coordinate corner1 = triangle.getCoordinates()[0];
+ Coordinate corner2 = triangle.getCoordinates()[1];
+ Coordinate corner3 = triangle.getCoordinates()[2];
+ Edge edge1 = new Edge(new Coordinate[] {corner1, corner2});
+ if (edges.contains(edge1)) {
+ edges.remove(edge1);
+ } else {
+ edges.add(edge1);
+ }
+ Edge edge2 = new Edge(new Coordinate[] {corner2, corner3});
+ if (edges.contains(edge2)) {
+ edges.remove(edge2);
+ } else {
+ edges.add(edge2);
+ }
+ Edge edge3 = new Edge(new Coordinate[] {corner3, corner1});
+ if (edges.contains(edge3)) {
+ edges.remove(edge3);
+ } else {
+ edges.add(edge3);
+ }
+ }
+ return edges;
+ }
+
+ /**
+ * Create the roof geometry. This is simply the triangulation of the building geometry at the
+ * building height.
+ *
+ * @param building
+ * @param translation
+ * @param triangulation
+ * @param vertices
+ * @param indices
+ */
+ private static void createRoof(Building building, float[] translation, Geometry triangulation,
+ List vertices, List indices) {
+ for (int i = 0; i < triangulation.getNumGeometries(); i++) {
+ Geometry triangle = triangulation.getGeometryN(i);
+ Coordinate corner1 = triangle.getCoordinates()[0];
+ Coordinate corner2 = triangle.getCoordinates()[1];
+ Coordinate corner3 = triangle.getCoordinates()[2];
+
+ float[] pos0 = cartesian3FromDegrees((float) corner1.y, (float) corner1.x, building.height());
+ pos0[0] -= translation[0];
+ pos0[1] -= translation[1];
+ pos0[2] -= translation[2];
+ float[] pos1 = cartesian3FromDegrees((float) corner2.y, (float) corner2.x, building.height());
+ pos1[0] -= translation[0];
+ pos1[1] -= translation[1];
+ pos1[2] -= translation[2];
+ float[] pos2 = cartesian3FromDegrees((float) corner3.y, (float) corner3.x, building.height());
+ pos2[0] -= translation[0];
+ pos2[1] -= translation[1];
+ pos2[2] -= translation[2];
+
+ indices.add(vertices.size() / 3);
+ vertices.add(pos0[0]);
+ vertices.add(pos0[1]);
+ vertices.add(pos0[2]);
+ indices.add(vertices.size() / 3);
+ vertices.add(pos1[0]);
+ vertices.add(pos1[1]);
+ vertices.add(pos1[2]);
+ indices.add(vertices.size() / 3);
+ vertices.add(pos2[0]);
+ vertices.add(pos2[1]);
+ vertices.add(pos2[2]);
+ }
+ }
+
+ /**
+ * Create a GLTF binary from a list of nodes.
+ *
+ * @param nodes
+ * @return
+ * @throws Exception
+ */
+ public static byte[] createGltf(List nodes) throws Exception {
+ DefaultSceneModel sceneModel = new DefaultSceneModel();
+
+ for (NodeModel node : nodes) {
+ sceneModel.addNode(node);
+ }
+
+ GltfModelBuilder gltfModelBuilder = GltfModelBuilder.create();
+ gltfModelBuilder.addSceneModel(sceneModel);
+ DefaultGltfModel gltfModel = gltfModelBuilder.build();
+
+ ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+ GltfAssetV2 asset = GltfAssetsV2.createEmbedded(gltfModel);
+
+ GltfAssetWriterV2 gltfAssetWriter = new GltfAssetWriterV2();
+ gltfAssetWriter.writeBinary(asset, outputStream);
+
+ return outputStream.toByteArray();
+ }
+
+ /**
+ * Returns a Cartesian3 position from longitude and latitude values given in radians. Port of
+ * CesiumGS
+ *
+ * @param latitude in radians
+ * @param longitude in radians
+ * @param height in meters above the ellipsoid.
+ * @return The position
+ */
+ public static float[] cartesian3FromRadians(float latitude, float longitude, float height) {
+ Vector3D wgs84RadiiSquared = new Vector3D(6378137.0f * 6378137.0f, 6378137.0f * 6378137.0f,
+ 6356752.3142451793f * 6356752.3142451793f);
+ double cosLatitude = Math.cos(latitude);
+ Vector3D scratchN = new Vector3D(cosLatitude * Math.cos(longitude),
+ cosLatitude * Math.sin(longitude), Math.sin(latitude)).normalize();
+ Vector3D scratchK = new Vector3D(wgs84RadiiSquared.getX() * scratchN.getX(),
+ wgs84RadiiSquared.getY() * scratchN.getY(), wgs84RadiiSquared.getZ() * scratchN.getZ());
+ double gamma = Math.sqrt(scratchN.dot(scratchK));
+ scratchK = scratchK.divide(gamma);
+ scratchN =
+ new Vector3D(scratchN.getX() * height, scratchN.getY() * height, scratchN.getZ() * height);
+ Vector3D result = scratchK.add(scratchN);
+ return new float[] {(float) result.getX(), (float) result.getZ(), -(float) result.getY()};
+ }
+
+ /**
+ * Returns a Cartesian3 position from longitude and latitude values given in degrees. Port of
+ * CesiumGS
+ *
+ * @param latitude in degrees
+ * @param longitude in degrees
+ * @param height in meters above the ellipsoid.
+ * @return The position
+ */
+ public static float[] cartesian3FromDegrees(float latitude, float longitude, float height) {
+ return cartesian3FromRadians(latitude * (float) Math.PI / 180,
+ longitude * (float) Math.PI / 180, height);
+ }
+}
diff --git a/baremaps-core/src/main/java/org/apache/baremaps/tdtiles/TdTilesStore.java b/baremaps-core/src/main/java/org/apache/baremaps/tdtiles/TdTilesStore.java
new file mode 100644
index 000000000..7c83a8258
--- /dev/null
+++ b/baremaps-core/src/main/java/org/apache/baremaps/tdtiles/TdTilesStore.java
@@ -0,0 +1,91 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.baremaps.tdtiles;
+
+
+
+import java.sql.Connection;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.*;
+import javax.sql.DataSource;
+import org.apache.baremaps.tdtiles.building.Building;
+import org.apache.baremaps.tilestore.TileStoreException;
+import org.apache.baremaps.utils.GeometryUtils;
+import org.locationtech.jts.geom.Geometry;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * A read-only {@code TileStore} implementation that uses the PostgreSQL to generate 3d tiles.
+ */
+public class TdTilesStore {
+
+ private static final Logger logger = LoggerFactory.getLogger(TdTilesStore.class);
+ private static final String QUERY =
+ "select st_asbinary(geom), tags -> 'buildings:height', tags -> 'height', tags -> 'buildings:levels' from osm_ways where tags ? 'building' and st_intersects(geom, st_makeenvelope(%1$s, %2$s, %3$s, %4$s, 4326)) LIMIT %5$s";
+
+
+ private final DataSource datasource;
+
+ public TdTilesStore(DataSource datasource) {
+ this.datasource = datasource;
+ }
+
+ public List read(float xmin, float xmax, float ymin, float ymax, int limit)
+ throws TileStoreException {
+ try (Connection connection = datasource.getConnection();
+ Statement statement = connection.createStatement()) {
+
+ String sql = String.format(QUERY, ymin * 180 / (float) Math.PI, xmin * 180 / (float) Math.PI,
+ ymax * 180 / (float) Math.PI, xmax * 180 / (float) Math.PI, limit);
+
+ logger.debug("Executing query: {}", sql);
+ System.out.println(sql);
+
+ List buildings = new ArrayList<>();
+
+
+ try (ResultSet resultSet = statement.executeQuery(sql)) {
+ while (resultSet.next()) {
+ byte[] bytes = resultSet.getBytes(1);
+ Geometry geometry = GeometryUtils.deserialize(bytes);
+
+ String buildingHeight = resultSet.getString(2);
+ String height = resultSet.getString(3);
+ String buildingLevels = resultSet.getString(4);
+ float finalHeight = 10;
+ if (buildingHeight != null) {
+ finalHeight = Float.parseFloat(buildingHeight.replaceAll("[^0-9]", ""));
+ } else if (height != null) {
+ finalHeight = Float.parseFloat(height.replaceAll("[^0-9]", ""));
+ } else if (buildingLevels != null) {
+ finalHeight = Float.parseFloat(buildingLevels.replaceAll("[^0-9]", "")) * 3;
+ }
+
+ buildings.add(new Building(geometry, finalHeight));
+ }
+ }
+ return buildings;
+ } catch (SQLException e) {
+ throw new TileStoreException(e);
+ }
+ }
+
+}
diff --git a/baremaps-core/src/main/java/org/apache/baremaps/tdtiles/building/Building.java b/baremaps-core/src/main/java/org/apache/baremaps/tdtiles/building/Building.java
new file mode 100644
index 000000000..abe69ca9d
--- /dev/null
+++ b/baremaps-core/src/main/java/org/apache/baremaps/tdtiles/building/Building.java
@@ -0,0 +1,24 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.baremaps.tdtiles.building;
+
+import org.locationtech.jts.geom.Geometry;
+
+public record Building(Geometry geometry, float height) {
+
+}
diff --git a/baremaps-core/src/main/java/org/apache/baremaps/tdtiles/subtree/Availability.java b/baremaps-core/src/main/java/org/apache/baremaps/tdtiles/subtree/Availability.java
new file mode 100644
index 000000000..4e5f4b4c8
--- /dev/null
+++ b/baremaps-core/src/main/java/org/apache/baremaps/tdtiles/subtree/Availability.java
@@ -0,0 +1,21 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.baremaps.tdtiles.subtree;
+
+public record Availability(boolean constant) {
+}
diff --git a/baremaps-core/src/main/java/org/apache/baremaps/tdtiles/subtree/Subtree.java b/baremaps-core/src/main/java/org/apache/baremaps/tdtiles/subtree/Subtree.java
new file mode 100644
index 000000000..18ae9d873
--- /dev/null
+++ b/baremaps-core/src/main/java/org/apache/baremaps/tdtiles/subtree/Subtree.java
@@ -0,0 +1,24 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.baremaps.tdtiles.subtree;
+
+public record Subtree(Availability tileAvailability,
+ Availability contentAvailability,
+ Availability childSubtreeAvailability) {
+
+}
diff --git a/baremaps-server/src/main/java/org/apache/baremaps/server/TdTilesResources.java b/baremaps-server/src/main/java/org/apache/baremaps/server/TdTilesResources.java
new file mode 100644
index 000000000..b328f5c66
--- /dev/null
+++ b/baremaps-server/src/main/java/org/apache/baremaps/server/TdTilesResources.java
@@ -0,0 +1,109 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.baremaps.server;
+
+import static com.google.common.net.HttpHeaders.*;
+import static io.netty.handler.codec.http.HttpHeaders.Values.APPLICATION_JSON;
+import static io.netty.handler.codec.http.HttpHeaders.Values.BINARY;
+
+import com.linecorp.armeria.common.HttpData;
+import com.linecorp.armeria.common.HttpResponse;
+import com.linecorp.armeria.common.ResponseHeaders;
+import com.linecorp.armeria.server.annotation.Get;
+import com.linecorp.armeria.server.annotation.Param;
+import de.javagl.jgltf.model.NodeModel;
+import java.util.ArrayList;
+import java.util.List;
+import javax.sql.DataSource;
+import org.apache.baremaps.tdtiles.GltfBuilder;
+import org.apache.baremaps.tdtiles.TdTilesStore;
+import org.apache.baremaps.tdtiles.building.Building;
+import org.apache.baremaps.tdtiles.subtree.Availability;
+import org.apache.baremaps.tdtiles.subtree.Subtree;
+
+public class TdTilesResources {
+
+ private static final ResponseHeaders GLB_HEADERS = ResponseHeaders.builder(200)
+ .add(CONTENT_TYPE, BINARY)
+ .add(ACCESS_CONTROL_ALLOW_ORIGIN, "*")
+ .build();
+
+ private static final ResponseHeaders JSON_HEADERS = ResponseHeaders.builder(200)
+ .add(CONTENT_TYPE, APPLICATION_JSON)
+ .add(ACCESS_CONTROL_ALLOW_ORIGIN, "*")
+ .build();
+
+ private final TdTilesStore tdTilesStore;
+
+ public TdTilesResources(DataSource dataSource) {
+ this.tdTilesStore = new TdTilesStore(dataSource);
+ }
+
+ @Get("regex:^/subtrees/(?[0-9]+).(?[0-9]+).(?[0-9]+).json")
+ public HttpResponse getSubtree(@Param("level") int level, @Param("x") int x, @Param("y") int y) {
+ if (level == 18) {
+ return HttpResponse.ofJson(JSON_HEADERS,
+ new Subtree(new Availability(false), new Availability(true), new Availability(false)));
+ }
+ return HttpResponse.ofJson(JSON_HEADERS,
+ new Subtree(new Availability(true), new Availability(true), new Availability(true)));
+ }
+
+ @Get("regex:^/content/content_(?[0-9]+)__(?[0-9]+)_(?[0-9]+).glb")
+ public HttpResponse getContent(@Param("level") int level, @Param("x") int x, @Param("y") int y)
+ throws Exception {
+ if (level < 14) {
+ return HttpResponse.of(GLB_HEADERS, HttpData.wrap(GltfBuilder.createGltf(new ArrayList<>())));
+ }
+ float[] coords = xyzToLatLonRadians(x, y, level);
+ List nodes = new ArrayList<>();
+ int limit = level > 17 ? 1000 : level > 16 ? 200 : level > 15 ? 30 : 10;
+ List buildings = tdTilesStore.read(coords[0], coords[1], coords[2], coords[3], limit);
+ for (Building building : buildings) {
+ float tolerance = level > 17 ? 0.00001f : level > 15 ? 0.00002f : 0.00004f;
+ nodes.add(GltfBuilder.createNode(building, tolerance));
+ }
+ return HttpResponse.of(GLB_HEADERS, HttpData.wrap(GltfBuilder.createGltf(nodes)));
+ }
+
+ /**
+ * Convert XYZ tile coordinates to lat/lon in radians.
+ *
+ * @param x
+ * @param y
+ * @param z
+ * @return
+ */
+ public static float[] xyzToLatLonRadians(int x, int y, int z) {
+ float[] answer = new float[4];
+ int subdivision = 1 << z;
+ float yWidth = (float) Math.PI / subdivision;
+ float xWidth = 2 * (float) Math.PI / subdivision;
+ answer[0] = -(float) Math.PI / 2 + y * yWidth; // Lon
+ answer[1] = answer[0] + yWidth; // Lon max
+ answer[2] = -(float) Math.PI + xWidth * x; // Lat
+ answer[3] = answer[2] + xWidth; // Lat max
+ // Clamp to -PI/2 to PI/2
+ answer[0] = Math.max(-(float) Math.PI / 2, Math.min((float) Math.PI / 2, answer[0]));
+ answer[1] = Math.max(-(float) Math.PI / 2, Math.min((float) Math.PI / 2, answer[1]));
+ // Clamp to -PI to PI
+ answer[2] = Math.max(-(float) Math.PI, Math.min((float) Math.PI, answer[2]));
+ answer[3] = Math.max(-(float) Math.PI, Math.min((float) Math.PI, answer[3]));
+ return answer;
+ }
+}
diff --git a/baremaps-server/src/main/resources/tdtiles/favicon.ico b/baremaps-server/src/main/resources/tdtiles/favicon.ico
new file mode 100644
index 000000000..7162b07e3
Binary files /dev/null and b/baremaps-server/src/main/resources/tdtiles/favicon.ico differ
diff --git a/baremaps-server/src/main/resources/tdtiles/index.html b/baremaps-server/src/main/resources/tdtiles/index.html
new file mode 100644
index 000000000..66449d058
--- /dev/null
+++ b/baremaps-server/src/main/resources/tdtiles/index.html
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/baremaps-server/src/main/resources/tdtiles/tileset.json b/baremaps-server/src/main/resources/tdtiles/tileset.json
new file mode 100644
index 000000000..84850eb87
--- /dev/null
+++ b/baremaps-server/src/main/resources/tdtiles/tileset.json
@@ -0,0 +1,24 @@
+{
+ "asset" : {
+ "version" : "1.1"
+ },
+ "geometricError" : 5000.0,
+ "root" : {
+ "boundingVolume" : {
+ "region" : [ -3.141592653589793238, -1.57079632679, 3.141592653589793238, 1.57079632679, 0, 1000]
+ },
+ "geometricError" : 100000.0,
+ "refine" : "REPLACE",
+ "content" : {
+ "uri" : "/content/content_{level}__{x}_{y}.glb"
+ },
+ "implicitTiling" : {
+ "subdivisionScheme" : "QUADTREE",
+ "subtreeLevels" : 3,
+ "availableLevels" : 5,
+ "subtrees" : {
+ "uri" : "/subtrees/{level}.{x}.{y}.json"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/basemap/import.js b/basemap/import.js
index f5490ae73..8d546251b 100644
--- a/basemap/import.js
+++ b/basemap/import.js
@@ -119,7 +119,7 @@ export default {
"type": "ImportOsmPbf",
"file": "data/data.osm.pbf",
"database": config.database,
- "databaseSrid": 3857,
+ "databaseSrid": 4326,
"replaceExisting": true,
},
{
diff --git a/examples/tdtiles/README.md b/examples/tdtiles/README.md
new file mode 100644
index 000000000..1d541c6f8
--- /dev/null
+++ b/examples/tdtiles/README.md
@@ -0,0 +1,5 @@
+# OpenStreetMap example
+
+This folder contains the required files to create and serve 3d tiles from OpenStreetMap data.
+
+Refer to the [official documentation](https://baremaps.apache.org/examples/serve-3d-tiles/) for more information.
\ No newline at end of file
diff --git a/examples/tdtiles/indexes.sql b/examples/tdtiles/indexes.sql
new file mode 100644
index 000000000..9cad3fc46
--- /dev/null
+++ b/examples/tdtiles/indexes.sql
@@ -0,0 +1,6 @@
+CREATE INDEX IF NOT EXISTS osm_ways_gin ON osm_ways USING gin (nodes);
+CREATE INDEX IF NOT EXISTS osm_ways_tags_gin ON osm_ways USING gin (tags);
+CREATE INDEX IF NOT EXISTS osm_relations_gin ON osm_relations USING gin (member_refs);
+CREATE INDEX IF NOT EXISTS osm_nodes_gix ON osm_nodes USING GIST (geom);
+CREATE INDEX IF NOT EXISTS osm_ways_gix ON osm_ways USING GIST (geom);
+CREATE INDEX IF NOT EXISTS osm_relations_gix ON osm_relations USING GIST (geom);
\ No newline at end of file
diff --git a/examples/tdtiles/workflow.json b/examples/tdtiles/workflow.json
new file mode 100644
index 000000000..fa7388e9f
--- /dev/null
+++ b/examples/tdtiles/workflow.json
@@ -0,0 +1,42 @@
+{
+ "steps": [
+ {
+ "id": "download",
+ "needs": [],
+ "tasks": [
+ {
+ "type": "DownloadUrl",
+ "url": "https://download.geofabrik.de/europe/liechtenstein-latest.osm.pbf",
+ "path": "liechtenstein-latest.osm.pbf"
+ }
+ ]
+ },
+ {
+ "id": "import",
+ "needs": [
+ "download"
+ ],
+ "tasks": [
+ {
+ "type": "ImportOpenStreetMap",
+ "file": "liechtenstein-latest.osm.pbf",
+ "database": "jdbc:postgresql://localhost:5432/baremaps?&user=baremaps&password=baremaps",
+ "databaseSrid": 4326
+ }
+ ]
+ },
+ {
+ "id": "index",
+ "needs": [
+ "import"
+ ],
+ "tasks": [
+ {
+ "type": "ExecuteSql",
+ "file": "indexes.sql",
+ "database": "jdbc:postgresql://localhost:5432/baremaps?&user=baremaps&password=baremaps"
+ }
+ ]
+ }
+ ]
+}
diff --git a/pom.xml b/pom.xml
index bd1744c98..5c528afb0 100644
--- a/pom.xml
+++ b/pom.xml
@@ -93,6 +93,7 @@ limitations under the License.
5.0.1
1.52
2.16.1
+ 2.0.3
1.35
1.19.0
5.10.0
@@ -196,6 +197,16 @@ limitations under the License.
pgbulkinsert
${version.lib.pgbulkinsert}
+
+ de.javagl
+ jgltf-model
+ ${version.lib.jgltf}
+
+
+ de.javagl
+ jgltf-model-builder
+ ${version.lib.jgltf}
+
info.picocli
picocli