OSM Buildings¶
This application shows a map obtained from OpenStreetMap (OSM) servers or a
locally limited data set when the server is unavailable using
Qt Quick 3D.
It is a port of the equivalent C++ demo.
It requires the mapbox_earcut Python module to be installed:
pip install -r requirements.txt
Controls¶
When you run the application, use the following controls for navigation.
Windows |
Android |
|
Pan |
Left mouse button + drag |
Drag |
Zoom |
Mouse wheel |
Pinch |
Rotate |
Right mouse button + drag |
n/a |
Fetching and parsing data¶
A custom request handler class (class OSMRequest) is implemented for
fetching the data from the OSM map servers. It uses queues to handle concurrent
requests to boost up the loading process of maps and building data .
The application parses the online building JSON data and converts it to a list
of keys and values in geo formats such as
QGeoPolygon (see class OSMGeometry).
It is then sent to a custom geometry item to convert the geo coordinates to 3D coordinates.
The required data for the index and vertex buffers, such as position, normals, tangents, and UV coordinates, is generated.
The downloaded PNG map data is sent to a custom
QQuick3DTextureData item to convert the PNG
format to a texture for map tiles.
The application uses camera position, orientation, zoom level, and tilt to find
the nearest tiles in the view (see OSMManager.setCameraProperties()).
Rendering¶
Every chunk of the map tile consists of a QML model (the 3D geometry) and a custom material which uses a rectangle as a base to render the tilemap texture.
The application uses a custom geometry to render tile buildings.
The code for drawing spheres is modeled after the OpenGL Sphere example code.
To render building parts such as rooftops with one draw call, a custom shader is used.
# Copyright (C) 2024 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import argparse
import sys
from pathlib import Path
from PySide6.QtQml import QQmlApplicationEngine
from PySide6.QtGui import QGuiApplication
from geometry import OSMGeometry # noqa: F401
from manager import OSMManager, CustomTextureData # noqa: F401
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="OSM Buildings")
parser.add_argument("--disable-buildings", "-b", action="store_true")
args = parser.parse_args()
if args.disable_buildings:
OSMManager.buildings = False
app = QGuiApplication(sys.argv)
engine = QQmlApplicationEngine()
engine.addImportPath(Path(__file__).parent)
engine.loadFromModule("OSMBuildings", "Main")
if not engine.rootObjects():
sys.exit(-1)
exit_code = QGuiApplication.exec()
del engine
sys.exit(exit_code)
# Copyright (C) 2024 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from PySide6.QtQuick3D import QQuick3DTextureData
from PySide6.QtQml import QmlElement
from PySide6.QtGui import QImage, QVector3D
from PySide6.QtCore import QByteArray, QObject, QThreadPool, Property, Slot, Signal
from request import OSMTileData, OSMRequest
# To be used on the @QmlElement decorator
# (QML_IMPORT_MINOR_VERSION is optional)
QML_IMPORT_NAME = "OSMBuildings"
QML_IMPORT_MAJOR_VERSION = 1
@QmlElement
class OSMManager(QObject):
buildingsDataReady = Signal(list, int, int, int)
mapsDataReady = Signal(QByteArray, int, int, int)
buildings = True
def __init__(self, parent=None):
super().__init__(parent)
self.m_request = OSMRequest(self)
self.m_startBuildingTileX = 17605
self.m_startBuildingTileY = 10746
self.m_tileSizeX = 37
self.m_tileSizeY = 37
self.m_request.buildingsDataReady.connect(self._slotBuildingsDataReady)
self.m_request.mapsDataReady.connect(self._slotMapsDataReady)
self.m_buildingsHash = set()
@Slot()
def stop(self):
self.m_request.stop()
self.m_request.buildingsDataReady.disconnect(self._slotBuildingsDataReady)
self.m_request.mapsDataReady.disconnect(self._slotMapsDataReady)
# Stop the threads started by OSMGeometry in the global pool
QThreadPool.globalInstance().waitForDone()
def tileSizeX(self):
return self.m_tileSizeX
def tileSizeY(self):
return self.m_tileSizeY
@Slot(list, int, int, int)
def _slotBuildingsDataReady(self, geoVariantsList, tileX, tileY, zoomLevel):
self.m_buildingsHash.add(OSMTileData(tileX, tileY, zoomLevel))
self.buildingsDataReady.emit(geoVariantsList, tileX - self.m_startBuildingTileX,
tileY - self.m_startBuildingTileY,
zoomLevel)
@Slot(QByteArray, int, int, int)
def _slotMapsDataReady(self, mapData, tileX, tileY, zoomLevel):
self.mapsDataReady.emit(mapData, tileX - self.m_startBuildingTileX,
tileY - self.m_startBuildingTileY, zoomLevel)
@Slot(QVector3D, QVector3D, float, float, float, float, float, float)
def setCameraProperties(self, position, right,
cameraZoom, minimumZoom, maximumZoom,
cameraTilt, minimumTilt, maximumTilt):
tiltFactor = (cameraTilt - minimumTilt) / max(maximumTilt - minimumTilt, 1.0)
zoomFactor = (cameraZoom - minimumZoom) / max(maximumZoom - minimumZoom, 1.0)
# Forward vector align to the XY plane
forwardVector = QVector3D.crossProduct(right, QVector3D(0.0, 0.0, -1.0)).normalized()
projectionOfForwardOnXY = position + forwardVector * tiltFactor * zoomFactor * 50.0
queue = []
for forwardIndex in range(-20, 21):
for sidewardIndex in range(-20, 21):
vx = float(self.m_tileSizeX * sidewardIndex)
vy = float(self.m_tileSizeY * forwardIndex)
transferredPosition = projectionOfForwardOnXY + QVector3D(vx, vy, 0)
tile_x = self.m_startBuildingTileX + int(transferredPosition.x() / self.m_tileSizeX)
tile_y = self.m_startBuildingTileY - int(transferredPosition.y() / self.m_tileSizeY)
self.addBuildingRequestToQueue(queue, tile_x, tile_y)
projectedTileX = (self.m_startBuildingTileX + int(projectionOfForwardOnXY.x()
/ self.m_tileSizeX))
projectedTileY = (self.m_startBuildingTileY - int(projectionOfForwardOnXY.y()
/ self.m_tileSizeY))
def tile_sort_key(tile_data):
return tile_data.distanceTo(projectedTileX, projectedTileY)
queue.sort(key=tile_sort_key)
if self.buildings:
self.m_request.getBuildingsData(queue.copy())
self.m_request.getMapsData(queue.copy())
def addBuildingRequestToQueue(self, queue, tileX, tileY, zoomLevel=15):
data = OSMTileData(tileX, tileY, zoomLevel)
if data not in self.m_buildingsHash:
queue.append(data)
@Slot(result=bool)
def isDemoToken(self):
return self.m_request.isDemoToken()
@Slot(str)
def setToken(self, token):
self.m_request.setToken(token)
@Slot(result=str)
def token(self):
return self.m_request.token()
tileSizeX = Property(int, tileSizeX, constant=True)
tileSizeY = Property(int, tileSizeY, constant=True)
@QmlElement
class CustomTextureData(QQuick3DTextureData):
@Slot(QByteArray)
def setImageData(self, data):
image = QImage.fromData(data).convertToFormat(QImage.Format.Format_RGBA8888)
self.setTextureData(QByteArray(bytearray(image.constBits())))
self.setSize(image.size())
self.setHasTransparency(False)
self.setFormat(QQuick3DTextureData.Format.RGBA8)
# Copyright (C) 2024 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import math
import sys
from dataclasses import dataclass
from enum import IntEnum
from functools import partial
from PySide6.QtPositioning import QGeoCoordinate, QGeoPolygon
from PySide6.QtNetwork import QNetworkAccessManager, QNetworkReply, QNetworkRequest
from PySide6.QtCore import (QByteArray, QTimer, QFile, QFileInfo, QJsonDocument,
QObject, QUrl, Signal, Slot)
# %1 = zoom level(15 the default and only one here that seems working),
# %2 = x tile number, %3 = y tile number
URL_OSMB_JSON = ("https://983wdxn2c2.execute-api.eu-north-1.amazonaws.com/production/"
"osmbuildingstile?z={}&x={}&y={}&token={}")
# %1 = zoom level(is dynamic), %2 = x tile number, %3 = y tile number
URL_OSMB_MAP = "https://tile-a.openstreetmap.fr/hot/{}/{}/{}.png"
class GeoTypeSwitch(IntEnum):
Polygon = 0
Feature = 1
FeatureCollection = 2
@dataclass
class OSMTileData:
TileX: int = 0
TileY: int = 0
ZoomLevel: int = 1
def distanceTo(self, x, y):
deltaX = float(self.TileX) - float(x)
deltaY = float(self.TileY) - float(y)
return math.sqrt(deltaX * deltaX + deltaY * deltaY)
def __eq__(self, rhs):
return self._equals(rhs)
def __ne__(self, rhs):
return not self._equals(rhs)
def __hash__(self):
return hash((self.TileX, self.TileY, self.ZoomLevel))
def _equals(self, rhs):
return (self.TileX == rhs.TileX and self.TileY == rhs.TileY
and self.ZoomLevel == rhs.ZoomLevel)
def tileKey(tile):
return f"{tile.ZoomLevel},{tile.TileX},{tile.TileY}"
def importPosition(position):
returnedCoordinates = QGeoCoordinate()
if position:
returnedCoordinates.setLongitude(position[0])
if len(position) > 1:
returnedCoordinates.setLatitude(position[1])
if len(position) > 2:
returnedCoordinates.setAltitude(position[2])
return returnedCoordinates
def importArrayOfPositions(arrayOfPositions):
returnedCoordinates = []
for position in arrayOfPositions:
coordinate = importPosition(position)
if coordinate.isValid():
returnedCoordinates.append(coordinate) # Populating the QList of coordinates
return returnedCoordinates
def importArrayOfArrayOfPositions(arrayOfArrayofPositions):
returnedCoordinates = []
for position in arrayOfArrayofPositions:
returnedCoordinates.append(importArrayOfPositions(position))
return returnedCoordinates
def importPolygon(inputMap):
returnedObject = QGeoPolygon()
valueCoordinates = inputMap.get("coordinates")
for i, p in enumerate(importArrayOfArrayOfPositions(valueCoordinates)):
if i == 0:
returnedObject.setPerimeter(p) # External perimeter
else:
returnedObject.addHole(p) # Inner perimeters
return returnedObject
def importGeometry(inputMap):
returnedObject = {}
geometryTypes = ["Polygon"]
for i in range(len(geometryTypes)):
if inputMap.get("type") == geometryTypes[i]:
if i == 0:
returnedObject["type"] = "Polygon"
returnedObject["data"] = importPolygon(inputMap)
return returnedObject
def importFeatureCollection(inputMap):
returnedObject = []
featuresList = inputMap.get("features")
for inputfeature in featuresList:
inputFeatureMap = inputfeature
singleFeatureMap = importGeometry(inputFeatureMap.get("geometry"))
importedProperties = inputFeatureMap.get("properties")
singleFeatureMap["properties"] = importedProperties
if "id" in inputFeatureMap:
importedId = inputFeatureMap.get("id")
singleFeatureMap["id"] = importedId
returnedObject.append(singleFeatureMap)
return returnedObject
def importGeoJson(geoJson):
returnedList = []
rootGeoJsonObject = geoJson.object() # Read json object from imported doc
geoType = ["Polygon", "Feature", "FeatureCollection"]
geometryTypesLen = len(geoType)
parsedGeoJsonMap = {}
# Checking whether the JSON object has a "type" member
valueType = rootGeoJsonObject.get("type")
# Checking whether the "type" member has a GeoJSON admitted value
for i in range(geometryTypesLen):
if valueType == geoType[i]:
if i == GeoTypeSwitch.Polygon:
poly = importPolygon(rootGeoJsonObject)
parsedGeoJsonMap.insert("type", "Polygon")
parsedGeoJsonMap.insert("data", poly)
# Single GeoJson geometry object with properties
elif i == GeoTypeSwitch.Feature:
parsedGeoJsonMap = importGeometry(rootGeoJsonObject.get("geometry"))
importedProperties = rootGeoJsonObject.get("properties")
parsedGeoJsonMap.insert("properties", importedProperties)
id_value = rootGeoJsonObject.get("id")
if id_value:
parsedGeoJsonMap.insert("id", id_value)
# Heterogeneous list of GeoJSON geometries with properties
elif i == GeoTypeSwitch.FeatureCollection:
featCollection = importFeatureCollection(rootGeoJsonObject)
parsedGeoJsonMap["type"] = "FeatureCollection"
parsedGeoJsonMap["data"] = featCollection
bboxNodeValue = rootGeoJsonObject.get("bbox")
if bboxNodeValue is not None:
parsedGeoJsonMap["bbox"] = bboxNodeValue
returnedList.append(parsedGeoJsonMap)
elif i >= 9:
# Error
break
return returnedList
class OSMRequest(QObject):
buildingsDataReady = Signal(list, int, int, int)
mapsDataReady = Signal(QByteArray, int, int, int)
def __init__(self, parent):
super().__init__(parent)
self.m_buildingsNumberOfRequestsInFlight = 0
self.m_mapsNumberOfRequestsInFlight = 0
self.m_queuesTimer = QTimer()
self.m_queuesTimer.setInterval(0)
self.m_buildingsQueue = []
self.m_mapsQueue = []
self.m_networkAccessManager = QNetworkAccessManager()
self.m_token = ""
self.m_queuesTimer.timeout.connect(self._slotTimeOut)
self.m_queuesTimer.setInterval(0)
self.m_lastBuildingsDataError = ""
self.m_lastMapsDataError = ""
@Slot()
def stop(self):
if self.m_queuesTimer.isActive():
self.m_queuesTimer.stop()
@Slot()
def _slotTimeOut(self):
if not self.m_buildingsQueue and not self.m_mapsQueue:
self.m_queuesTimer.stop()
else:
numConcurrentRequests = 6
if (self.m_buildingsQueue
and self.m_buildingsNumberOfRequestsInFlight < numConcurrentRequests):
self.getBuildingsDataRequest(self.m_buildingsQueue[0])
del self.m_buildingsQueue[0]
self.m_buildingsNumberOfRequestsInFlight += 1
if self.m_mapsQueue and self.m_mapsNumberOfRequestsInFlight < numConcurrentRequests:
self.getMapsDataRequest(self.m_mapsQueue[0])
del self.m_mapsQueue[0]
self.m_mapsNumberOfRequestsInFlight += 1
def isDemoToken(self):
return not self.m_token
def token(self):
return self.m_token
def setToken(self, token):
self.m_token = token
def getBuildingsData(self, buildingsQueue):
if not buildingsQueue:
return
self.m_buildingsQueue = buildingsQueue
if not self.m_queuesTimer.isActive():
self.m_queuesTimer.start()
def getBuildingsDataRequest(self, tile):
fileName = "data/" + tileKey(tile) + ".json"
if QFileInfo.exists(fileName):
file = QFile(fileName)
if file.open(QFile.ReadOnly):
data = file.readAll()
file.close()
doc = QJsonDocument.fromJson(data)
self.buildingsDataReady.emit(importGeoJson(doc),
tile.TileX, tile.TileY, tile.ZoomLevel)
self.m_buildingsNumberOfRequestsInFlight -= 1
return
url = QUrl(URL_OSMB_JSON.format(tile.ZoomLevel, tile.TileX, tile.TileY, self.m_token))
reply = self.m_networkAccessManager.get(QNetworkRequest(url))
reply.finished.connect(partial(self._buildingsDataReceived, reply, tile))
@Slot(OSMTileData)
def _buildingsDataReceived(self, reply, tile):
reply.deleteLater()
if reply.error() == QNetworkReply.NoError:
data = reply.readAll()
self.buildingsDataReady.emit(importGeoJson(QJsonDocument.fromJson(data)),
tile.TileX, tile.TileY, tile.ZoomLevel)
else:
message = reply.readAll().data().decode('utf-8')
if message != self.m_lastBuildingsDataError:
self.m_lastBuildingsDataError = message
print("OSMRequest.getBuildingsData ", reply.error(),
reply.url(), message, file=sys.stderr)
self.m_buildingsNumberOfRequestsInFlight -= 1
def getMapsData(self, mapsQueue):
if not mapsQueue:
return
self.m_mapsQueue = mapsQueue
if not self.m_queuesTimer.isActive():
self.m_queuesTimer.start()
def getMapsDataRequest(self, tile):
fileName = "data/" + tileKey(tile) + ".png"
if QFileInfo.exists(fileName):
file = QFile(fileName)
if file.open(QFile.OpenModeFlag.ReadOnly):
data = file.readAll()
file.close()
self.mapsDataReady.emit(data, tile.TileX, tile.TileY, tile.ZoomLevel)
self.m_mapsNumberOfRequestsInFlight -= 1
return
url = QUrl(URL_OSMB_MAP.format(tile.ZoomLevel, tile.TileX, tile.TileY))
reply = self.m_networkAccessManager.get(QNetworkRequest(url))
reply.finished.connect(partial(self._mapsDataReceived, reply, tile))
@Slot(OSMTileData)
def _mapsDataReceived(self, reply, tile):
reply.deleteLater()
if reply.error() == QNetworkReply.NetworkError.NoError:
data = reply.readAll()
self.mapsDataReady.emit(data, tile.TileX, tile.TileY, tile.ZoomLevel)
else:
message = reply.readAll().data().decode('utf-8')
if message != self.m_lastMapsDataError:
self.m_lastMapsDataError = message
print("OSMRequest.getMapsDataRequest", reply.error(),
reply.url(), message, file=sys.stderr)
self.m_mapsNumberOfRequestsInFlight -= 1
# Copyright (C) 2026 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import ctypes
import math
import mapbox_earcut
import numpy
from functools import partial
from PySide6.QtQuick3D import QQuick3DGeometry
from PySide6.QtQml import QmlElement
from PySide6.QtGui import QVector3D, QColor
from PySide6.QtCore import QByteArray, QThreadPool, Qt, Signal, Slot
# To be used on the @QmlElement decorator
# (QML_IMPORT_MINOR_VERSION is optional)
QML_IMPORT_NAME = "OSMBuildings"
QML_IMPORT_MAJOR_VERSION = 1
FLOAT_SIZE = ctypes.sizeof(ctypes.c_float)
FLOAT_MAX = 3.40282e+38
FLOAT_MIN = 1.17549e-38
UINT32_SIZE = 4
STRIDE_VERTEX_LEN = 20
# 3 Position + 3 Normal + 3 Tangent + 3 Binormal + 4 Color + 2 Texcoord0 + 2 Texcoord1
# as Number of Levels and Is Rooftop.
STRIDE_PRIMITIVE = 3
SPHERE_SECTOR_COUNT = 10
SPHERE_STACK_COUNT = 10
def convertGeoCoordToVertexPosition(lat, lon):
scale = 1.212
geoToPositionScale = 1000000 * scale
XOffsetFromCenter = 537277 * scale
YOffsetFromCenter = 327957 * scale
x = (lon / 360.0 + 0.5) * geoToPositionScale
y = ((1.0 - math.log(math.tan(math.radians(lat)) + 1.0 / math.cos(math.radians(lat))) / math.pi)
* 0.5 * geoToPositionScale)
return QVector3D(x - XOffsetFromCenter, YOffsetFromCenter - y, 0.0)
def readColorProperty(properties, name, defaultColor):
if colorName := properties.get("color"):
color = QColor.fromString(colorName)
if color.isValid() and color != QColor(Qt.GlobalColor.black):
return color
return defaultColor
class VertexData:
"""A data buffer for the vertexes consisting of STRIDE_VERTEX_LEN * float32
entries. It can be converted to QByteArray for
QQuick3DGeometry.setVertexData()."""
def __init__(self):
self._vertexData: numpy.ndarray = None
self._index = 0
def vertexCount(self):
return self._vertexData.shape[0] if self._vertexData is not None else 0
def toByteArray(self):
return (QByteArray(self._vertexData.tobytes()) if self._vertexData is not None
else QByteArray())
def growBy(self, count):
if count > 0:
if self._vertexData is None:
self._vertexData = numpy.ndarray(shape=(count, STRIDE_VERTEX_LEN),
dtype=numpy.float32)
else:
oldSize = self.vertexCount()
self._vertexData.resize((oldSize + count, STRIDE_VERTEX_LEN), refcheck=False)
def append(self, pos, normal, tangent, binormal, color, alpha,
texCoordX, texCoordY, levels, isRoofTop):
self.set(self._index, pos, normal, tangent, binormal, color, alpha,
texCoordX, texCoordY, levels, isRoofTop)
self._index += 1
def set(self, index, pos, normal, tangent, binormal, color, alpha,
texCoordX, texCoordY, levels, isRoofTop):
self._vertexData[index][0] = pos.x()
self._vertexData[index][1] = pos.y()
self._vertexData[index][2] = pos.z()
self._vertexData[index][3] = normal.x()
self._vertexData[index][4] = normal.y()
self._vertexData[index][5] = normal.z()
self._vertexData[index][6] = tangent.x()
self._vertexData[index][7] = tangent.y()
self._vertexData[index][8] = tangent.z()
self._vertexData[index][9] = binormal.x()
self._vertexData[index][10] = binormal.y()
self._vertexData[index][11] = binormal.z()
self._vertexData[index][12] = color.redF()
self._vertexData[index][13] = color.greenF()
self._vertexData[index][14] = color.blueF()
self._vertexData[index][15] = alpha
self._vertexData[index][16] = texCoordX
self._vertexData[index][17] = texCoordY
self._vertexData[index][18] = levels
self._vertexData[index][19] = isRoofTop
class IndexData:
"""A data buffer for the vertex indexes consisting uint32 entries. It can be
converted to QByteArray for QQuick3DGeometry.setIndexData()."""
def __init__(self):
self._indexData: numpy.ndarray = None
self._index = 0
def indexCount(self):
return self._indexData.shape[0] if self._indexData is not None else 0
def toByteArray(self):
return (QByteArray(self._indexData.tobytes()) if self._indexData is not None
else QByteArray())
def growBy(self, count):
if count > 0:
if self._indexData is None:
self._indexData = numpy.ndarray(shape=(count),
dtype=numpy.uint32)
else:
oldSize = self.indexCount()
self._indexData.resize((oldSize + count), refcheck=False)
def append(self, v):
self._indexData[self._index] = v
self._index += 1
def append3(self, v1, v2, v3):
self._indexData[self._index] = v1
self._index += 1
self._indexData[self._index] = v2
self._index += 1
self._indexData[self._index] = v3
self._index += 1
@QmlElement
class OSMGeometry(QQuick3DGeometry):
geometryReady = Signal()
def __init__(self, parent=None):
super().__init__(parent)
@Slot("QVariantList")
def updateData(self, geoVariantsList):
QThreadPool.globalInstance().start(partial(self.loadGeometryFromData, geoVariantsList))
def loadGeometryFromData(self, geoVariantsList):
meshMinBound = QVector3D(FLOAT_MAX, FLOAT_MAX, FLOAT_MAX)
meshMaxBound = QVector3D(FLOAT_MIN, FLOAT_MIN, FLOAT_MIN)
globalVertexCounter = 0
globalPrimitiveCounter = 0
vertexData = VertexData()
indexData = IndexData()
for baseData in geoVariantsList:
for featureMap in baseData["data"]:
properties = featureMap["properties"]
buildingCoords = featureMap["data"].perimeter()
height = 0.15 * properties["height"]
levels = float(properties.get("levels", 0))
color = readColorProperty(properties, "color", QColor(Qt.GlobalColor.white))
roofColor = readColorProperty(properties, "roofColor", color)
subsetMinBound = QVector3D(FLOAT_MAX, FLOAT_MAX, FLOAT_MAX)
subsetMaxBound = QVector3D(FLOAT_MIN, FLOAT_MIN, FLOAT_MIN)
numSubsetVertices = len(buildingCoords) * 2
vertexData.growBy(numSubsetVertices)
indexData.growBy((numSubsetVertices - 2) * STRIDE_PRIMITIVE)
subsetVertexCounter = 0
lastBaseVertexPos = QVector3D()
lastExtrudedVertexPos = QVector3D()
currentBaseVertexPos = QVector3D()
currentExtrudedVertexPos = QVector3D()
subsetPolygonCenter = QVector3D()
roofPolygonVertices = numpy.ndarray(shape=(len(buildingCoords), 2),
dtype=numpy.float32)
for b, buildingPoint in enumerate(buildingCoords):
lastBaseVertexPos = currentBaseVertexPos
lastExtrudedVertexPos = currentExtrudedVertexPos
currentBaseVertexPos = convertGeoCoordToVertexPosition(buildingPoint.latitude(), # noqa: E501
buildingPoint.longitude()) # noqa: E501
currentExtrudedVertexPos = QVector3D(currentBaseVertexPos.x(),
currentBaseVertexPos.y(),
height)
roofPolygonVertices[b][0] = currentBaseVertexPos.x()
roofPolygonVertices[b][1] = currentBaseVertexPos.y()
subsetPolygonCenter.setX(subsetPolygonCenter.x() + currentBaseVertexPos.x())
subsetPolygonCenter.setY(subsetPolygonCenter.y() + currentBaseVertexPos.y())
meshMinBound.setX(min(meshMinBound.x(), currentBaseVertexPos.x()))
meshMinBound.setY(min(meshMinBound.y(), currentBaseVertexPos.y()))
meshMinBound.setZ(min(meshMinBound.z(), currentBaseVertexPos.z()))
meshMaxBound.setX(max(meshMaxBound.x(), currentExtrudedVertexPos.x()))
meshMaxBound.setY(max(meshMaxBound.y(), currentExtrudedVertexPos.y()))
meshMaxBound.setZ(max(meshMaxBound.z(), currentExtrudedVertexPos.z()))
subsetMinBound.setX(min(subsetMinBound.x(), currentBaseVertexPos.x()))
subsetMinBound.setY(min(subsetMinBound.y(), currentBaseVertexPos.y()))
subsetMinBound.setZ(min(subsetMinBound.z(), currentBaseVertexPos.z()))
subsetMaxBound.setX(max(subsetMaxBound.x(), currentExtrudedVertexPos.x()))
subsetMaxBound.setY(max(subsetMaxBound.y(), currentExtrudedVertexPos.y()))
subsetMaxBound.setZ(max(subsetMaxBound.z(), currentExtrudedVertexPos.z()))
if subsetVertexCounter < numSubsetVertices - 2:
indexData.append3(globalVertexCounter + 3, globalVertexCounter + 2,
globalVertexCounter + 0)
indexData.append3(globalVertexCounter + 1, globalVertexCounter + 3,
globalVertexCounter + 0)
globalPrimitiveCounter += 2
if subsetVertexCounter == 2:
tangent = (currentExtrudedVertexPos - currentBaseVertexPos).normalized()
binormal = (lastBaseVertexPos - currentBaseVertexPos).normalized()
normal = QVector3D.crossProduct(binormal, tangent).normalized()
vertexData.append(lastBaseVertexPos, normal, tangent,
binormal, color, 1, 0, 0, levels, 0.0)
vertexData.append(lastExtrudedVertexPos, normal,
tangent, binormal, color, 1, 0, 0, levels, 0.0)
if subsetVertexCounter >= 2:
tangent = (currentExtrudedVertexPos - currentBaseVertexPos).normalized()
binormal = (lastBaseVertexPos - currentBaseVertexPos).normalized()
normal = QVector3D.crossProduct(binormal, tangent).normalized()
xCoord = 1.0 if subsetVertexCounter % 4 != 0 else 0.0
vertexData.append(currentBaseVertexPos, normal, tangent,
binormal, color, 1, xCoord, 0, levels, 0.0)
vertexData.append(currentExtrudedVertexPos, normal,
tangent, binormal, color, 1, xCoord, 0, levels, 0.0)
subsetVertexCounter += 2
globalVertexCounter += 2
if properties.get("shape", "") == "sphere":
subsetPolygonCenter = QVector3D(subsetPolygonCenter.x()
/ len(roofPolygonVertices),
subsetPolygonCenter.y()
/ len(roofPolygonVertices),
height)
sphereRadius = 2.0 * abs(roofPolygonVertices[0][0] - subsetPolygonCenter.x())
sphereRadius = max(sphereRadius, 1.0)
sphereRadiuslengthInv = 1.0 / sphereRadius
sphereSectorStep = 2.0 * math.pi / SPHERE_SECTOR_COUNT
sphereStackStep = math.pi / SPHERE_STACK_COUNT
sphereVertexCount = (SPHERE_STACK_COUNT + 1) * (SPHERE_SECTOR_COUNT + 1)
vertexData.growBy(sphereVertexCount)
indexData.growBy(sphereVertexCount * 2 * STRIDE_PRIMITIVE)
for stackIndex in range(0, SPHERE_STACK_COUNT + 1):
k1 = stackIndex * (SPHERE_SECTOR_COUNT + 1)
k2 = k1 + SPHERE_SECTOR_COUNT + 1
sphereStackAngle = math.pi / 2.0 - stackIndex * sphereStackStep
xy = sphereRadius * math.cos(sphereStackAngle)
z = sphereRadius * math.sin(sphereStackAngle)
for sectorIndex in range(0, SPHERE_SECTOR_COUNT + 1):
if stackIndex != SPHERE_STACK_COUNT:
if stackIndex != 0:
indexData.append3(k1 + globalVertexCounter,
k2 + globalVertexCounter,
k1 + 1 + globalVertexCounter)
globalPrimitiveCounter += 1
if stackIndex != (SPHERE_STACK_COUNT - 1):
indexData.append3(k1 + 1 + globalVertexCounter,
k2 + globalVertexCounter,
k2 + 1 + globalVertexCounter)
globalPrimitiveCounter += 1
sphereSectorAngle = sectorIndex * sphereSectorStep
x = xy * math.cos(sphereSectorAngle)
y = xy * math.sin(sphereSectorAngle)
position = QVector3D(x + subsetPolygonCenter.x(),
y + subsetPolygonCenter.y(),
z + subsetPolygonCenter.z())
normal = QVector3D(x * sphereRadiuslengthInv,
y * sphereRadiuslengthInv,
z * sphereRadiuslengthInv)
vertexData.append(position, normal,
QVector3D(0, 0, 0), QVector3D(0, 0, 0),
roofColor, 1, 1.0, 1.0, 0.0, 1.0)
k1 += 1
k2 += 1
subsetVertexCounter += sphereVertexCount
globalVertexCounter += sphereVertexCount
rings = numpy.array([len(roofPolygonVertices)], dtype=numpy.uint32)
roofIndices = mapbox_earcut.triangulate_float32(roofPolygonVertices, rings)
vertexData.growBy(len(roofPolygonVertices))
indexData.growBy(len(roofIndices))
for roofIndex in roofIndices:
indexData.append(roofIndex + globalVertexCounter)
roofPrimitiveCount = int(len(roofIndices) / 3)
globalPrimitiveCounter += roofPrimitiveCount
for polygonVertex in roofPolygonVertices:
position = QVector3D(polygonVertex[0], polygonVertex[1], height)
normal = QVector3D(0.0, 0.0, 1.0)
tangent = QVector3D(1.0, 0.0, 0.0)
binormal = QVector3D(0.0, 1.0, 0.0)
vertexData.append(position, normal, tangent,
binormal, roofColor, 1.0, 1.0, 1.0, 0.0, 1.0)
subsetVertexCounter += 1
globalVertexCounter += 1
self.clear()
self.setIndexData(indexData.toByteArray())
self.setVertexData(vertexData.toByteArray())
self.setStride(STRIDE_VERTEX_LEN * FLOAT_SIZE)
self.setBounds(meshMinBound, meshMaxBound)
self.setPrimitiveType(QQuick3DGeometry.PrimitiveType.Triangles)
self.addAttribute(QQuick3DGeometry.Attribute.IndexSemantic, 0,
QQuick3DGeometry.Attribute.U32Type)
self.addAttribute(QQuick3DGeometry.Attribute.PositionSemantic, 0,
QQuick3DGeometry.Attribute.F32Type)
self.addAttribute(QQuick3DGeometry.Attribute.NormalSemantic, 3 * FLOAT_SIZE,
QQuick3DGeometry.Attribute.F32Type)
self.addAttribute(QQuick3DGeometry.Attribute.TangentSemantic, 6 * FLOAT_SIZE,
QQuick3DGeometry.Attribute.F32Type)
self.addAttribute(QQuick3DGeometry.Attribute.BinormalSemantic, 9 * FLOAT_SIZE,
QQuick3DGeometry.Attribute.F32Type)
self.addAttribute(QQuick3DGeometry.Attribute.ColorSemantic, 12 * FLOAT_SIZE,
QQuick3DGeometry.Attribute.F32Type)
self.addAttribute(QQuick3DGeometry.Attribute.TexCoord0Semantic, 16 * FLOAT_SIZE,
QQuick3DGeometry.Attribute.F32Type)
self.addAttribute(QQuick3DGeometry.Attribute.TexCoord1Semantic, 18 * FLOAT_SIZE,
QQuick3DGeometry.Attribute.F32Type)
self.update()
self.geometryReady.emit()
// Copyright (C) 2024 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Controls
import QtQuick.Window
import QtQuick3D
import QtQuick3D.Helpers
import OSMBuildings
Window {
width: 1024
height: 768
visible: true
title: qsTr("OSM Buildings")
OSMManager {
id: osmManager
onBuildingsDataReady: function( geoVariantsList, tileX, tileY, zoomLevel ){
buildingModels.addModel(geoVariantsList, tileX, tileY, zoomLevel)
}
onMapsDataReady: function( mapData, tileX, tileY, zoomLevel ){
mapModels.addModel(mapData, tileX, tileY, zoomLevel)
}
}
Component {
id: chunkModelBuilding
Node {
id: node
property variant geoVariantsList: null
property int tileX: 0
property int tileY: 0
property int zoomLevel: 0
Model {
id: model
scale: Qt.vector3d(1, 1, 1)
OSMGeometry {
id: osmGeometry
Component.onCompleted: updateData( node.geoVariantsList )
onGeometryReady:{
model.geometry = osmGeometry
}
}
materials: [
CustomMaterial {
shadingMode: CustomMaterial.Shaded
cullMode: Material.BackFaceCulling
vertexShader: "customshaderbuildings.vert"
fragmentShader: "customshaderbuildings.frag"
}
]
}
}
}
Component {
id: chunkModelMap
Node {
id: node
property variant mapData: null
property int tileX: 0
property int tileY: 0
property int zoomLevel: 0
Model {
id: basePlane
position: Qt.vector3d( osmManager.tileSizeX * node.tileX, osmManager.tileSizeY * -node.tileY, 0.0 )
scale: Qt.vector3d( osmManager.tileSizeX / 100., osmManager.tileSizeY / 100., 0.5)
source: "#Rectangle"
materials: [
CustomMaterial {
property TextureInput tileTexture: TextureInput {
enabled: true
texture: Texture {
textureData: CustomTextureData {
Component.onCompleted: setImageData( node.mapData )
} }
}
shadingMode: CustomMaterial.Shaded
cullMode: Material.BackFaceCulling
fragmentShader: "customshadertiles.frag"
}
]
}
}
}
View3D {
id: v3d
anchors.fill: parent
environment: ExtendedSceneEnvironment {
id: env
backgroundMode: SceneEnvironment.Color
clearColor: "#8099b3"
fxaaEnabled: true
fog: Fog {
id: theFog
color:"#8099b3"
enabled: true
depthEnabled: true
depthFar: 600
}
}
Node {
id: originNode
eulerRotation: Qt.vector3d(50.0, 0.0, 0.0)
PerspectiveCamera {
id: cameraNode
frustumCullingEnabled: true
clipFar: 600
clipNear: 100
fieldOfView: 90
z: 100
onZChanged: originNode.updateManagerCamera()
}
Component.onCompleted: updateManagerCamera()
onPositionChanged: updateManagerCamera()
onRotationChanged: updateManagerCamera()
function updateManagerCamera(){
osmManager.setCameraProperties( originNode.position,
originNode.right, cameraNode.z,
cameraController.minimumZoom,
cameraController.maximumZoom,
originNode.eulerRotation.x,
cameraController.minimumTilt,
cameraController.maximumTilt )
}
}
DirectionalLight {
color: Qt.rgba(1.0, 1.0, 0.95, 1.0)
ambientColor: Qt.rgba(0.5, 0.45, 0.45, 1.0)
rotation: Quaternion.fromEulerAngles(-10, -45, 0)
}
Node {
id: buildingModels
function addModel(geoVariantsList, tileX, tileY, zoomLevel)
{
chunkModelBuilding.createObject( buildingModels, {
"geoVariantsList": geoVariantsList,
"tileX": tileX,
"tileY": tileY,
"zoomLevel": zoomLevel
} )
}
}
Node {
id: mapModels
function addModel(mapData, tileX, tileY, zoomLevel)
{
chunkModelMap.createObject( mapModels, { "mapData": mapData,
"tileX": tileX,
"tileY": tileY,
"zoomLevel": zoomLevel
} )
}
}
OSMCameraController {
id: cameraController
origin: originNode
camera: cameraNode
}
}
Item {
id: tokenArea
anchors.left: parent.left
anchors.bottom: parent.bottom
anchors.margins: 10
Text {
id: tokenInputArea
visible: false
anchors.left: parent.left
anchors.bottom: parent.bottom
color: "white"
styleColor: "black"
style: Text.Outline
text: "Open street map tile token: "
Rectangle {
border.width: 1
border.color: "black"
anchors.fill: tokenTxtInput
anchors.rightMargin: -30
Text {
anchors.right: parent.right
anchors.top: parent.top
anchors.topMargin: 2
anchors.rightMargin: 8
color: "blue"
styleColor: "white"
style: Text.Outline
text: "OK"
Behavior on scale {
NumberAnimation {
easing.type: Easing.OutBack
}
}
MouseArea {
anchors.fill: parent
anchors.margins: -10
onPressedChanged: {
if (pressed)
parent.scale = 0.9
else
parent.scale = 1.0
}
onClicked: {
tokenInputArea.visible = false
osmManager.setToken(tokenTxtInput.text)
tokenWarning.demoToken = osmManager.isDemoToken()
tokenWarning.visible = true
}
}
}
}
TextInput {
id: tokenTxtInput
clip: true
anchors.left: parent.right
anchors.bottom: parent.bottom
anchors.bottomMargin: -3
height: tokenTxtInput.contentHeight + 5
width: 110
leftPadding: 5
rightPadding: 5
}
}
Text {
id: tokenWarning
property bool demoToken: true
anchors.left: parent.left
anchors.bottom: parent.bottom
color: "white"
styleColor: "black"
style: Text.Outline
text: demoToken ? "You are using the OSM limited demo token " :
"You are using a token "
Text {
anchors.left: parent.right
color: "blue"
styleColor: "white"
style: Text.Outline
text: "click here to change"
Behavior on scale {
NumberAnimation {
easing.type: Easing.OutBack
}
}
MouseArea {
anchors.fill: parent
onPressedChanged: {
if (pressed)
parent.scale = 0.9
else
parent.scale = 1.0
}
onClicked: {
tokenWarning.visible = false
tokenTxtInput.text = osmManager.token()
tokenInputArea.visible = true
}
}
}
}
}
Action {
id: quitAction
shortcut: StandardKey.Quit
onTriggered: close()
}
onClosing: function(close) {
osmManager.stop();
close.accepted = true;
}
}
// Copyright (C) 2024 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import QtQuick
import QtQuick3D
Item {
id: root
required property Node origin
required property Camera camera
property real xSpeed: 0.05
property real ySpeed: 0.05
property bool xInvert: false
property bool yInvert: false
property bool mouseEnabled: true
property bool panEnabled: true
readonly property bool inputsNeedProcessing: status.useMouse || status.isPanning
readonly property real minimumZoom: 30
readonly property real maximumZoom: 200
readonly property real minimumTilt: 0
readonly property real maximumTilt: 80
implicitWidth: parent.width
implicitHeight: parent.height
Connections {
target: root.camera
Component.onCompleted: {
onZChanged()
}
function onZChanged() {
// Adjust near/far values based on distance
let distance = root.camera.z
if (distance < 1) {
root.camera.clipNear = 0.01
root.camera.clipFar = 100
} else if (distance < 100) {
root.camera.clipNear = 0.1
root.camera.clipFar = 1000
} else {
root.camera.clipNear = 1
root.camera.clipFar = 10000
}
}
}
DragHandler {
id: dragHandler
target: null
enabled: root.mouseEnabled
acceptedModifiers: Qt.NoModifier
acceptedButtons: Qt.RightButton
onCentroidChanged: {
root.mouseMoved(Qt.vector2d(centroid.position.x, centroid.position.y), false);
}
onActiveChanged: {
if (active)
root.mousePressed(Qt.vector2d(centroid.position.x, centroid.position.y));
else
root.mouseReleased(Qt.vector2d(centroid.position.x, centroid.position.y));
}
}
DragHandler {
id: ctrlDragHandler
target: null
enabled: root.mouseEnabled && root.panEnabled
//acceptedModifiers: Qt.ControlModifier
onCentroidChanged: {
root.panEvent(Qt.vector2d(centroid.position.x, centroid.position.y));
}
onActiveChanged: {
if (active)
root.startPan(Qt.vector2d(centroid.position.x, centroid.position.y));
else
root.endPan();
}
}
PinchHandler {
id: pinchHandler
target: null
enabled: root.mouseEnabled
property real distance: 0.0
onCentroidChanged: {
root.panEvent(Qt.vector2d(centroid.position.x, centroid.position.y))
}
onActiveChanged: {
if (active) {
root.startPan(Qt.vector2d(centroid.position.x, centroid.position.y))
distance = root.camera.z
} else {
root.endPan()
distance = 0.0
}
}
onScaleChanged: {
root.camera.z = distance * (1 / scale)
root.camera.z = Math.min(Math.max(root.camera.z, root.minimumZoom), root.maximumZoom)
}
}
TapHandler {
onTapped: root.forceActiveFocus()
}
WheelHandler {
id: wheelHandler
orientation: Qt.Vertical
target: null
enabled: root.mouseEnabled
onWheel: event => {
let delta = -event.angleDelta.y * 0.01;
root.camera.z += root.camera.z * 0.1 * delta
root.camera.z = Math.min(Math.max(root.camera.z, root.minimumZoom), root.maximumZoom)
}
}
function mousePressed(newPos) {
root.forceActiveFocus()
status.currentPos = newPos
status.lastPos = newPos
status.useMouse = true;
}
function mouseReleased(newPos) {
status.useMouse = false;
}
function mouseMoved(newPos: vector2d) {
status.currentPos = newPos;
}
function startPan(pos: vector2d) {
status.isPanning = true;
status.currentPanPos = pos;
status.lastPanPos = pos;
}
function endPan() {
status.isPanning = false;
}
function panEvent(newPos: vector2d) {
status.currentPanPos = newPos;
}
FrameAnimation {
id: updateTimer
running: root.inputsNeedProcessing
onTriggered: status.processInput(frameTime * 100)
}
QtObject {
id: status
property bool useMouse: false
property bool isPanning: false
property vector2d lastPos: Qt.vector2d(0, 0)
property vector2d lastPanPos: Qt.vector2d(0, 0)
property vector2d currentPos: Qt.vector2d(0, 0)
property vector2d currentPanPos: Qt.vector2d(0, 0)
property real rotateAlongZ: 0
property real rotateAlongXY: 50.0
function processInput(frameDelta) {
if (useMouse) {
// Get the delta
let delta = Qt.vector2d(lastPos.x - currentPos.x,
lastPos.y - currentPos.y);
let rotateX = delta.x * root.xSpeed * frameDelta
if ( root.xInvert )
rotateX = -rotateX
rotateAlongZ += rotateX;
let rotateAlongZRad = rotateAlongZ * (Math.PI / 180.)
root.origin.rotate(rotateX, Qt.vector3d(0.0, 0.0, -1.0), Node.SceneSpace)
let rotateY = delta.y * -root.ySpeed * frameDelta
if ( root.yInvert )
rotateY = -rotateY;
let preRotateAlongXY = rotateAlongXY + rotateY
if ( preRotateAlongXY <= root.maximumTilt && preRotateAlongXY >= root.minimumTilt )
{
rotateAlongXY = preRotateAlongXY
root.origin.rotate(rotateY, Qt.vector3d(Math.cos(rotateAlongZRad), Math.sin(-rotateAlongZRad), 0.0), Node.SceneSpace)
}
lastPos = currentPos;
}
if (isPanning) {
let delta = currentPanPos.minus(lastPanPos);
delta.x = -delta.x
delta.x = (delta.x / root.width) * root.camera.z * frameDelta
delta.y = (delta.y / root.height) * root.camera.z * frameDelta
let velocity = Qt.vector3d(0, 0, 0)
// X Movement
let xDirection = root.origin.right
velocity = velocity.plus(Qt.vector3d(xDirection.x * delta.x,
xDirection.y * delta.x,
xDirection.z * delta.x));
// Z Movement
let zDirection = root.origin.right.crossProduct(Qt.vector3d(0.0, 0.0, -1.0))
velocity = velocity.plus(Qt.vector3d(zDirection.x * delta.y,
zDirection.y * delta.y,
zDirection.z * delta.y));
root.origin.position = root.origin.position.plus(velocity)
lastPanPos = currentPanPos
}
}
}
}
module OSMBuildings
Main 1.0 Main.qml
OSMCameraController 1.0 OSMCameraController.qml
// Copyright (C) 2026 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
VARYING vec4 color;
float rectangle(vec2 samplePosition, vec2 halfSize) {
vec2 componentWiseEdgeDistance = abs(samplePosition) - halfSize;
float outsideDistance = length(max(componentWiseEdgeDistance, 0.0));
float insideDistance = min(max(componentWiseEdgeDistance.x, componentWiseEdgeDistance.y), 0.0);
return outsideDistance + insideDistance;
}
void MAIN() {
vec2 tc = UV0;
vec2 uv = fract(tc * UV1.x); //UV1.x number of levels
uv = uv * 2.0 - 1.0;
uv.x = 0.0;
uv.y = smoothstep(0.0, 0.2, rectangle( vec2(uv.x, uv.y + 0.5), vec2(0.2)) );
BASE_COLOR = vec4(color.xyz * mix( clamp( ( vec3( 0.4, 0.4, 0.4 ) + tc.y)
* ( vec3( 0.6, 0.6, 0.6 ) + uv.y)
, 0.0, 1.0), vec3(1.0), UV1.y ), 1.0); // UV1.y as is roofTop
ROUGHNESS = 0.3;
METALNESS = 0.0;
FRESNEL_POWER = 1.0;
}
// Copyright (C) 2026 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
VARYING vec4 color;
void MAIN() {
color = COLOR;
}
// Copyright (C) 2024 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
void MAIN() {
vec2 tc = UV0;
BASE_COLOR = vec4( texture(tileTexture, vec2(tc.x, 1.0 - tc.y )).xyz, 1.0 );
ROUGHNESS = 0.3;
METALNESS = 0.0;
FRESNEL_POWER = 1.0;
}