Fix calculation of tile zoom levels for ESRI vector tiles when

map is in a geographic CRS
This commit is contained in:
Nyall Dawson 2022-03-23 11:37:14 +10:00
parent d866bd3ce9
commit 883d31dbc0
7 changed files with 167 additions and 12 deletions

View File

@ -9,6 +9,7 @@
class QgsTileXYZ
{
%Docstring(signature="appended")
@ -295,6 +296,25 @@ The zoom level will be linearly interpolated between zoom levels present in the
Finds the best fitting (integer) zoom level given a map ``scale`` denominator.
Values are constrained to the zoom levels between :py:func:`~QgsTileMatrixSet.minimumZoom` and :py:func:`~QgsTileMatrixSet.maximumZoom`.
%End
double scaleForRenderContext( const QgsRenderContext &context ) const;
%Docstring
Calculates the correct scale to use for the tiles when rendered using the specified render ``context``.
.. versionadded:: 3.26
%End
double calculateTileScaleForMap( double actualMapScale,
const QgsCoordinateReferenceSystem &mapCrs,
const QgsRectangle &mapExtent,
const QSize mapSize,
const double mapDpi
) const;
%Docstring
Calculates the correct scale to use for the tiles when rendered using the specified map properties.
.. versionadded:: 3.26
%End
virtual bool readXml( const QDomElement &element, QgsReadWriteContext &context );

View File

@ -18,6 +18,7 @@
#include "qgslogger.h"
#include "qgscoordinatereferencesystem.h"
#include "qgssettings.h"
#include "qgsrendercontext.h"
QgsTileMatrix QgsTileMatrix::fromWebMercator( int zoomLevel )
{
@ -257,6 +258,48 @@ int QgsTileMatrixSet::scaleToZoomLevel( double scale ) const
return std::clamp( tileZoom, minimumZoom(), maximumZoom() );
}
double QgsTileMatrixSet::scaleForRenderContext( const QgsRenderContext &context ) const
{
return calculateTileScaleForMap( context.rendererScale(),
context.coordinateTransform().destinationCrs(),
context.mapExtent(),
context.outputSize(),
context.painter()->device()->logicalDpiX() );
}
double QgsTileMatrixSet::calculateTileScaleForMap( double actualMapScale, const QgsCoordinateReferenceSystem &mapCrs, const QgsRectangle &mapExtent, const QSize mapSize, const double mapDpi ) const
{
switch ( mScaleToTileZoomMethod )
{
case Qgis::ScaleToTileZoomLevelMethod::MapBox:
return actualMapScale;
case Qgis::ScaleToTileZoomLevelMethod::Esri:
if ( mapCrs.isGeographic() )
{
// ESRI calculates the scale for geographic CRS ***ALWAYS*** at the equator, regardless of map extent!
// see https://support.esri.com/en/technical-article/000007211, https://gis.stackexchange.com/questions/33270/how-does-arcmap-calculate-scalebar-inside-a-wgs84-layout
constexpr double METERS_PER_DEGREE = M_PI / 180.0 * 6378137;
constexpr double INCHES_PER_METER = 39.370078;
const double mapWidthInches = mapExtent.width() * METERS_PER_DEGREE * INCHES_PER_METER;
double scale = mapWidthInches * mapDpi / static_cast< double >( mapSize.width() );
// Note: I **think** there's also some magic which ESRI applies when rendering tiles ON SCREEN,
// which may be something like adjusting the scale based on the ratio between the map DPI and 96 DPI,
// e.g. scale *= mapDpi / 96.0;
// BUT the same adjustment isn't applied when exporting maps. This needs further investigation!
return scale;
}
else
{
return actualMapScale;
}
}
BUILTIN_UNREACHABLE
}
bool QgsTileMatrixSet::readXml( const QDomElement &element, QgsReadWriteContext & )
{
mTileMatrices.clear();

View File

@ -24,6 +24,8 @@
#include "qgscoordinatereferencesystem.h"
#include "qgsreadwritecontext.h"
class QgsRenderContext;
/**
* \ingroup core
* \brief Stores coordinates of a tile in a tile matrix set. Tile matrix is identified
@ -289,6 +291,25 @@ class CORE_EXPORT QgsTileMatrixSet
*/
int scaleToZoomLevel( double scale ) const;
/**
* Calculates the correct scale to use for the tiles when rendered using the specified render \a context.
*
* \since QGIS 3.26
*/
double scaleForRenderContext( const QgsRenderContext &context ) const;
/**
* Calculates the correct scale to use for the tiles when rendered using the specified map properties.
*
* \since QGIS 3.26
*/
double calculateTileScaleForMap( double actualMapScale,
const QgsCoordinateReferenceSystem &mapCrs,
const QgsRectangle &mapExtent,
const QSize mapSize,
const double mapDpi
) const;
/**
* Reads the set from an XML \a element.
*

View File

@ -82,10 +82,11 @@ bool QgsVectorTileLayerRenderer::render()
QElapsedTimer tTotal;
tTotal.start();
const double tileRenderScale = mTileMatrixSet.scaleForRenderContext( ctx );
QgsDebugMsgLevel( QStringLiteral( "Vector tiles rendering extent: " ) + ctx.extent().toString( -1 ), 2 );
QgsDebugMsgLevel( QStringLiteral( "Vector tiles map scale 1 : %1" ).arg( ctx.rendererScale() ), 2 );
QgsDebugMsgLevel( QStringLiteral( "Vector tiles map scale 1 : %1" ).arg( tileRenderScale ), 2 );
mTileZoom = mTileMatrixSet.scaleToZoomLevel( ctx.rendererScale() );
mTileZoom = mTileMatrixSet.scaleToZoomLevel( tileRenderScale );
QgsDebugMsgLevel( QStringLiteral( "Vector tiles zoom level: %1" ).arg( mTileZoom ), 2 );
mTileMatrix = mTileMatrixSet.tileMatrix( mTileZoom );
@ -149,7 +150,7 @@ bool QgsVectorTileLayerRenderer::render()
// add @zoom_level variable which can be used in styling
QgsExpressionContextScope *scope = new QgsExpressionContextScope( QObject::tr( "Tiles" ) ); // will be deleted by popper
scope->setVariable( QStringLiteral( "zoom_level" ), mTileZoom, true );
scope->setVariable( QStringLiteral( "vector_tile_zoom" ), mTileMatrixSet.scaleToZoom( ctx.rendererScale() ), true );
scope->setVariable( QStringLiteral( "vector_tile_zoom" ), mTileMatrixSet.scaleToZoom( tileRenderScale ), true );
const QgsExpressionContextScopePopper popper( ctx.expressionContext(), scope );
mRenderer->startRender( *renderContext(), mTileZoom, mTileRange );

View File

@ -338,12 +338,25 @@ QgsVectorTileBasicLabelingWidget::QgsVectorTileBasicLabelingWidget( QgsVectorTil
{
connect( mMapCanvas, &QgsMapCanvas::scaleChanged, this, [ = ]( double scale )
{
const int zoom = mVTLayer ? mVTLayer->tileMatrixSet().scaleToZoomLevel( scale ) : QgsVectorTileUtils::scaleToZoomLevel( scale, 0, 99 );
const QgsMapSettings &mapSettings = mMapCanvas->mapSettings();
const double tileScale = mVTLayer ? mVTLayer->tileMatrixSet().calculateTileScaleForMap( scale,
mapSettings.destinationCrs(),
mapSettings.visibleExtent(),
mapSettings.outputSize(),
mapSettings.outputDpi() ) : scale;
const int zoom = mVTLayer ? mVTLayer->tileMatrixSet().scaleToZoomLevel( tileScale ) : QgsVectorTileUtils::scaleToZoomLevel( tileScale, 0, 99 );
mLabelCurrentZoom->setText( tr( "Current zoom: %1" ).arg( zoom ) );
if ( mProxyModel )
mProxyModel->setCurrentZoom( zoom );
} );
mLabelCurrentZoom->setText( tr( "Current zoom: %1" ).arg( mVTLayer ? mVTLayer->tileMatrixSet().scaleToZoomLevel( mMapCanvas->scale() ) : QgsVectorTileUtils::scaleToZoomLevel( mMapCanvas->scale(), 0, 99 ) ) );
const QgsMapSettings &mapSettings = mMapCanvas->mapSettings();
const double tileScale = mVTLayer ? mVTLayer->tileMatrixSet().calculateTileScaleForMap( mMapCanvas->scale(),
mapSettings.destinationCrs(),
mapSettings.visibleExtent(),
mapSettings.outputSize(),
mapSettings.outputDpi() ) : mMapCanvas->scale();
mLabelCurrentZoom->setText( tr( "Current zoom: %1" ).arg( mVTLayer ? mVTLayer->tileMatrixSet().scaleToZoomLevel( tileScale ) : QgsVectorTileUtils::scaleToZoomLevel( tileScale, 0, 99 ) ) );
}
connect( mCheckVisibleOnly, &QCheckBox::toggled, this, [ = ]( bool filter )
@ -378,7 +391,13 @@ void QgsVectorTileBasicLabelingWidget::setLayer( QgsVectorTileLayer *layer )
if ( mMapCanvas )
{
const int zoom = mVTLayer ? mVTLayer->tileMatrixSet().scaleToZoomLevel( mMapCanvas->scale() ) : QgsVectorTileUtils::scaleToZoomLevel( mMapCanvas->scale(), 0, 99 );
const QgsMapSettings &mapSettings = mMapCanvas->mapSettings();
const double tileScale = mVTLayer ? mVTLayer->tileMatrixSet().calculateTileScaleForMap( mMapCanvas->scale(),
mapSettings.destinationCrs(),
mapSettings.visibleExtent(),
mapSettings.outputSize(),
mapSettings.outputDpi() ) : mMapCanvas->scale();
const int zoom = mVTLayer ? mVTLayer->tileMatrixSet().scaleToZoomLevel( tileScale ) : QgsVectorTileUtils::scaleToZoomLevel( tileScale, 0, 99 );
mProxyModel->setCurrentZoom( zoom );
}
@ -442,7 +461,13 @@ void QgsVectorTileBasicLabelingWidget::editStyleAtIndex( const QModelIndex &prox
if ( mMapCanvas )
{
const int zoom = mVTLayer ? mVTLayer->tileMatrixSet().scaleToZoomLevel( mMapCanvas->scale() ) : QgsVectorTileUtils::scaleToZoomLevel( mMapCanvas->scale(), 0, 99 );
const QgsMapSettings &mapSettings = mMapCanvas->mapSettings();
const double tileScale = mVTLayer ? mVTLayer->tileMatrixSet().calculateTileScaleForMap( mMapCanvas->scale(),
mapSettings.destinationCrs(),
mapSettings.visibleExtent(),
mapSettings.outputSize(),
mapSettings.outputDpi() ) : mMapCanvas->scale();
const int zoom = mVTLayer ? mVTLayer->tileMatrixSet().scaleToZoomLevel( tileScale ) : QgsVectorTileUtils::scaleToZoomLevel( tileScale, 0, 99 );
QList<QgsExpressionContextScope> scopes = context.additionalExpressionContextScopes();
QgsExpressionContextScope tileScope;
tileScope.setVariable( "zoom_level", zoom, true );

View File

@ -340,12 +340,25 @@ QgsVectorTileBasicRendererWidget::QgsVectorTileBasicRendererWidget( QgsVectorTil
{
connect( mMapCanvas, &QgsMapCanvas::scaleChanged, this, [ = ]( double scale )
{
const int zoom = mVTLayer ? mVTLayer->tileMatrixSet().scaleToZoomLevel( scale ) : QgsVectorTileUtils::scaleToZoomLevel( scale, 0, 99 );
const QgsMapSettings &mapSettings = mMapCanvas->mapSettings();
const double tileScale = mVTLayer ? mVTLayer->tileMatrixSet().calculateTileScaleForMap( mMapCanvas->scale(),
mapSettings.destinationCrs(),
mapSettings.visibleExtent(),
mapSettings.outputSize(),
mapSettings.outputDpi() ) : scale;
const int zoom = mVTLayer ? mVTLayer->tileMatrixSet().scaleToZoomLevel( tileScale ) : QgsVectorTileUtils::scaleToZoomLevel( tileScale, 0, 99 );
mLabelCurrentZoom->setText( tr( "Current zoom: %1" ).arg( zoom ) );
if ( mProxyModel )
mProxyModel->setCurrentZoom( zoom );
} );
mLabelCurrentZoom->setText( tr( "Current zoom: %1" ).arg( mVTLayer ? mVTLayer->tileMatrixSet().scaleToZoomLevel( mMapCanvas->scale() ) : QgsVectorTileUtils::scaleToZoomLevel( mMapCanvas->scale(), 0, 99 ) ) );
const QgsMapSettings &mapSettings = mMapCanvas->mapSettings();
const double tileScale = mVTLayer ? mVTLayer->tileMatrixSet().calculateTileScaleForMap( mMapCanvas->scale(),
mapSettings.destinationCrs(),
mapSettings.visibleExtent(),
mapSettings.outputSize(),
mapSettings.outputDpi() ) : mMapCanvas->scale();
mLabelCurrentZoom->setText( tr( "Current zoom: %1" ).arg( mVTLayer ? mVTLayer->tileMatrixSet().scaleToZoomLevel( tileScale ) : QgsVectorTileUtils::scaleToZoomLevel( tileScale, 0, 99 ) ) );
}
connect( mCheckVisibleOnly, &QCheckBox::toggled, this, [ = ]( bool filter )
@ -380,7 +393,13 @@ void QgsVectorTileBasicRendererWidget::setLayer( QgsVectorTileLayer *layer )
if ( mMapCanvas )
{
const int zoom = mVTLayer ? mVTLayer->tileMatrixSet().scaleToZoomLevel( mMapCanvas->scale() ) : QgsVectorTileUtils::scaleToZoomLevel( mMapCanvas->scale(), 0, 99 );
const QgsMapSettings &mapSettings = mMapCanvas->mapSettings();
const double tileScale = mVTLayer ? mVTLayer->tileMatrixSet().calculateTileScaleForMap( mMapCanvas->scale(),
mapSettings.destinationCrs(),
mapSettings.visibleExtent(),
mapSettings.outputSize(),
mapSettings.outputDpi() ) : mMapCanvas->scale();
const int zoom = mVTLayer ? mVTLayer->tileMatrixSet().scaleToZoomLevel( tileScale ) : QgsVectorTileUtils::scaleToZoomLevel( tileScale, 0, 99 );
mProxyModel->setCurrentZoom( zoom );
}
@ -446,7 +465,13 @@ void QgsVectorTileBasicRendererWidget::editStyleAtIndex( const QModelIndex &prox
if ( mMapCanvas )
{
const int zoom = mVTLayer ? mVTLayer->tileMatrixSet().scaleToZoomLevel( mMapCanvas->scale() ) : QgsVectorTileUtils::scaleToZoomLevel( mMapCanvas->scale(), 0, 99 );
const QgsMapSettings &mapSettings = mMapCanvas->mapSettings();
const double tileScale = mVTLayer ? mVTLayer->tileMatrixSet().calculateTileScaleForMap( mMapCanvas->scale(),
mapSettings.destinationCrs(),
mapSettings.visibleExtent(),
mapSettings.outputSize(),
mapSettings.outputDpi() ) : mMapCanvas->scale();
const int zoom = mVTLayer ? mVTLayer->tileMatrixSet().scaleToZoomLevel( tileScale ) : QgsVectorTileUtils::scaleToZoomLevel( tileScale, 0, 99 );
QList<QgsExpressionContextScope> scopes = context.additionalExpressionContextScopes();
QgsExpressionContextScope tileScope;
tileScope.setVariable( "zoom_level", zoom, true );

View File

@ -11,6 +11,7 @@ __date__ = '04/03/2022'
__copyright__ = 'Copyright 2022, The QGIS Project'
import qgis # NOQA
from qgis.PyQt.QtCore import QSize
from qgis.PyQt.QtXml import QDomDocument
from qgis.core import (
QgsTileXYZ,
@ -21,7 +22,8 @@ from qgis.core import (
QgsTileMatrixSet,
QgsVectorTileMatrixSet,
QgsReadWriteContext,
Qgis
Qgis,
QgsRectangle,
)
from qgis.testing import start_app, unittest
@ -78,6 +80,12 @@ class TestQgsTiles(unittest.TestCase):
self.assertEqual(matrix_set.maximumZoom(), 1)
self.assertEqual(matrix_set.crs().authid(), 'EPSG:4326')
# should not apply any special logic here, and return scales unchanged
self.assertEqual(matrix_set.calculateTileScaleForMap(1000, QgsCoordinateReferenceSystem('EPSG:4326'),
QgsRectangle(0, 2, 20, 12), QSize(20, 10), 96), 1000)
self.assertEqual(matrix_set.calculateTileScaleForMap(1000, QgsCoordinateReferenceSystem('EPSG:3857'),
QgsRectangle(0, 2, 20, 12), QSize(20, 10), 96), 1000)
self.assertEqual(matrix_set.tileMatrix(1).zoomLevel(), 1)
# zoom level not present in matrix!
self.assertEqual(matrix_set.tileMatrix(99).zoomLevel(), -1)
@ -357,6 +365,18 @@ class TestQgsTiles(unittest.TestCase):
self.assertFalse(vector_tile_set.fromEsriJson({}))
self.assertTrue(vector_tile_set.fromEsriJson(esri_metadata))
# should not apply any special logic here for non-geographic CRS, and return scales unchanged
self.assertEqual(vector_tile_set.calculateTileScaleForMap(1000, QgsCoordinateReferenceSystem('EPSG:3857'),
QgsRectangle(0, 2, 20, 12), QSize(20, 10), 96), 1000)
# for geographic CRS the scale should be calculated using the scale at the equator.
# see https://support.esri.com/en/technical-article/000007211,
# https://gis.stackexchange.com/questions/33270/how-does-arcmap-calculate-scalebar-inside-a-wgs84-layout
self.assertAlmostEqual(vector_tile_set.calculateTileScaleForMap(420735075, QgsCoordinateReferenceSystem('EPSG:4326'),
QgsRectangle(0, 2, 20, 12), QSize(2000, 1000), 96), 4207351, 0)
self.assertAlmostEqual(vector_tile_set.calculateTileScaleForMap(420735075, QgsCoordinateReferenceSystem('EPSG:4326'),
QgsRectangle(0, 62, 20, 72), QSize(2000, 1000), 96), 4207351, 0)
# we should NOT apply the tile scale doubling hack to ESRI tiles, otherwise our scales
# are double what ESRI use for the same tile sets
self.assertEqual(vector_tile_set.scaleToTileZoomMethod(), Qgis.ScaleToTileZoomLevelMethod.Esri)