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.

OSM Buildings Demo

Download this example

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