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 subset of the equivalent C++ demo, which in addition displays buildings. This functionality requires a special license key, though.
Queue handling¶
The application uses a queue to handle concurrent requests to boost up the loading process of maps and building data.
Fetching and parsing data¶
A custom request handler class is implemented for fetching the data from the OSM map servers.
The downloaded PNG
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.
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 |
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.
# Copyright (C) 2024 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import sys
from pathlib import Path
from PySide6.QtQml import QQmlApplicationEngine
from PySide6.QtGui import QGuiApplication
from PySide6.QtCore import QCoreApplication
from manager import OSMManager, CustomTextureData # noqa: F401
if __name__ == "__main__":
app = QGuiApplication(sys.argv)
engine = QQmlApplicationEngine()
engine.addImportPath(Path(__file__).parent)
engine.loadFromModule("OSMBuildings", "Main")
if not engine.rootObjects():
sys.exit(-1)
exit_code = QCoreApplication.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, 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):
mapsDataReady = Signal(QByteArray, int, int, int)
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.mapsDataReady.connect(self._slotMapsDataReady)
def tileSizeX(self):
return self.m_tileSizeX
def tileSizeY(self):
return self.m_tileSizeY
@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)
self.m_request.getMapsData(queue.copy())
def addBuildingRequestToQueue(self, queue, tileX, tileY, zoomLevel=15):
queue.append(OSMTileData(tileX, tileY, zoomLevel))
@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 functools import partial
from PySide6.QtNetwork import QNetworkAccessManager, QNetworkReply, QNetworkRequest
from PySide6.QtCore import (QByteArray, QTimer, QFile, QFileInfo,
QObject, QUrl, Signal, Slot)
# %1 = zoom level(is dynamic), %2 = x tile number, %3 = y tile number
URL_OSMB_MAP = "https://tile-a.openstreetmap.fr/hot/{}/{}/{}.png"
@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}"
class OSMRequest(QObject):
mapsDataReady = Signal(QByteArray, int, int, int)
def __init__(self, parent):
super().__init__(parent)
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 _slotTimeOut(self):
if not self.m_buildingsQueue and not self.m_mapsQueue:
self.m_queuesTimer.stop()
else:
numConcurrentRequests = 6
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 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.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.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) 2024 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import QtQuick
import QtQuick.Window
import QtQuick3D
import QtQuick3D.Helpers
import OSMBuildings
Window {
width: 1024
height: 768
visible: true
title: qsTr("OSM Buildings")
OSMManager {
id: osmManager
onMapsDataReady: function( mapData, tileX, tileY, zoomLevel ){
mapModels.addModel(mapData, tileX, tileY, zoomLevel)
}
}
Component {
id: chunkModelMap
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 * tileX, osmManager.tileSizeY * -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( 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: 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
}
}
}
}
}
}
// 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: camera
Component.onCompleted: {
onZChanged()
}
function onZChanged() {
// Adjust near/far values based on distance
let distance = camera.z
if (distance < 1) {
camera.clipNear = 0.01
camera.clipFar = 100
} else if (distance < 100) {
camera.clipNear = 0.1
camera.clipFar = 1000
} else {
camera.clipNear = 1
camera.clipFar = 10000
}
}
}
DragHandler {
id: dragHandler
target: null
enabled: mouseEnabled
acceptedModifiers: Qt.NoModifier
acceptedButtons: Qt.RightButton
onCentroidChanged: {
mouseMoved(Qt.vector2d(centroid.position.x, centroid.position.y), false);
}
onActiveChanged: {
if (active)
mousePressed(Qt.vector2d(centroid.position.x, centroid.position.y));
else
mouseReleased(Qt.vector2d(centroid.position.x, centroid.position.y));
}
}
DragHandler {
id: ctrlDragHandler
target: null
enabled: mouseEnabled && panEnabled
//acceptedModifiers: Qt.ControlModifier
onCentroidChanged: {
panEvent(Qt.vector2d(centroid.position.x, centroid.position.y));
}
onActiveChanged: {
if (active)
startPan(Qt.vector2d(centroid.position.x, centroid.position.y));
else
endPan();
}
}
PinchHandler {
id: pinchHandler
target: null
enabled: mouseEnabled
property real distance: 0.0
onCentroidChanged: {
panEvent(Qt.vector2d(centroid.position.x, centroid.position.y))
}
onActiveChanged: {
if (active) {
startPan(Qt.vector2d(centroid.position.x, centroid.position.y))
distance = root.camera.z
} else {
endPan()
distance = 0.0
}
}
onScaleChanged: {
camera.z = distance * (1 / scale)
camera.z = Math.min(Math.max(camera.z, minimumZoom), maximumZoom)
}
}
TapHandler {
onTapped: root.forceActiveFocus()
}
WheelHandler {
id: wheelHandler
orientation: Qt.Vertical
target: null
enabled: mouseEnabled
onWheel: event => {
let delta = -event.angleDelta.y * 0.01;
camera.z += camera.z * 0.1 * delta
camera.z = Math.min(Math.max(camera.z, minimumZoom), 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
var delta = Qt.vector2d(lastPos.x - currentPos.x,
lastPos.y - currentPos.y);
var rotateX = delta.x * xSpeed * frameDelta
if ( xInvert )
rotateX = -rotateX
rotateAlongZ += rotateX;
let rotateAlongZRad = rotateAlongZ * (Math.PI / 180.)
origin.rotate(rotateX, Qt.vector3d(0.0, 0.0, -1.0), Node.SceneSpace)
var rotateY = delta.y * -ySpeed * frameDelta
if ( yInvert )
rotateY = -rotateY;
let preRotateAlongXY = rotateAlongXY + rotateY
if ( preRotateAlongXY <= maximumTilt && preRotateAlongXY >= minimumTilt )
{
rotateAlongXY = preRotateAlongXY
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) * camera.z * frameDelta
delta.y = (delta.y / root.height) * camera.z * frameDelta
let velocity = Qt.vector3d(0, 0, 0)
// X Movement
let xDirection = origin.right
velocity = velocity.plus(Qt.vector3d(xDirection.x * delta.x,
xDirection.y * delta.x,
xDirection.z * delta.x));
// Z Movement
let zDirection = 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));
origin.position = origin.position.plus(velocity)
lastPanPos = currentPanPos
}
}
}
}
// 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;
}