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.

OSM Buildings Demo

Download this example

# 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;
}