diff --git a/changelog.txt b/changelog.txt index 2dba3fe7f..d4a195bcd 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,5 +1,5 @@ V1.XX.X -[QMS-902] Add basic HiDPI support +[QMS-902] Add HiDPI support [QMS-963] Add action to move a map to the top in map list [QMS-1105] Fix: Context menu shortcuts not shown (macOS) [QMS-1111] Add percentage values to track range diff --git a/src/qmapshack/canvas/IDrawContext.cpp b/src/qmapshack/canvas/IDrawContext.cpp index 9d39be727..6a6aeb1d1 100644 --- a/src/qmapshack/canvas/IDrawContext.cpp +++ b/src/qmapshack/canvas/IDrawContext.cpp @@ -423,7 +423,6 @@ void IDrawContext::run() { // map render objects to buffer structure currentBuffer.zoomFactor = zoomFactor; currentBuffer.scale = scale; - currentBuffer.pixelRatio = pixelRatio; currentBuffer.ref1 = ref1; currentBuffer.ref2 = ref2; currentBuffer.ref3 = ref3; diff --git a/src/qmapshack/canvas/IDrawContext.h b/src/qmapshack/canvas/IDrawContext.h index 2c4e84f59..80404a991 100644 --- a/src/qmapshack/canvas/IDrawContext.h +++ b/src/qmapshack/canvas/IDrawContext.h @@ -43,7 +43,6 @@ class IDrawContext : public QThread { int zoomLevels; //< the number of zoom levels QPointF zoomFactor{1.0, 1.0}; //< the zoomfactor used to draw the canvas QPointF scale{1.0, 1.0}; //< the scale of the global viewport - qreal pixelRatio = 1.0; //< device pixel ratio of the canvas QPointF ref1; //< top left corner QPointF ref2; //< top right corner diff --git a/src/qmapshack/canvas/IDrawObject.cpp b/src/qmapshack/canvas/IDrawObject.cpp index 2397ec36b..26e38c19e 100644 --- a/src/qmapshack/canvas/IDrawObject.cpp +++ b/src/qmapshack/canvas/IDrawObject.cpp @@ -99,7 +99,8 @@ void IDrawObject::drawTileLQ(const QImage& img, QPolygonF& l, QPainter& p, IDraw // finally translate, scale, rotate and draw tile p.save(); p.translate(l[0]); - p.scale(w / img.width(), h / img.height()); + auto imgSize = img.deviceIndependentSize(); + p.scale(w / imgSize.width(), h / imgSize.height()); p.rotate(a); p.drawImage(0, 0, img); p.restore(); @@ -107,15 +108,16 @@ void IDrawObject::drawTileLQ(const QImage& img, QPolygonF& l, QPainter& p, IDraw void IDrawObject::drawTileHQ(const QImage& img, QPolygonF& l, QPainter& p, IDrawContext& context, const CProj& proj) const { + auto imgSize = img.deviceIndependentSize(); // the sub-tiles need a sensible size // if they get too small there will be too much // rounding effects. qint32 nStepsX = 8; qint32 nStepsY = 8; - if (img.width() / nStepsX < 32) { + if (imgSize.width() / nStepsX < 32) { nStepsX = 4; } - if (img.height() / nStepsY < 32) { + if (imgSize.height() / nStepsY < 32) { nStepsY = 4; } @@ -161,7 +163,7 @@ void IDrawObject::drawTileHQ(const QImage& img, QPolygonF& l, QPainter& p, IDraw // canvas using the view's projection context.convertRad2Px(quads); - QRectF rect(0, 0, img.width() / nStepsX, img.height() / nStepsY); + QRectF rect(0, 0, imgSize.width() / nStepsX, imgSize.height() / nStepsY); const qreal rw = rect.width(); const qreal rh = rect.height(); diff --git a/src/qmapshack/dem/CDemVRT.cpp b/src/qmapshack/dem/CDemVRT.cpp index c1e03066d..9dd1a48f0 100644 --- a/src/qmapshack/dem/CDemVRT.cpp +++ b/src/qmapshack/dem/CDemVRT.cpp @@ -325,8 +325,8 @@ void CDemVRT::draw(IDrawContext::buffer_t& buf) { } // use bufferScale (and therefore the zoom level) and the pixel scale of the DEM to caluclate a downsampling factor - qreal buf_scale_x = qAbs(bufferScale.x() / xscale); - qreal buf_scale_y = qAbs(bufferScale.y() / yscale); + qreal buf_scale_x = qAbs(bufferScale.x() / xscale / buf.image.devicePixelRatio()); + qreal buf_scale_y = qAbs(bufferScale.y() / yscale / buf.image.devicePixelRatio()); // <1 would mean GDAL does upscaling which is pointless if (buf_scale_x < 1.0) { buf_scale_x = 1.0; diff --git a/src/qmapshack/map/CMapGEMF.cpp b/src/qmapshack/map/CMapGEMF.cpp index 0afb07ded..a994992c8 100644 --- a/src/qmapshack/map/CMapGEMF.cpp +++ b/src/qmapshack/map/CMapGEMF.cpp @@ -161,7 +161,7 @@ void CMapGEMF::draw(IDrawContext::buffer_t& buf) { x2 = 180 * DEG_TO_RAD; } - QPointF s1 = buf.scale * buf.zoomFactor / buf.pixelRatio; + QPointF s1 = buf.scale * buf.zoomFactor; qreal d = NOFLOAT; quint32 z = MAX_ZOOM_LEVEL; diff --git a/src/qmapshack/map/CMapJNX.cpp b/src/qmapshack/map/CMapJNX.cpp index 9ea0b2f04..5a7df01da 100644 --- a/src/qmapshack/map/CMapJNX.cpp +++ b/src/qmapshack/map/CMapJNX.cpp @@ -287,7 +287,7 @@ void CMapJNX::draw(IDrawContext::buffer_t& buf) /* override */ break; } - qint32 level = scale2level(bufferScale.x() / (5 * buf.pixelRatio), mapFile); + qint32 level = scale2level(bufferScale.x() / 5, mapFile); // no scalable level found, draw bounding box of map // derive maps corner coordinate diff --git a/src/qmapshack/map/CMapRMAP.cpp b/src/qmapshack/map/CMapRMAP.cpp index 0d5968583..755c0d868 100644 --- a/src/qmapshack/map/CMapRMAP.cpp +++ b/src/qmapshack/map/CMapRMAP.cpp @@ -343,7 +343,7 @@ void CMapRMAP::draw(IDrawContext::buffer_t& buf) /* override */ return; } - level_t& level = findBestLevel(bufferScale / buf.pixelRatio); + level_t& level = findBestLevel(bufferScale); // convert top left and bottom right point of buffer to local coord. system QPointF p1 = buf.ref1; diff --git a/src/qmapshack/map/CMapTMS.cpp b/src/qmapshack/map/CMapTMS.cpp index dd328270d..a87d5df55 100644 --- a/src/qmapshack/map/CMapTMS.cpp +++ b/src/qmapshack/map/CMapTMS.cpp @@ -28,21 +28,45 @@ #include "helpers/CDraw.h" #include "map/CMapDraw.h" #include "map/cache/CDiskCache.h" -#include "units/IUnit.h" -inline int lon2tile(double lon, int z) { return (int)(qRound(256 * (lon + 180.0) / 360.0 * qPow(2.0, z))); } - -inline int lat2tile(double lat, int z) { - return (int)(qRound(256 * (1.0 - log(qTan(lat * M_PI / 180.0) + 1.0 / qCos(lat * M_PI / 180.0)) / M_PI) / 2.0 * - qPow(2.0, z))); -} - -inline double tile2lon(int x, int z) { return x / qPow(2.0, z) * 360.0 - 180; } - -inline double tile2lat(int y, int z) { - double n = M_PI - 2.0 * M_PI * y / qPow(2.0, z); - return 180.0 / M_PI * qAtan(0.5 * (exp(n) - exp(-n))); -} +namespace { +// extend of web mercator per direction in (projected) meters +// meaning the web mercator projection goes from -C to +C in both axis +constexpr qreal C = 20037508.34; + +// convert from web mercator meters to tile indices +QPoint toTile(const QPointF& p, int z) { + int n = (1 << z); // n tiles across the map + // normalize into [0,1] + qreal x = (1 + p.x() / C) / 2; + qreal y = (1 - p.y() / C) / 2; + + // tile that contains p + int x_tile = x * n; + int y_tile = y * n; + // clamp to valid range + x_tile = std::clamp(x_tile, 0, n - 1); + y_tile = std::clamp(y_tile, 0, n - 1); + + return QPoint(x_tile, y_tile); +}; + +// get web mercator meters for the NW corner of a tile +QPointF fromTile(const QPoint& p, int z) { + int n = (1 << z); + // get normalized again + qreal x = static_cast(p.x()) / n; + qreal y = static_cast(p.y()) / n; + + return QPointF((2 * x - 1) * C, (2 * y - 1) * -C); +}; + +// euclidian modulo +int eucmod(int a, int b) { + int r = a % b; + return r >= 0 ? r : r + std::abs(b); +}; +} // namespace CMapTMS::CMapTMS(const QString& filename, CMapDraw* parent) : IMapOnline(parent) { qDebug() << "------------------------------"; @@ -106,6 +130,7 @@ CMapTMS::CMapTMS(const QString& filename, CMapDraw* parent) : IMapOnline(parent) layers[idx].script = xmlLayer.namedItem("Script").toElement().text(); layers[idx].minZoomLevel = minZoomLevel; layers[idx].maxZoomLevel = maxZoomLevel; + layers[idx].tileSizePx = 256; layers[idx].strUrl.replace("{z}", "%1", Qt::CaseInsensitive); layers[idx].strUrl.replace("{x}", "%2", Qt::CaseInsensitive); @@ -293,89 +318,72 @@ void CMapTMS::draw(IDrawContext::buffer_t& buf) /* override */ return; } - // get pixel offset of top left buffer corner - QPointF pp = buf.ref1; - map->convertRad2Px(pp); + // get the top-left and bottom-right corners into web mercator + auto r1 = buf.ref1; + auto r3 = buf.ref3; + proj.transform(r1, PJ_INV); + proj.transform(r3, PJ_INV); + QPointF pt1(r1.x(), r1.y()); + QPointF pt2(r3.x(), r3.y()); // start to draw the map QPainter p(&buf.image); USE_ANTI_ALIASING(p, true); p.setOpacity(getOpacity() / 100.0); + QPointF pp = buf.ref1; + map->convertRad2Px(pp); p.translate(-pp); - // calculate maximum viewport - qreal x1 = buf.ref1.x() < buf.ref4.x() ? buf.ref1.x() : buf.ref4.x(); - qreal y1 = buf.ref1.y() > buf.ref2.y() ? buf.ref1.y() : buf.ref2.y(); - - qreal x2 = buf.ref2.x() > buf.ref3.x() ? buf.ref2.x() : buf.ref3.x(); - qreal y2 = buf.ref3.y() < buf.ref4.y() ? buf.ref3.y() : buf.ref4.y(); - - if (x1 < -180.0 * DEG_TO_RAD) { - x1 = -180 * DEG_TO_RAD; - } - if (x2 > 180.0 * DEG_TO_RAD) { - x2 = 180 * DEG_TO_RAD; - } - - // draw layers - for (const layer_t& layer : std::as_const(layers)) { + for (auto& layer : layers) { if (!layer.enabled) { continue; } - qint32 z = 20; - QPointF s1 = buf.scale * buf.zoomFactor / buf.pixelRatio; - qreal d = NOFLOAT; - - for (qint32 i = layer.minZoomLevel; i < 21; i++) { - qreal s2 = 0.055 * (1 << i); - if (qAbs(s2 - s1.x()) < d) { - z = i; - d = qAbs(s2 - s1.x()); - } - } - + // calculate zoom level + qreal width = std::remainder(pt2.x() - pt1.x(), C); + qint32 z = std::round(std::log2(2 * C / (width / buf.image.width() * layer.tileSizePx))); + z = qMax(z, layer.minZoomLevel); if (z > layer.maxZoomLevel) { continue; } + int n = (1 << z); - z = 21 - z; - - qint32 row1, row2, col1, col2; + // tile indices of the two corners and number of tiles between them + auto t1 = toTile(pt1, z); + auto t2 = toTile(pt2, z); + auto dim = t2 - t1; + dim = {eucmod(dim.x(), n), dim.y()}; - col1 = lon2tile(x1 * RAD_TO_DEG, z) / 256; - col2 = lon2tile(x2 * RAD_TO_DEG, z) / 256; - row1 = lat2tile(y1 * RAD_TO_DEG, z) / 256; - row2 = lat2tile(y2 * RAD_TO_DEG, z) / 256; - - // qDebug() << col1 << col2 << row1 << row2 << (col2 - col1) << (row2 - row1) << ((col2 - col1) * (row2 - - // row1)); - - // start to request tiles. draw tiles in cache, queue urls of tile yet to be requested - for (qint32 row = row1; row <= row2; row++) { - for (qint32 col = col1; col <= col2; col++) { - QString url = createUrl(layer, col, row, z); - // qDebug() << url; + for (int i = 0; i <= dim.y(); ++i) { + for (int j = 0; j <= dim.x(); ++j) { + int x = eucmod(t1.x() + j, n); + int y = t1.y() + i; + QString url = createUrl(layer, x, y, z); if (diskCache->contains(url)) { QImage img; diskCache->restore(url, img); - + img.setDevicePixelRatio(buf.image.devicePixelRatio()); + if (img.width() != layer.tileSizePx) { + // we got a tile with a different size then expected + // (which is normal for the first tile we get from e.g. a HiDPI source) + // remember it's size and request a redraw + layer.tileSizePx = img.width(); + map->emitSigCanvasUpdate(); + } + + // TODO throw away drawTile and handle drawing in a sane way + // then we'll also get wraparound on the antimeridian + // (this function is ready but it's impossible with drawTile) QPolygonF l; - - qreal xx1 = tile2lon(col, z) * DEG_TO_RAD; - qreal yy1 = tile2lat(row, z) * DEG_TO_RAD; - qreal xx2 = tile2lon(col + 1, z) * DEG_TO_RAD; - qreal yy2 = tile2lat(row + 1, z) * DEG_TO_RAD; - - l << QPointF(xx1, yy1) << QPointF(xx2, yy1) << QPointF(xx2, yy2) << QPointF(xx1, yy2); + l << fromTile({x, y}, z) << fromTile({x + 1, y}, z) << fromTile({x + 1, y + 1}, z) << fromTile({x, y + 1}, z); + proj.transform(l, PJ_FWD); drawTile(img, l, p); } else { urlQueue << url; } } } - emit sigQueueChanged(); } } diff --git a/src/qmapshack/map/CMapTMS.h b/src/qmapshack/map/CMapTMS.h index e0e850c88..472c5714f 100644 --- a/src/qmapshack/map/CMapTMS.h +++ b/src/qmapshack/map/CMapTMS.h @@ -47,10 +47,11 @@ class CMapTMS : public IMapOnline { QString createUrl(const layer_t& layer, int x, int y, int z); struct layer_t { - layer_t() : enabled(true), minZoomLevel(0), maxZoomLevel(0) {} + layer_t() : enabled(true), minZoomLevel(0), maxZoomLevel(0), tileSizePx(256) {} bool enabled; qint32 minZoomLevel; qint32 maxZoomLevel; + qint32 tileSizePx; QString title; QString strUrl; QString script; diff --git a/src/qmapshack/map/CMapVRT.cpp b/src/qmapshack/map/CMapVRT.cpp index c0c1ebdcc..0257a1460 100644 --- a/src/qmapshack/map/CMapVRT.cpp +++ b/src/qmapshack/map/CMapVRT.cpp @@ -282,8 +282,8 @@ bool CMapVRT::computeSourceWindow(const IDrawContext::buffer_t& buf, const QPoin sourceWindow_t& window) const { // use bufferScale (and therefore the zoom level) and the pixel scale of the map to calculate a downsampling // factor; <1 would mean GDAL does upscaling which is pointless - window.bufScaleX = qMax(1.0, qAbs(bufferScale.x() / xscale)); - window.bufScaleY = qMax(1.0, qAbs(bufferScale.y() / yscale)); + window.bufScaleX = qMax(1.0, qAbs(bufferScale.x() / xscale) / buf.image.devicePixelRatio()); + window.bufScaleY = qMax(1.0, qAbs(bufferScale.y() / yscale) / buf.image.devicePixelRatio()); // corners of the area we shall draw, converted from the canvas projection into the // map's own pixel coordinate space diff --git a/src/qmapshack/map/CMapWMTS.cpp b/src/qmapshack/map/CMapWMTS.cpp index ebdaea091..40ca6fcd7 100644 --- a/src/qmapshack/map/CMapWMTS.cpp +++ b/src/qmapshack/map/CMapWMTS.cpp @@ -383,7 +383,7 @@ void CMapWMTS::draw(IDrawContext::buffer_t& buf) /* override */ QRectF viewport(QPointF(x1, y1) * RAD_TO_DEG, QPointF(x2, y2) * RAD_TO_DEG); // draw layers - for (const layer_t& layer : std::as_const(layers)) { + for (layer_t& layer : layers) { if (!layer.boundingBox.intersects(viewport) || !layer.enabled) { continue; } @@ -410,7 +410,12 @@ void CMapWMTS::draw(IDrawContext::buffer_t& buf) /* override */ const QStringList& keys = tileset.tilematrix.keys(); for (const QString& key : keys) { const tilematrix_t& tilematrix = tileset.tilematrix[key]; - qreal s2 = tilematrix.scale * 0.28e-3; + // Effective ground resolution of a served tile pixel. For spec-compliant + // layers tileScale is 1.0 and this is just the declared pixel span. For + // HiDPI servers that serve tiles larger than their TileMatrixSet declares, + // dividing by tileScale selects a coarser matrix so the bigger tile still + // maps 1:1 onto the (physical) buffer pixels. + qreal s2 = tilematrix.scale * 0.28e-3 / layer.tileScale; if (qAbs(s2 - s1.x()) < d) { tileMatrixId = key; @@ -486,6 +491,16 @@ void CMapWMTS::draw(IDrawContext::buffer_t& buf) /* override */ QImage img; diskCache->restore(url, img); + // Detect HiDPI servers that serve tiles larger (or smaller) than the + // TileWidth declared in their TileMatrixSet. Learn the real ratio from + // the fetched tile and request a redraw so a matching matrix is picked + // and the tile is drawn 1:1 to physical pixels (sharp, with the HiDPI + // text-size advantage on scaled displays). + if (tilematrix.tileWidth > 0 && img.width() != qRound(tilematrix.tileWidth * layer.tileScale)) { + layer.tileScale = qreal(img.width()) / tilematrix.tileWidth; + map->emitSigCanvasUpdate(); + } + QPolygonF l; qreal xx1 = col * (xscale * tilematrix.tileWidth) + tilematrix.topLeft.x(); diff --git a/src/qmapshack/map/CMapWMTS.h b/src/qmapshack/map/CMapWMTS.h index 09506cd6f..cfc3b770d 100644 --- a/src/qmapshack/map/CMapWMTS.h +++ b/src/qmapshack/map/CMapWMTS.h @@ -60,6 +60,16 @@ class CMapWMTS : public IMapOnline { QRectF boundingBox; QString resourceURL; QMap limits; + + /** + Ratio of the actually served tile size to the TileWidth declared in the + TileMatrixSet. Some HiDPI servers violate the WMTS spec by reusing a + standard TileMatrixSet (e.g. TileWidth 256) while serving larger tiles + (e.g. 512px). The ratio is learned from the first fetched tile and used + to pick a matching (coarser) matrix so an over-sized tile still maps 1:1 + onto the physical buffer pixels. 1.0 means the server is spec-compliant. + */ + qreal tileScale = 1.0; }; QList layers;