From eb3ebd29d93aed4671cb4e536f30d5e45ba4086c Mon Sep 17 00:00:00 2001 From: Don Gagne Date: Thu, 13 Aug 2026 11:21:20 -0700 Subject: [PATCH] fix(GeoMap): one continuous heightfield so patches never show cliffs or holes Every rendered patch now samples the same continuous terrain heightfield instead of fetching its own per-patch height grid, so neighboring patches can never disagree about the terrain they share - no more cliffs at patch boundaries and no holes while data loads. - HeightField: best-estimate height everywhere by construction - real data where a tile is stored, ancestor-interpolated estimate where only coarser data exists. Coincident vertices of neighboring patches compute bit-identical heights regardless of which patch meshes them. - ElevationTilePyramid: bounded in-memory working set of decoded elevation tiles (LRU past 128 tiles, ~32MB) serving queries from the tile itself or its nearest stored ancestor with the sub-window to sample. - TerrariumTileFetcher: cache-first fetcher over the AWS Open Data Terrain Tiles (terrarium encoding) - one slippy PNG tile per patch at every zoom, network fallback on cache miss with write-back. Replaces the fixed resolution Copernicus pipeline (TerrainHeightSource removed) and its flat floor / blend band. - Patch skirts scale with the coarsest constraining neighbor LOD delta to hide seams against coarser neighbors. Fixes #14823 --- .clang-format | 7 +- src/GeoMap/CMakeLists.txt | 10 +- src/GeoMap/CheckerboardTextureData.cc | 1 + src/GeoMap/ElevationTilePyramid.cc | 107 ++ src/GeoMap/ElevationTilePyramid.h | 98 ++ src/GeoMap/GeoMap.qml | 2 + src/GeoMap/GeoMapCamera.cc | 5 + src/GeoMap/GeoMapCamera.h | 1 + src/GeoMap/GeoScene.cc | 5 + src/GeoMap/HeightField.cc | 225 ++++ src/GeoMap/HeightField.h | 107 ++ src/GeoMap/HeightSource.cc | 39 + src/GeoMap/HeightSource.h | 32 +- src/GeoMap/PatchGeometry.cc | 169 ++- src/GeoMap/PatchGeometry.h | 59 +- src/GeoMap/SurfaceAnalysis.cc | 1 + src/GeoMap/SurfaceModel.cc | 340 +++--- src/GeoMap/SurfaceModel.h | 68 +- src/GeoMap/SurfacePatchModel.cc | 103 +- src/GeoMap/SurfacePatchModel.h | 32 +- src/GeoMap/TerrariumHeightSource.cc | 276 ----- src/GeoMap/TerrariumTileFetcher.cc | 411 +++++++ ...mHeightSource.h => TerrariumTileFetcher.h} | 41 +- src/GeoMap/TileImageSource.cc | 54 +- src/GeoMap/TileImageSource.h | 12 +- src/GeoMap/TileMath.cc | 18 + src/GeoMap/TileMath.h | 7 + test/GeoMap/CMakeLists.txt | 12 +- test/GeoMap/ElevationTilePyramidTest.cc | 261 +++++ test/GeoMap/ElevationTilePyramidTest.h | 23 + test/GeoMap/GeoMapCameraTest.cc | 1 + test/GeoMap/GeoSceneTest.cc | 1 + test/GeoMap/HeightFieldTest.cc | 402 +++++++ test/GeoMap/HeightFieldTest.h | 30 + test/GeoMap/HeightSourceTest.cc | 1 + test/GeoMap/PatchGeometryTest.cc | 475 +++++++- test/GeoMap/PatchGeometryTest.h | 14 + test/GeoMap/SurfaceModelTest.cc | 1010 +++++++++++------ test/GeoMap/SurfaceModelTest.h | 22 +- test/GeoMap/SurfacePatchImageryTest.cc | 139 +++ test/GeoMap/SurfacePatchImageryTest.h | 2 + test/GeoMap/SurfacePatchModelTest.cc | 131 ++- test/GeoMap/SurfacePatchModelTest.h | 4 +- ...rceTest.cc => TerrariumTileFetcherTest.cc} | 371 +++++- ...ourceTest.h => TerrariumTileFetcherTest.h} | 13 +- test/GeoMap/TileImageSourceTest.cc | 299 ++++- test/GeoMap/TileImageSourceTest.h | 7 + test/GeoMap/TileMathTest.cc | 14 + test/GeoMap/TileMathTest.h | 1 + test/QmlUITests/FlyViewGeoUITest.cc | 3 +- 50 files changed, 4519 insertions(+), 947 deletions(-) create mode 100644 src/GeoMap/ElevationTilePyramid.cc create mode 100644 src/GeoMap/ElevationTilePyramid.h create mode 100644 src/GeoMap/HeightField.cc create mode 100644 src/GeoMap/HeightField.h delete mode 100644 src/GeoMap/TerrariumHeightSource.cc create mode 100644 src/GeoMap/TerrariumTileFetcher.cc rename src/GeoMap/{TerrariumHeightSource.h => TerrariumTileFetcher.h} (57%) create mode 100644 test/GeoMap/ElevationTilePyramidTest.cc create mode 100644 test/GeoMap/ElevationTilePyramidTest.h create mode 100644 test/GeoMap/HeightFieldTest.cc create mode 100644 test/GeoMap/HeightFieldTest.h rename test/GeoMap/{TerrariumHeightSourceTest.cc => TerrariumTileFetcherTest.cc} (51%) rename test/GeoMap/{TerrariumHeightSourceTest.h => TerrariumTileFetcherTest.h} (55%) diff --git a/.clang-format b/.clang-format index 47885505fd75..a0e6919f8bf2 100644 --- a/.clang-format +++ b/.clang-format @@ -56,11 +56,12 @@ SpacesInSquareBrackets: false SortIncludes: CaseSensitive IncludeBlocks: Regroup IncludeCategories: - - Regex: '^<[a-z].*>' # System/STL headers - Priority: 1 - Regex: '^' # Qt headers + Priority: 1 + - Regex: '^<[a-z].*>' # System/STL headers + CaseSensitive: true Priority: 2 - - Regex: '.*' # Project headers + - Regex: '.*' # Project headers Priority: 3 # Alignment diff --git a/src/GeoMap/CMakeLists.txt b/src/GeoMap/CMakeLists.txt index a56e8abc3d21..e212cf6e505b 100644 --- a/src/GeoMap/CMakeLists.txt +++ b/src/GeoMap/CMakeLists.txt @@ -5,14 +5,18 @@ target_sources(${CMAKE_PROJECT_NAME} PRIVATE + ElevationTilePyramid.cc + ElevationTilePyramid.h + HeightField.cc + HeightField.h HeightSource.cc HeightSource.h SurfaceAnalysis.cc SurfaceAnalysis.h SurfaceModel.cc SurfaceModel.h - TerrariumHeightSource.cc - TerrariumHeightSource.h + TerrariumTileFetcher.cc + TerrariumTileFetcher.h TileImageSource.cc TileImageSource.h TileMath.cc @@ -55,6 +59,8 @@ target_link_libraries(GeoMapModule Qt6::Positioning Qt6::Qml Qt6::Quick3D + PRIVATE + QGCLogging ) target_include_directories(GeoMapModule PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) diff --git a/src/GeoMap/CheckerboardTextureData.cc b/src/GeoMap/CheckerboardTextureData.cc index 2934c2825c2d..355963dff2c2 100644 --- a/src/GeoMap/CheckerboardTextureData.cc +++ b/src/GeoMap/CheckerboardTextureData.cc @@ -1,6 +1,7 @@ #include "CheckerboardTextureData.h" #include + #include CheckerboardTextureData::CheckerboardTextureData(QQuick3DObject* parent) : QQuick3DTextureData(parent) diff --git a/src/GeoMap/ElevationTilePyramid.cc b/src/GeoMap/ElevationTilePyramid.cc new file mode 100644 index 000000000000..6892494e11de --- /dev/null +++ b/src/GeoMap/ElevationTilePyramid.cc @@ -0,0 +1,107 @@ +/**************************************************************************** + * + * (c) 2009-2024 QGROUNDCONTROL PROJECT + * + * QGroundControl is licensed according to the terms in the file + * COPYING.md in the root of the source code directory. + * + ****************************************************************************/ + +#include "ElevationTilePyramid.h" + +#include +#include + +#include "QGCLoggingCategory.h" + +QGC_LOGGING_CATEGORY(GeoMapElevationTilePyramidVerboseLog, "GeoMap.ElevationTilePyramid.Verbose") + +namespace { + +/// Applies \a delta to the descendant count of every ancestor of \a key, +/// pruning zeroed entries +void adjustAncestorCounts(QHash& counts, const TileMath::TileKey& key, int delta) +{ + for (int zoom = key.zoom - 1; zoom >= TileMath::kMinZoom; zoom--) { + const int shift = key.zoom - zoom; + const TileMath::TileKey ancestor{key.x >> shift, key.y >> shift, zoom}; + const int count = counts.value(ancestor, 0) + delta; + if (count > 0) { + counts.insert(ancestor, count); + } else { + counts.remove(ancestor); + } + } +} + +} // namespace + +bool ElevationTilePyramid::insertTile(const TileMath::TileKey& key, Grid grid, TileMath::TileKey* evictedKey) +{ + if (!TileMath::isValidKey(key) || !grid.isValid()) { + return false; + } + const bool replacing = _tiles.contains(key); + if (!replacing && (_tiles.count() >= kMaxTiles)) { + const TileMath::TileKey evicted = _evictLeastRecentlyUsed(); + if (TileMath::isValidKey(evicted) && evictedKey) { + *evictedKey = evicted; + } + } + _tiles.insert(key, std::move(grid)); + _lastUsed.insert(key, ++_useTick); + if (!replacing) { + adjustAncestorCounts(_descendantCounts, key, +1); + } + return true; +} + +TileMath::TileKey ElevationTilePyramid::_evictLeastRecentlyUsed() +{ + // Pinned tiles back rendered patches: never evict them. When everything + // is pinned there is no victim and the working set grows past the cap. + // O(tileCount) victim scan: fine at kMaxTiles, revisit if the cap grows + TileMath::TileKey lruKey{0, 0, -1}; + qint64 lruTick = std::numeric_limits::max(); + for (auto it = _lastUsed.cbegin(); it != _lastUsed.cend(); ++it) { + if ((it.value() < lruTick) && !_pinnedKeys.contains(it.key())) { + lruTick = it.value(); + lruKey = it.key(); + } + } + if (!TileMath::isValidKey(lruKey)) { + qCDebug(GeoMapElevationTilePyramidVerboseLog) + << "all resident tiles pinned, growing past cap, tileCount" << _tiles.count(); + return lruKey; + } + // Verbose: fires per insert once the working set is full + qCDebug(GeoMapElevationTilePyramidVerboseLog) + << "evicting least-recently-used tile" << lruKey << "tileCount" << _tiles.count(); + _tiles.remove(lruKey); + _lastUsed.remove(lruKey); + adjustAncestorCounts(_descendantCounts, lruKey, -1); + return lruKey; +} + +ElevationTilePyramid::View ElevationTilePyramid::bestTileFor(const TileMath::TileKey& key) const +{ + // Valid zoom bounds also cap the ancestor-shift distance below UB + if (!TileMath::isValidKey(key)) { + return View{}; + } + _lookupCount++; + for (int zoom = key.zoom; zoom >= TileMath::kMinZoom; zoom--) { + const int shift = key.zoom - zoom; + const TileMath::TileKey candidate{key.x >> shift, key.y >> shift, zoom}; + const auto it = _tiles.constFind(candidate); + if (it == _tiles.cend()) { + continue; + } + _lastUsed.insert(candidate, ++_useTick); + const double scale = 1.0 / (1LL << shift); + return View{&it.value(), candidate, + QRectF((key.x - (qint64(candidate.x) << shift)) * scale, + (key.y - (qint64(candidate.y) << shift)) * scale, scale, scale)}; + } + return View{}; +} diff --git a/src/GeoMap/ElevationTilePyramid.h b/src/GeoMap/ElevationTilePyramid.h new file mode 100644 index 000000000000..87c18ebd1a04 --- /dev/null +++ b/src/GeoMap/ElevationTilePyramid.h @@ -0,0 +1,98 @@ +/**************************************************************************** + * + * (c) 2009-2024 QGROUNDCONTROL PROJECT + * + * QGroundControl is licensed according to the terms in the file + * COPYING.md in the root of the source code directory. + * + ****************************************************************************/ + +#pragma once + +#include +#include +#include +#include + +#include + +#include "TileMath.h" + +/// In-memory working set of decoded elevation tiles, keyed by slippy tile. +/// +/// This is the synchronous sampling layer of the continuous-drape design: the +/// persistent store remains QGC's shared tile cache database (encoded tiles, +/// async); this pyramid retains what the fetch path has already decoded so a +/// height estimate is answerable immediately at mesh time. Lookup serves a +/// query tile from itself or its nearest stored ancestor with the sub-window +/// to sample — never a descendant — so coverage is continuous wherever any +/// ancestor data exists. +/// +/// Not thread-safe: confine to one thread or synchronize externally. This +/// includes the const lookup methods — they mutate recency/instrumentation +/// state, so even concurrent reads race. +/// Bounded working set: least-recently-used tiles are evicted past kMaxTiles. +class ElevationTilePyramid +{ +public: + /// Working-set cap: least-recently-used tiles are evicted at insert time + /// (256x256 float tiles ~256KB each, so the cap bounds memory at ~32MB) + static constexpr int kMaxTiles = 128; + + /// Decoded elevation samples for one tile, row-major from the NW corner + struct Grid + { + int width = 0; ///< samples per row + int height = 0; ///< rows + QList heights; ///< meters + + bool isValid() const { return (width > 0) && (height > 0) && (heights.size() == qsizetype(width) * height); } + }; + + /// Where to sample a query tile: the stored tile and the query's extent + /// within it (unit UV from the NW corner) + struct View + { + const Grid* grid = nullptr; ///< null when nothing stored covers the query + TileMath::TileKey key; ///< stored tile the view samples + QRectF subWindow; + + bool isValid() const { return grid != nullptr; } + }; + + /// Stores \a grid for \a key, replacing any previous tile. Invalid grids + /// are rejected (returns false). When the insert evicts a tile, its key is + /// written to \a evictedKey (left untouched otherwise). + bool insertTile(const TileMath::TileKey& key, Grid grid, TileMath::TileKey* evictedKey = nullptr); + + bool hasTile(const TileMath::TileKey& key) const { return _tiles.contains(key); } + + /// Finest stored tile covering \a key: the tile itself when present, else + /// the nearest ancestor. View pointers stay valid until the next insert. + View bestTileFor(const TileMath::TileKey& key) const; + + int tileCount() const { return static_cast(_tiles.count()); } + + /// Tiles that must not be evicted (they back rendered patches or resolve + /// them as ancestors). kMaxTiles becomes a soft cap: when every resident + /// tile is pinned, inserts grow past it rather than break a rendered mesh. + void setPinnedKeys(QSet keys) { _pinnedKeys = std::move(keys); } + + /// True when any stored tile lies strictly deeper within \a key's extent + /// (i.e. a lookup inside \a key could resolve finer than \a key itself) + bool hasDescendant(const TileMath::TileKey& key) const { return _descendantCounts.value(key, 0) > 0; } + + /// Perf instrumentation: number of bestTileFor resolutions performed + /// (test hook, not API — semantics track the resolution strategy) + qint64 lookupCountForTest() const { return _lookupCount; } + +private: + TileMath::TileKey _evictLeastRecentlyUsed(); + + QHash _tiles; + QHash _descendantCounts; ///< stored tiles strictly below each key + QSet _pinnedKeys; + mutable QHash _lastUsed; + mutable qint64 _useTick = 0; + mutable qint64 _lookupCount = 0; +}; diff --git a/src/GeoMap/GeoMap.qml b/src/GeoMap/GeoMap.qml index 652b6db63093..ef16b29d43c0 100644 --- a/src/GeoMap/GeoMap.qml +++ b/src/GeoMap/GeoMap.qml @@ -170,6 +170,7 @@ Item { required property int zoomLevel required property var heights required property bool covered + required property var edgeLodDeltas required property var tileImage required property bool hasTileImage @@ -181,6 +182,7 @@ Item { gridSize: patchModel.gridSize span: patchDelegate.span heights: patchDelegate.heights + edgeLodDeltas: patchDelegate.edgeLodDeltas } Texture { diff --git a/src/GeoMap/GeoMapCamera.cc b/src/GeoMap/GeoMapCamera.cc index d011f3f74ed3..4e110d7958bc 100644 --- a/src/GeoMap/GeoMapCamera.cc +++ b/src/GeoMap/GeoMapCamera.cc @@ -1,11 +1,15 @@ #include "GeoMapCamera.h" #include + #include #include +#include "QGCLoggingCategory.h" #include "TileMath.h" +QGC_LOGGING_CATEGORY(GeoMapCameraLog, "GeoMap.GeoMapCamera") + namespace { struct Vec3 @@ -153,6 +157,7 @@ void GeoMapCamera::setMode(Mode mode) if (mode == _mode) { return; } + qCDebug(GeoMapCameraLog) << "mode" << _mode << "->" << mode; _mode = mode; emit modeChanged(); } diff --git a/src/GeoMap/GeoMapCamera.h b/src/GeoMap/GeoMapCamera.h index b6b9cebf1e3e..b9f4665a6aa2 100644 --- a/src/GeoMap/GeoMapCamera.h +++ b/src/GeoMap/GeoMapCamera.h @@ -16,6 +16,7 @@ #include #include #include + #include /// Camera pose model for the GeoMap engine. diff --git a/src/GeoMap/GeoScene.cc b/src/GeoMap/GeoScene.cc index 8ae991e4a7f8..cc35e92f6d34 100644 --- a/src/GeoMap/GeoScene.cc +++ b/src/GeoMap/GeoScene.cc @@ -12,8 +12,11 @@ #include #include "GeoMapCamera.h" +#include "QGCLoggingCategory.h" #include "TileMath.h" +QGC_LOGGING_CATEGORY(GeoMapGeoSceneLog, "GeoMap.GeoScene") + GeoScene::GeoScene(QObject* parent) : QObject(parent) {} void GeoScene::setCamera(GeoMapCamera* camera) @@ -21,6 +24,7 @@ void GeoScene::setCamera(GeoMapCamera* camera) if (camera == _camera) { return; } + qCDebug(GeoMapGeoSceneLog) << "camera" << (camera ? "set" : "cleared"); if (_camera) { disconnect(_camera, nullptr, this, nullptr); } @@ -79,5 +83,6 @@ void GeoScene::_maybeReanchor() } _origin = cameraWorld; _originSet = true; + qCDebug(GeoMapGeoSceneLog) << "scene origin re-anchored to" << _origin; emit sceneOriginChanged(); } diff --git a/src/GeoMap/HeightField.cc b/src/GeoMap/HeightField.cc new file mode 100644 index 000000000000..5e52d36caf7d --- /dev/null +++ b/src/GeoMap/HeightField.cc @@ -0,0 +1,225 @@ +/**************************************************************************** + * + * (c) 2009-2024 QGROUNDCONTROL PROJECT + * + * QGroundControl is licensed according to the terms in the file + * COPYING.md in the root of the source code directory. + * + ****************************************************************************/ + +#include "HeightField.h" + +#include +#include + +#include +#include + +#include "QGCLoggingCategory.h" + +QGC_LOGGING_CATEGORY(GeoMapHeightFieldLog, "GeoMap.HeightField") +QGC_LOGGING_CATEGORY(GeoMapHeightFieldVerboseLog, "GeoMap.HeightField.Verbose") + +namespace { + +/// Bilinear height at a unit-UV position within a grid (origin NW corner), +/// sample-center convention, clamped at grid edges. +/// Must stay numerically identical to TerrariumTileFetcher.cc's heightAtUV: +/// cross-source vertex identity depends on both samplers agreeing +double heightAtUV(const ElevationTilePyramid::Grid& grid, double u, double v) +{ + const int w = grid.width; + const int h = grid.height; + const double px = (u * w) - 0.5; + const double py = (v * h) - 0.5; + const int x0 = qBound(0, static_cast(std::floor(px)), w - 1); + const int y0 = qBound(0, static_cast(std::floor(py)), h - 1); + const int x1 = qMin(x0 + 1, w - 1); + const int y1 = qMin(y0 + 1, h - 1); + const double fx = qBound(0.0, px - x0, 1.0); + const double fy = qBound(0.0, py - y0, 1.0); + + const auto at = [&grid](int x, int y) { return double(grid.heights[(qsizetype(y) * grid.width) + x]); }; + const double north = (at(x0, y0) * (1.0 - fx)) + (at(x1, y0) * fx); + const double south = (at(x0, y1) * (1.0 - fx)) + (at(x1, y1) * fx); + return (north * (1.0 - fy)) + (south * fy); +} + +} // namespace + +HeightField::HeightField(QObject* parent) : QObject(parent) {} + +bool HeightField::insertTile(const TileMath::TileKey& key, ElevationTilePyramid::Grid grid) +{ + // Capture before the move: on rejection the grid has been consumed + const int gridWidth = grid.width; + const int gridHeight = grid.height; + TileMath::TileKey evicted{0, 0, -1}; + if (!_pyramid.insertTile(key, std::move(grid), &evicted)) { + qCWarning(GeoMapHeightFieldLog) << "insertTile rejected: key" << key << "grid" << gridWidth << "x" + << gridHeight; + return false; + } + _memoView = ElevationTilePyramid::View{}; // grid pointers die on insert + qCDebug(GeoMapHeightFieldVerboseLog) << "inserted tile" << key; + + const auto tileExtent = [](const TileMath::TileKey& k) { + const QPointF corner = TileMath::tileMinCorner(k); + const double span = TileMath::tileSpanAtZoom(k.zoom); + return QRectF(corner.x(), corner.y(), span, span); + }; + if (TileMath::isValidKey(evicted)) { + qCDebug(GeoMapHeightFieldVerboseLog) << "evicted tile" << evicted; + emit regionChanged(tileExtent(evicted)); + } + emit regionChanged(tileExtent(key)); + return true; +} + +double HeightField::heightAt(const QPointF& world) const +{ + const TileMath::TileKey query = TileMath::tileForWorld(world, TileMath::kMaxZoom); + + // Memoized fast path: the last resolved tile answers when the position + // resolves to it (the memo is only populated when no stored descendant + // could override it, and every insert invalidates it). The hit test uses + // the same key arithmetic as the full lookup — a world-coordinate bounds + // check can disagree with tileForWorld by an ulp exactly on a tile + // boundary, silently answering with the wrong neighbor's data there. + if (_memoView.isValid()) { + const int shift = TileMath::kMaxZoom - _memoView.key.zoom; + if (((query.x >> shift) == _memoView.key.x) && ((query.y >> shift) == _memoView.key.y)) { + const double u = (world.x() - _memoMinX) / _memoSpan; + const double v = (_memoMaxY - world.y()) / _memoSpan; // grid origin is the NW corner + return heightAtUV(*_memoView.grid, u, v); + } + } + + // The pyramid resolves the finest stored cover of the deepest-zoom query + const ElevationTilePyramid::View view = _pyramid.bestTileFor(query); + if (!view.isValid()) { + return 0.0; + } + + const QPointF corner = TileMath::tileMinCorner(view.key); + const double span = TileMath::tileSpanAtZoom(view.key.zoom); + if (!_pyramid.hasDescendant(view.key)) { + // Nothing finer exists anywhere inside this tile, so it answers for + // every position within its bounds until the next insert + _memoView = view; + _memoMinX = corner.x(); + _memoMaxY = corner.y() + span; + _memoSpan = span; + } + + const double u = (world.x() - corner.x()) / span; + const double v = ((corner.y() + span) - world.y()) / span; // grid origin is the NW corner + return heightAtUV(*view.grid, u, v); +} + +QList HeightField::samplePatch(const TileMath::TileKey& key, int gridSize) const +{ + if ((gridSize < 1) || (gridSize > kMaxGridSize) || !TileMath::isValidKey(key)) { + qCWarning(GeoMapHeightFieldLog) << "samplePatch rejected: key" << key << "gridSize" << gridSize; + return QList(); + } + + // Interior vertices resolve the patch's backing view once, by the + // patch's own key, and interpolate within that one grid. Boundary + // vertices are shared with neighbor patches whose backing views can + // differ (adjacent exact tiles, fine tile next to an ancestor-backed + // neighbor), so they resolve canonically by position instead: the + // deepest-zoom cells touching the vertex, tried east/south first, pick + // the same stored tile no matter which patch asks. Positions are exact + // dyadic values, so coincident vertices compute bit-identical UVs and + // sample bit-identical heights: meshes never crack where data exists. + const ElevationTilePyramid::View patchView = _pyramid.bestTileFor(key); + + // Height at the exact vertex position (n/gridSize, m/gridSize in tile + // units at key.zoom) within a resolved view; ldexp rescales exactly, so + // equal positions give equal UV bits regardless of the asking patch + const auto viewHeight = [&key, gridSize](const ElevationTilePyramid::View& view, qint64 n, qint64 m) { + const double u = (std::ldexp(double(n), view.key.zoom - key.zoom) / gridSize) - view.key.x; + const double v = (std::ldexp(double(m), view.key.zoom - key.zoom) / gridSize) - view.key.y; + return static_cast(heightAtUV(*view.grid, u, v)); + }; + + // Memo of resolved views per key.zoom tile: when a tile has no stored + // descendant, every cell inside it resolves identically (chain below the + // tile is empty, ancestors are shared), so one lookup answers the whole + // edge run along it. An invalid view memoizes the same way — repeated + // misses over uncovered neighbors stay O(1). A patch's boundary touches + // at most 9 such tiles (own + 8 neighbors). + struct TileMemo + { + TileMath::TileKey tile; + ElevationTilePyramid::View view; + }; + + QVarLengthArray memos; + + const int shiftToMax = TileMath::kMaxZoom - key.zoom; + const auto resolveCell = [&, this](qint64 cx, qint64 cy) { + const TileMath::TileKey tile{int(cx >> shiftToMax), int(cy >> shiftToMax), key.zoom}; + for (const TileMemo& memo : memos) { + if (memo.tile == tile) { + return memo.view; + } + } + const ElevationTilePyramid::View view = + _pyramid.bestTileFor(TileMath::TileKey{int(cx), int(cy), TileMath::kMaxZoom}); + if (!_pyramid.hasDescendant(tile) && (memos.size() < memos.capacity())) { + memos.append({tile, view}); + } + return view; + }; + + const auto boundaryHeight = [&](qint64 n, qint64 m) -> float { + // Cells touching the vertex on each axis, east/south side first; a + // vertex not exactly on a cell boundary lies in a single cell + const qint64 cellCount = qint64(1) << TileMath::kMaxZoom; + const auto touchingCells = [&](qint64 s, qint64(&cells)[2]) { + int count = 0; + const qint64 cell = s / gridSize; + if (cell < cellCount) { + cells[count++] = cell; + } + if (((s % gridSize) == 0) && (cell > 0)) { + cells[count++] = cell - 1; + } + return count; + }; + qint64 xCells[2]; + qint64 yCells[2]; + const int xCount = touchingCells(n << shiftToMax, xCells); + const int yCount = touchingCells(m << shiftToMax, yCells); + + // Fixed candidate order derived purely from the position: every + // patch sharing this vertex walks the same cells and returns the + // first resolvable view, so the height is canonical + for (int yi = 0; yi < yCount; yi++) { + for (int xi = 0; xi < xCount; xi++) { + const ElevationTilePyramid::View view = resolveCell(xCells[xi], yCells[yi]); + if (view.isValid()) { + return viewHeight(view, n, m); + } + } + } + return 0.0f; // no stored data touches the vertex: every sharer agrees on zero + }; + + QList heights; + heights.reserve(qsizetype(gridSize + 1) * (gridSize + 1)); + for (int row = 0; row <= gridSize; row++) { + const qint64 m = (qint64(key.y) * gridSize) + row; + for (int col = 0; col <= gridSize; col++) { + const qint64 n = (qint64(key.x) * gridSize) + col; + if ((row == 0) || (row == gridSize) || (col == 0) || (col == gridSize)) { + heights.append(boundaryHeight(n, m)); + } else { + heights.append(patchView.isValid() ? viewHeight(patchView, n, m) : 0.0f); + } + } + } + return heights; +} diff --git a/src/GeoMap/HeightField.h b/src/GeoMap/HeightField.h new file mode 100644 index 000000000000..1acdac2d1953 --- /dev/null +++ b/src/GeoMap/HeightField.h @@ -0,0 +1,107 @@ +/**************************************************************************** + * + * (c) 2009-2024 QGROUNDCONTROL PROJECT + * + * QGroundControl is licensed according to the terms in the file + * COPYING.md in the root of the source code directory. + * + ****************************************************************************/ + +#pragma once + +#include +#include +#include +#include +#include + +#include "ElevationTilePyramid.h" +#include "TileMath.h" + +/// The one continuous terrain heightfield of the drape design: best-estimate +/// height everywhere, by construction — real data where a tile is stored, +/// ancestor-interpolated estimate where only coarser data exists, zero where +/// nothing is known. There is no "missing" region, only coarser-estimate +/// regions, so any two callers asking for the same world position always get +/// the same answer regardless of which patch they mesh. +/// +/// Heights are sampled bilinearly between grid sample centers (pixel-center +/// convention, clamped at tile edges), matching the terrarium tile layout. +/// +/// Not thread-safe: confine to one thread or synchronize externally. This +/// includes the const sampling methods — they mutate an internal memo cache, +/// so even concurrent reads race. +class HeightField : public QObject +{ + Q_OBJECT + // Passed through QML as an opaque pointer (PatchGeometry.heightField) + QML_ANONYMOUS + +public: + explicit HeightField(QObject* parent = nullptr); + + /// Sanity cap on patch density: rejects absurd sizes before allocation + static constexpr int kMaxGridSize = 4096; + + /// Stores a decoded tile in the backing pyramid; invalid keys/grids + /// rejected. Emits regionChanged for the tile's world extent on success, + /// and additionally for the extent of any tile the insert evicted — + /// eviction changes the answer there too. + bool insertTile(const TileMath::TileKey& key, ElevationTilePyramid::Grid grid); + + /// Tiles that must not be evicted because they back rendered patches + /// (or resolve them as ancestors); see ElevationTilePyramid::setPinnedKeys + void setPinnedKeys(QSet keys) { _pyramid.setPinnedKeys(std::move(keys)); } + + /// Best-estimate height (meters) at a mercator world position; 0.0 where + /// no stored tile covers the position + double heightAt(const QPointF& world) const; + + /// The (gridSize+1)^2 vertex heights of a patch, row-major from the NW + /// corner. Empty for invalid keys or gridSize outside [1, kMaxGridSize]. + /// Boundary vertices resolve by canonical world position rather than the + /// patch's own backing view, so coincident vertices of neighboring + /// patches sample bit-identical heights even across different backing + /// tiles — patch edges never crack where data exists. + QList samplePatch(const TileMath::TileKey& key, int gridSize) const; + + int tileCount() const { return _pyramid.tileCount(); } + + /// True when the backing pyramid holds this exact tile + bool hasTile(const TileMath::TileKey& key) const { return _pyramid.hasTile(key); } + + /// Stored tile that backs samples for \a key (the tile itself when + /// present, else its nearest stored ancestor); invalid when nothing covers it + TileMath::TileKey backingKeyFor(const TileMath::TileKey& key) const + { + const ElevationTilePyramid::View view = _pyramid.bestTileFor(key); + return view.isValid() ? view.key : TileMath::TileKey{0, 0, -1}; + } + + /// Perf instrumentation: pyramid resolutions performed so far (memoized + /// sampling keeps this far below one per sampled vertex). Test hook, not + /// API — semantics track the resolution strategy. + qint64 lookupCountForTest() const { return _pyramid.lookupCountForTest(); } + +signals: + /// Best-estimate heights changed within this rect (TileMath world meters, + /// y north; min-corner + positive spans — don't use top()/bottom()). + /// The rect is closed: patch edge vertices exactly on its boundary sample + /// the new data, but QRectF::intersects() is false for edge-only contact, + /// so inflate the patch rect (e.g. marginsAdded) before testing + void regionChanged(const QRectF& worldRect); + +private: + ElevationTilePyramid _pyramid; + + // Memoized last resolved view: adjacent sample positions almost always + // resolve to the same stored tile, so heightAt reuses it when the query + // key resolves to it (same arithmetic as the full lookup). Only populated + // when the tile has no stored descendant (nothing finer could override + // it), and invalidated on every insert (the view's grid pointer is only + // valid until then). + mutable ElevationTilePyramid::View _memoView; + mutable double _memoMinX = 0.0; + mutable double _memoMaxY = 0.0; + mutable double _memoSpan = 0.0; +}; diff --git a/src/GeoMap/HeightSource.cc b/src/GeoMap/HeightSource.cc index 92357717aefd..c1e2e42c8a3f 100644 --- a/src/GeoMap/HeightSource.cc +++ b/src/GeoMap/HeightSource.cc @@ -1,7 +1,11 @@ #include "HeightSource.h" #include + #include +#include + +#include "HeightField.h" HeightSource::HeightSource(QObject* parent) : QObject(parent) {} @@ -49,6 +53,41 @@ void ProceduralHeightSource::cancelRequest(int requestId) _pending.remove(requestId); } +bool ProceduralHeightSource::requestTile(const TileMath::TileKey& key) +{ + if (!_heightField || !TileMath::isValidKey(key)) { + return false; + } + if (_heightField->hasTile(key)) { + return true; + } + + // Sample-center convention matching the terrarium tile layout (see + // HeightField's bilinear sampling) + const QPointF minCorner = TileMath::tileMinCorner(key); + const double span = TileMath::tileSpanAtZoom(key.zoom); + ElevationTilePyramid::Grid grid; + grid.width = kSynthGridSize; + grid.height = kSynthGridSize; + grid.heights.reserve(qsizetype(kSynthGridSize) * kSynthGridSize); + for (int row = 0; row < kSynthGridSize; row++) { + const double y = minCorner.y() + span - (span * (row + 0.5) / kSynthGridSize); + for (int col = 0; col < kSynthGridSize; col++) { + const double x = minCorner.x() + (span * (col + 0.5) / kSynthGridSize); + grid.heights.append(heightAtWorld(QPointF(x, y))); + } + } + + // Deliver through the event loop so consumers see the same async flow as + // a real tile fetch + QTimer::singleShot(0, this, [this, key, grid = std::move(grid)]() mutable { + if (_heightField && !_heightField->hasTile(key)) { + _heightField->insertTile(key, std::move(grid)); + } + }); + return true; +} + void ProceduralHeightSource::_deliver(int requestId) { const auto it = _pending.constFind(requestId); diff --git a/src/GeoMap/HeightSource.h b/src/GeoMap/HeightSource.h index 28db94c541fc..0ef20d3ef5b2 100644 --- a/src/GeoMap/HeightSource.h +++ b/src/GeoMap/HeightSource.h @@ -15,6 +15,8 @@ #include "TileMath.h" +class HeightField; + /// Async source of per-patch terrain height grids for the GeoMap surface mesh. /// /// A patch is addressed by its slippy TileKey. A request returns the heights (meters) @@ -22,7 +24,7 @@ /// the north-west corner. Results are always delivered asynchronously (queued), even /// for sources that can answer immediately, so consumers see one consistent flow. /// -/// TerrariumHeightSource adapts the terrarium elevation tile source behind this interface. +/// TerrariumTileFetcher adapts the terrarium elevation tile source behind this interface. class HeightSource : public QObject { Q_OBJECT @@ -36,6 +38,18 @@ class HeightSource : public QObject /// Cancel a pending request. No signal is emitted for a cancelled request. virtual void cancelRequest(int requestId) = 0; + /// Field that receives whole tiles requested via requestTile (not owned) + void setHeightField(HeightField* field) { _heightField = field; } + + /// Ensures the attached field holds (or will receive) elevation data for + /// this tile. Default: no tile data available — the field keeps serving + /// its current best estimate (flat zero when empty). + virtual bool requestTile(const TileMath::TileKey& key) + { + Q_UNUSED(key); + return false; + } + signals: /// heights has (gridSize+1)^2 entries, row-major from the north-west corner void patchHeightsReady(int requestId, const QList& heights); @@ -44,6 +58,8 @@ class HeightSource : public QObject protected: int _nextRequestId(); + HeightField* _heightField = nullptr; ///< tile delivery target for requestTile (not owned) + private: int _requestIdCounter = 0; }; @@ -57,9 +73,15 @@ class ProceduralHeightSource : public HeightSource public: using HeightSource::HeightSource; + static constexpr int kSynthGridSize = 33; ///< samples per edge of a synthesized tile + int requestPatchHeights(const TileMath::TileKey& key, int gridSize) final; void cancelRequest(int requestId) final; + /// Synthesizes the tile grid from heightAtWorld and inserts it into the + /// attached field through the event loop (mimicking async tile delivery) + bool requestTile(const TileMath::TileKey& key) override; + protected: /// Height in meters at a world-space ground position virtual float heightAtWorld(const QPointF& world) const = 0; @@ -78,6 +100,14 @@ class FlatHeightSource : public ProceduralHeightSource public: using ProceduralHeightSource::ProceduralHeightSource; + /// z=0 everywhere is already the empty field's estimate: inserting + /// all-zero tiles would only churn the pyramid working set + bool requestTile(const TileMath::TileKey& key) override + { + Q_UNUSED(key); + return false; + } + protected: float heightAtWorld(const QPointF& world) const override; }; diff --git a/src/GeoMap/PatchGeometry.cc b/src/GeoMap/PatchGeometry.cc index ccacc91590bd..9079ca73dcb5 100644 --- a/src/GeoMap/PatchGeometry.cc +++ b/src/GeoMap/PatchGeometry.cc @@ -1,14 +1,33 @@ #include "PatchGeometry.h" #include + #include -#include +#include + +#include "HeightField.h" +#include "QGCLoggingCategory.h" + +QGC_LOGGING_CATEGORY(GeoMapPatchGeometryLog, "GeoMap.PatchGeometry") PatchGeometry::PatchGeometry(QQuick3DObject* parent) : QQuick3DGeometry(parent) { _rebuild(); } +void PatchGeometry::componentComplete() +{ + QQuick3DGeometry::componentComplete(); + _rebuild(); // one build for the whole batch of initial property assignments +} + +void PatchGeometry::_requestRebuild() +{ + if (isComponentComplete()) { + _rebuild(); + } +} + void PatchGeometry::setGridSize(int gridSize) { const int clamped = std::clamp(gridSize, kMinGridSize, kMaxGridSize); @@ -16,8 +35,17 @@ void PatchGeometry::setGridSize(int gridSize) return; } _gridSize = clamped; + for (const int delta : _lodDelta) { + if ((delta > 0) && ((_gridSize % (1 << delta)) != 0)) { + qCWarning(GeoMapPatchGeometryLog) << "setGridSize reset edge LOD deltas: delta" << delta + << "has no coincident vertices at gridSize" << _gridSize; + _lodDelta.fill(0); + emit edgeLodDeltasChanged(); + break; + } + } emit gridSizeChanged(); - _rebuild(); + _requestRebuild(); } void PatchGeometry::setSpan(qreal span) @@ -27,7 +55,7 @@ void PatchGeometry::setSpan(qreal span) } _span = span; emit spanChanged(); - _rebuild(); + _requestRebuild(); } void PatchGeometry::setHeights(const QList& heights) @@ -37,18 +65,136 @@ void PatchGeometry::setHeights(const QList& heights) } _heights = heights; emit heightsChanged(); + _requestRebuild(); +} + +bool PatchGeometry::sampleFromField(const TileMath::TileKey& key) +{ + if (!_heightField) { + qCWarning(GeoMapPatchGeometryLog) << "sampleFromField rejected: no height field set, key" << key; + return false; + } + const QList sampled = _heightField->samplePatch(key, _gridSize); + if (sampled.isEmpty()) { + qCWarning(GeoMapPatchGeometryLog) + << "sampleFromField rejected: field returned no samples, key" << key << "gridSize" << _gridSize; + return false; + } + if (sampled != _heights) { + _heights = sampled; + emit heightsChanged(); + } _rebuild(); + return true; } -float PatchGeometry::_heightAt(int row, int col) const +void PatchGeometry::setHeightField(HeightField* heightField) +{ + if (heightField == _heightField) { + return; + } + if (_heightField) { + disconnect(_heightField, nullptr, this, nullptr); + } + _heightField = heightField; + if (_heightField) { + // Match setHeightField(nullptr) semantics; null first, the dying + // object needs no disconnect + connect(_heightField, &QObject::destroyed, this, [this] { + _heightField = nullptr; + emit heightFieldChanged(); + }); + } + emit heightFieldChanged(); +} + +void PatchGeometry::setEdgeLodDeltas(int north, int south, int west, int east) +{ + // Bounding delta first keeps the shift below well-defined + static_assert(std::has_single_bit(static_cast(kMaxGridSize)), "kMaxLodDelta assumes a power of two"); + constexpr int kMaxLodDelta = std::countr_zero(static_cast(kMaxGridSize)); + for (const int delta : {north, south, west, east}) { + if ((delta < 0) || (delta > kMaxLodDelta) || ((delta > 0) && ((_gridSize % (1 << delta)) != 0))) { + qCWarning(GeoMapPatchGeometryLog) + << "setEdgeLodDeltas rejected: delta" << delta << "has no coincident vertices at gridSize" << _gridSize; + return; + } + } + if ((north == _lodDelta[kNorth]) && (south == _lodDelta[kSouth]) && (west == _lodDelta[kWest]) && + (east == _lodDelta[kEast])) { + return; + } + _lodDelta[kNorth] = north; + _lodDelta[kSouth] = south; + _lodDelta[kWest] = west; + _lodDelta[kEast] = east; + emit edgeLodDeltasChanged(); + _requestRebuild(); +} + +void PatchGeometry::setEdgeLodDeltas(const QList& deltas) +{ + if (deltas.count() != kEdgeCount) { + qCWarning(GeoMapPatchGeometryLog) + << "setEdgeLodDeltas rejected: expected {N,S,W,E}, got" << deltas.count() << "deltas"; + return; + } + setEdgeLodDeltas(deltas[0], deltas[1], deltas[2], deltas[3]); +} + +float PatchGeometry::_rawHeightAt(int row, int col) const { const int verticesPerEdge = _gridSize + 1; if (_heights.count() != (verticesPerEdge * verticesPerEdge)) { return 0.0f; // missing or mismatched grid renders flat } - const int clampedRow = std::clamp(row, 0, _gridSize); - const int clampedCol = std::clamp(col, 0, _gridSize); - return _heights.at((clampedRow * verticesPerEdge) + clampedCol); + return _heights.at((row * verticesPerEdge) + col); +} + +float PatchGeometry::_heightAt(int row, int col) const +{ + const int r = std::clamp(row, 0, _gridSize); + const int c = std::clamp(col, 0, _gridSize); + + // T-junction fix: on an edge with a coarser neighbor, non-coincident + // vertices are collapsed onto the segment between the coincident ones. + // The coincident vertices need no correction: HeightField::samplePatch's + // canonical boundary resolution makes them bit-identical to the coarse + // neighbor's rendered edge (plain setHeights callers must provide heights + // with the same property). A corner on two constrained edges takes the + // first match (N,S,W,E precedence); skirts hide the residual three-LOD + // corner mismatch. + Edge edge = kNorth; + bool matched = false; + bool alongCol = false; // lerp runs along the edge direction + if ((r == 0) && (_lodDelta[kNorth] > 0)) { + edge = kNorth; + alongCol = true; + matched = true; + } else if ((r == _gridSize) && (_lodDelta[kSouth] > 0)) { + edge = kSouth; + alongCol = true; + matched = true; + } else if ((c == 0) && (_lodDelta[kWest] > 0)) { + edge = kWest; + matched = true; + } else if ((c == _gridSize) && (_lodDelta[kEast] > 0)) { + edge = kEast; + matched = true; + } + if (matched) { + const int step = 1 << _lodDelta[edge]; + const int idx = alongCol ? c : r; + const int base = (idx / step) * step; + if (idx == base) { + return _rawHeightAt(r, c); + } + const float t = float(idx - base) / step; + const float a = alongCol ? _rawHeightAt(r, base) : _rawHeightAt(base, c); + const float b = alongCol ? _rawHeightAt(r, base + step) : _rawHeightAt(base + step, c); + return a + ((b - a) * t); + } + return _rawHeightAt(r, c); } void PatchGeometry::_rebuild() @@ -61,7 +207,14 @@ void PatchGeometry::_rebuild() const float span = static_cast(_span); const float half = span / 2.0f; const float step = span / _gridSize; - const float skirtDepth = span * static_cast(kSkirtDepthFraction); + // Skirt hides the seam against the coarsest constraining neighbor; that + // seam doubles with each level the neighbor is coarser, so scale the base + // depth by 2^maxDelta (the coarser level's geometric error halves per + // level). maxDelta is in + // [0, kMaxLodDelta] (setEdgeLodDeltas validates), so the shift is in + // range; 0 = base depth. + const int maxDelta = *std::max_element(_lodDelta.cbegin(), _lodDelta.cend()); + const float skirtDepth = span * static_cast(kSkirtDepthFraction) * static_cast(1 << maxDelta); // Interleaved layout: position (3f) + normal (3f) + uv (2f) constexpr int kFloatsPerVertex = 8; diff --git a/src/GeoMap/PatchGeometry.h b/src/GeoMap/PatchGeometry.h index a47205dff57f..5c269b0d2e77 100644 --- a/src/GeoMap/PatchGeometry.h +++ b/src/GeoMap/PatchGeometry.h @@ -12,6 +12,14 @@ #include #include +#include + +#include "TileMath.h" + +class HeightField; + +Q_MOC_INCLUDE("HeightField.h") + /// Grid mesh for one surface patch of the GeoMap engine. /// /// Local space: origin at the patch center, x east, y north, z up (meters). @@ -34,13 +42,16 @@ class PatchGeometry : public QQuick3DGeometry Q_PROPERTY(int gridSize READ gridSize WRITE setGridSize NOTIFY gridSizeChanged) Q_PROPERTY(qreal span READ span WRITE setSpan NOTIFY spanChanged) Q_PROPERTY(QList heights READ heights WRITE setHeights NOTIFY heightsChanged) + Q_PROPERTY(QList edgeLodDeltas READ edgeLodDeltas WRITE setEdgeLodDeltas NOTIFY edgeLodDeltasChanged) + Q_PROPERTY(HeightField* heightField READ heightField WRITE setHeightField NOTIFY heightFieldChanged) public: explicit PatchGeometry(QQuick3DObject* parent = nullptr); static constexpr int kMinGridSize = 1; static constexpr int kMaxGridSize = 256; - static constexpr double kSkirtDepthFraction = 0.05; ///< skirt depth as fraction of span + static constexpr double kSkirtDepthFraction = + 0.05; ///< base skirt depth (fraction of span); scaled by 2^maxEdgeDelta int gridSize() const { return _gridSize; } @@ -55,16 +66,62 @@ class PatchGeometry : public QQuick3DGeometry void setHeights(const QList& heights); + /// Field for sampleFromField to sample from; not owned, may be null. + HeightField* heightField() const { return _heightField; } + + void setHeightField(HeightField* heightField); + + /// Samples the height field at this patch's vertex world positions (tile + /// \a key at the current gridSize) and rebuilds the mesh. Returns false + /// when no field is set or the key is invalid. + bool sampleFromField(const TileMath::TileKey& key); + + /// How many LOD levels coarser each edge's neighbor renders (0 = same or + /// finer: unconstrained). Non-coincident edge vertices are collapsed onto + /// the segments between the coincident ones so no T-junction cracks open; + /// HeightField::samplePatch's canonical boundary resolution guarantees the + /// coincident vertices already match the coarse neighbor's rendered edge. + void setEdgeLodDeltas(int north, int south, int west, int east); + + /// QML-bindable form of the deltas: {north, south, west, east} + QList edgeLodDeltas() const + { + return {_lodDelta[kNorth], _lodDelta[kSouth], _lodDelta[kWest], _lodDelta[kEast]}; + } + + void setEdgeLodDeltas(const QList& deltas); + signals: void gridSizeChanged(); void spanChanged(); void heightsChanged(); + void edgeLodDeltasChanged(); + void heightFieldChanged(); + +protected: + void componentComplete() override; private: + /// Edge index order shared by the delta/offset/sample member arrays: N,S,W,E + enum Edge + { + kNorth, + kSouth, + kWest, + kEast, + kEdgeCount + }; + void _rebuild(); + /// Defers to one build at componentComplete during QML instantiation; + /// immediate otherwise (C++ construction is always "complete") + void _requestRebuild(); float _heightAt(int row, int col) const; + float _rawHeightAt(int row, int col) const; int _gridSize = 16; qreal _span = 1000.0; QList _heights; + HeightField* _heightField = nullptr; + std::array _lodDelta{}; }; diff --git a/src/GeoMap/SurfaceAnalysis.cc b/src/GeoMap/SurfaceAnalysis.cc index 04125d21fe38..1b621d54e41c 100644 --- a/src/GeoMap/SurfaceAnalysis.cc +++ b/src/GeoMap/SurfaceAnalysis.cc @@ -1,6 +1,7 @@ #include "SurfaceAnalysis.h" #include + #include #include #include diff --git a/src/GeoMap/SurfaceModel.cc b/src/GeoMap/SurfaceModel.cc index 5e84a37aa475..ed9083e8decf 100644 --- a/src/GeoMap/SurfaceModel.cc +++ b/src/GeoMap/SurfaceModel.cc @@ -5,13 +5,20 @@ #include #include #include +#include + #include #include -#include +#include #include #include "GeoMapCamera.h" +#include "HeightField.h" #include "HeightSource.h" +#include "QGCLoggingCategory.h" + +QGC_LOGGING_CATEGORY(GeoMapSurfaceModelLog, "GeoMap.SurfaceModel") +QGC_LOGGING_CATEGORY(GeoMapSurfaceModelVerboseLog, "GeoMap.SurfaceModel.Verbose") namespace { @@ -22,15 +29,35 @@ QRectF patchRect(const TileMath::TileKey& key) return QRectF(minCorner.x(), minCorner.y(), span, span); } +/// Region contact including edge-only touch: patch edge vertices exactly on +/// the region boundary sample the changed data, but QRectF::intersects is +/// false for edge-only contact, so the patch rect is inflated first +bool patchTouchesRegion(const TileMath::TileKey& key, const QRectF& region) +{ + const QRectF rect = patchRect(key); + const double margin = rect.width() * 1e-6; + return rect.marginsAdded(QMarginsF(margin, margin, margin, margin)).intersects(region); +} + +float maxHeightOf(const QList& heights) +{ + float maxHeight = 0.0f; + for (const float height : heights) { + if (std::isfinite(height)) { + maxHeight = std::max(maxHeight, height); + } + } + return maxHeight; +} + } // namespace -SurfaceModel::SurfaceModel(GeoMapCamera* camera, HeightSource* heightSource, QObject* parent) - : QObject(parent), _camera(camera), _heightSource(heightSource) +SurfaceModel::SurfaceModel(GeoMapCamera* camera, HeightSource* heightSource, HeightField* field, QObject* parent) + : QObject(parent), _camera(camera), _heightSource(heightSource), _field(field) { qRegisterMetaType(); - connect(_heightSource, &HeightSource::patchHeightsReady, this, &SurfaceModel::_heightsReady); - connect(_heightSource, &HeightSource::patchHeightsFailed, this, &SurfaceModel::_heightsFailed); + connect(_field, &HeightField::regionChanged, this, &SurfaceModel::_fieldRegionChanged); // Coalesce: camera signals fire per input event (up to 120/s during a // gesture); one queued pass per event-loop iteration bounds the cost @@ -82,71 +109,104 @@ void SurfaceModel::update() QSet desiredSet(desired.cbegin(), desired.cend()); - // Drop pending patches no longer desired, cancelling in-flight height - // requests. Ready patches being replaced retire instead: they keep - // rendering until their replacements have heights, so LOD churn and - // panning never open holes in the surface. Removals are capped like adds: - // each erase synchronously destroys a render delegate, and pose jumps - // (high-tilt orbits) can invalidate hundreds of patches at once. - _removalsThisPass = 0; - _removalsDeferred = false; - for (auto it = _patches.begin(); it != _patches.end();) { - if (desiredSet.contains(it.key())) { - it.value().retiring = false; // re-desired while retiring: it is ready, keep as-is - ++it; + // Add newly desired patches, capped per pass: each add synchronously + // builds a render delegate downstream, and uncapped bursts (300+ adds/s + // while zooming) blow the frame budget. A new patch meshes immediately + // from the field's best estimate; tile coverage is requested so the + // estimate refines when data arrives. + QVarLengthArray churnRects; // added/removed extents: neighbors there re-stitch + int adds = 0; + _addsDeferred = false; + for (const TileMath::TileKey& key : desired) { + if (_patches.contains(key)) { continue; } - if (it.value().retiring) { - ++it; // awaiting sweep + if (adds >= kMaxPatchAddsPerUpdate) { + _addsDeferred = true; continue; } - if (it.value().ready) { - it.value().retiring = true; + PatchData data; + data.heights = _field->samplePatch(key, kGridSize); + data.maxHeight = maxHeightOf(data.heights); + _patches.insert(key, std::move(data)); + churnRects.append(patchRect(key)); + _heightSource->requestTile(key); + emit patchAdded(key); + adds++; + } + + // Rects of desired patches still not resident after the capped adds: a + // no-longer-desired patch overlapping one may not be removed yet, or the + // surface would show a hole until the adds catch up. Computed after the + // adds so a replaced patch drops in the same pass its last replacement + // arrives, instead of both rendering (and z-fighting) one extra pass. + QVarLengthArray missingRects; + for (const TileMath::TileKey& key : desired) { + if (!_patches.contains(key)) { + missingRects.append(patchRect(key)); + } + } + const auto overlapsMissing = [&missingRects](const TileMath::TileKey& key) { + const QRectF rect = patchRect(key); + for (const QRectF& missing : missingRects) { + if (rect.intersects(missing)) { + return true; + } + } + return false; + }; + + // Drop patches no longer desired. Removals are capped like adds: each + // erase synchronously destroys a render delegate, and pose jumps + // (high-tilt orbits) can invalidate hundreds of patches at once. Patches + // whose replacements are not resident yet stay for a follow-up pass. + _removalsThisPass = 0; + _removalsDeferred = false; + for (auto it = _patches.begin(); it != _patches.end();) { + if (desiredSet.contains(it.key())) { ++it; continue; } - if (_removalsThisPass >= kMaxPatchRemovalsPerUpdate) { + if ((_removalsThisPass >= kMaxPatchRemovalsPerUpdate) || overlapsMissing(it.key())) { _removalsDeferred = true; // stays resident one more pass; follow-up finishes the cull ++it; continue; } - // Pending patches own a tracked request, except while awaiting a retry - // (requestId 0, never issued by a source): then both calls are no-ops. - // _heightsReady untracks the id when a patch becomes ready (guard also - // protects against a future source reusing ids). - _heightSource->cancelRequest(it.value().requestId); - _requestKeys.remove(it.value().requestId); const TileMath::TileKey removedKey = it.key(); it = _patches.erase(it); + churnRects.append(patchRect(removedKey)); emit patchRemoved(removedKey); _removalsThisPass++; } - // Request heights for newly desired patches, capped per pass: each add - // synchronously builds a render delegate downstream, and uncapped bursts - // (300+ adds/s while zooming) blow the frame budget. No further - // backpressure: the resident set may transiently exceed kMaxPatches while - // slow heights pin retiring covers, which is accepted - fixing terrain - // fetch latency is the pipeline's job, not this model's. - int adds = 0; - _addsDeferred = false; - for (const TileMath::TileKey& key : desired) { - if (_patches.contains(key)) { - continue; - } - if (adds >= kMaxPatchAddsPerUpdate) { - _addsDeferred = true; - continue; + // Added/removed patches change their neighbors' edge LOD deltas: notify + // every resident patch touching a churned extent so it re-stitches + for (auto it = _patches.cbegin(); it != _patches.cend(); ++it) { + for (const QRectF& rect : churnRects) { + if (patchTouchesRegion(it.key(), rect)) { + emit patchEdgeDeltasChanged(it.key()); + break; + } } - PatchData data; - data.requestId = _heightSource->requestPatchHeights(key, kGridSize); - _requestKeys.insert(data.requestId, key); - _patches.insert(key, data); - emit patchAdded(key); - adds++; } - _sweepRetiring(); + // Pin every resident patch's key and its full ancestor chain: whatever + // tile the field resolves a patch sample to is in that chain, and an + // eviction there would silently coarsen a rendered mesh + if ((adds > 0) || (_removalsThisPass > 0)) { + QSet pinned; + for (auto it = _patches.cbegin(); it != _patches.cend(); ++it) { + TileMath::TileKey key = it.key(); + while (true) { + pinned.insert(key); + if (key.zoom == TileMath::kMinZoom) { + break; + } + key = TileMath::TileKey{key.x >> 1, key.y >> 1, key.zoom - 1}; + } + } + _field->setPinnedKeys(std::move(pinned)); + } // The caps guarantee every deferred pass makes progress, so follow-ups // always converge @@ -155,6 +215,10 @@ void SurfaceModel::update() } const qint64 elapsedUs = updateTimer.nsecsElapsed() / 1000; + qCDebug(GeoMapSurfaceModelVerboseLog) + << "update pass: desired" << desired.count() << "resident" << _patches.count() << "adds" << adds << "removals" + << _removalsThisPass << "deferred adds" << _addsDeferred << "deferred removals" << _removalsDeferred + << "elapsedUs" << elapsedUs; _updateStats.updates++; _updateStats.totalUs += elapsedUs; _updateStats.maxUs = std::max(_updateStats.maxUs, elapsedUs); @@ -172,7 +236,7 @@ QList SurfaceModel::patches() const QList result; result.reserve(_patches.count()); for (auto it = _patches.cbegin(); it != _patches.cend(); ++it) { - result.append(Patch{it.key(), it.value().heights, it.value().ready, _isCovered(it.key(), it.value())}); + result.append(Patch{it.key(), it.value().heights, true, false}); } return result; } @@ -180,39 +244,42 @@ QList SurfaceModel::patches() const std::optional SurfaceModel::patch(const TileMath::TileKey& key) const { const auto it = _patches.constFind(key); - if (it == _patches.constEnd()) { + if (it == _patches.cend()) { return std::nullopt; } - return Patch{key, it.value().heights, it.value().ready, _isCovered(key, it.value())}; + return Patch{key, it.value().heights, true, false}; } -bool SurfaceModel::_isCovered(const TileMath::TileKey& key, const PatchData& data) const +QList SurfaceModel::edgeLodDeltas(const TileMath::TileKey& key) const { - if (data.ready) { - return false; - } - // A pending patch overlapped by ready geometry (its retiring ancestor, - // descendants, or a degraded flat fallback) is covered: rendering its - // empty flat grid too would z-fight the cover and occlude terrain below - // sea level - const QRectF rect = patchRect(key); - for (auto it = _patches.cbegin(); it != _patches.cend(); ++it) { - if (it.value().ready && (it.key() != key) && rect.intersects(patchRect(it.key()))) { - return true; - } + // {N,S,W,E} neighbor offsets; slippy y grows south, so north is y-1 + static constexpr int kOffsets[4][2] = {{0, -1}, {0, 1}, {-1, 0}, {1, 0}}; + QList deltas; + deltas.reserve(4); + for (const auto& offset : kOffsets) { + deltas.append(_edgeDelta(key, offset[0], offset[1])); } - return false; + return deltas; } -int SurfaceModel::pendingCount() const +int SurfaceModel::_edgeDelta(const TileMath::TileKey& key, int dx, int dy) const { - int pending = 0; - for (const PatchData& data : _patches) { - if (!data.ready) { - pending++; + const TileMath::TileKey neighbor{key.x + dx, key.y + dy, key.zoom}; + if (!TileMath::isValidKey(neighbor) || _patches.contains(neighbor)) { + return 0; // world edge or same-zoom neighbor: unconstrained + } + // First resident ancestor of the missing same-zoom neighbor is the coarse + // patch rendering across this edge; finer neighbors constrain themselves + TileMath::TileKey ancestor = neighbor; + while (ancestor.zoom > TileMath::kMinZoom) { + ancestor = TileMath::TileKey{ancestor.x >> 1, ancestor.y >> 1, ancestor.zoom - 1}; + if (_patches.contains(ancestor)) { + const int delta = key.zoom - ancestor.zoom; + // No coincident vertices beyond this: leave unconstrained (skirts cover) + return ((kGridSize % (1 << delta)) == 0) ? delta : 0; } } - return pending; + return 0; } QRectF SurfaceModel::_visibleGroundRect(double terrainZ) const @@ -364,37 +431,25 @@ QList SurfaceModel::_desiredPatches(const QRectF& visible, co return desired; } -void SurfaceModel::_heightsReady(int requestId, const QList& heights) +void SurfaceModel::_fieldRegionChanged(const QRectF& worldRect) { - const auto it = _requestKeys.constFind(requestId); - if (it == _requestKeys.constEnd()) { - return; // patch was dropped before delivery - } - const TileMath::TileKey key = it.value(); - _requestKeys.erase(it); - - const auto patchIt = _patches.find(key); - if (patchIt == _patches.end()) { - return; - } - patchIt.value().heights = heights; - float patchMax = 0.0f; - for (const float height : heights) { - if (std::isfinite(height)) { - patchMax = std::max(patchMax, height); + // Re-mesh exactly the patches touching the changed region: their field + // samples (and skirt/normal data downstream) may have changed. Patch edge + // vertices exactly on the region boundary sample the new data too, hence + // the inflated-rect contact test. + int remeshed = 0; + for (auto it = _patches.begin(); it != _patches.end(); ++it) { + if (!patchTouchesRegion(it.key(), worldRect)) { + continue; } + PatchData& data = it.value(); + data.heights = _field->samplePatch(it.key(), kGridSize); + data.maxHeight = maxHeightOf(data.heights); + emit patchMeshChanged(it.key()); + remeshed++; } - patchIt.value().maxHeight = patchMax; - patchIt.value().ready = true; - emit patchReady(key); - // Fresh removal budget: this is its own event-loop activation. Removals - // here also unblock ceiling-deferred adds, so schedule a follow-up. - _removalsThisPass = 0; - _removalsDeferred = false; - _sweepRetiring(); - if (_removalsDeferred || (_removalsThisPass > 0)) { - _scheduleUpdate(); - } + qCDebug(GeoMapSurfaceModelVerboseLog) + << "regionChanged" << worldRect << "re-meshed" << remeshed << "of" << _patches.count() << "patches"; // Terrain taller than the last cull assumed may be visible below/behind // the camera: re-cull terrain-aware @@ -402,86 +457,3 @@ void SurfaceModel::_heightsReady(int requestId, const QList& heights) _scheduleUpdate(); } } - -void SurfaceModel::_sweepRetiring() -{ - // A retiring patch may go once every pending patch it covers for has its - // heights (or it covers for none, e.g. it was panned out of the view). - // While adds are deferred, replacements may not be resident yet, so covers - // cannot be judged - skip the sweep; the capped follow-up passes always - // drain the backlog, after which sweeping resumes. Shares the caller's - // removal budget: sweeps after mass invalidation are the largest - // destruction bursts. - if (_addsDeferred) { - return; - } - for (auto it = _patches.begin(); it != _patches.end();) { - if (!it.value().retiring || _overlapsPendingPatch(it.key())) { - ++it; - continue; - } - if (_removalsThisPass >= kMaxPatchRemovalsPerUpdate) { - _removalsDeferred = true; - break; - } - const TileMath::TileKey key = it.key(); - it = _patches.erase(it); - emit patchRemoved(key); - _removalsThisPass++; - } -} - -bool SurfaceModel::_overlapsPendingPatch(const TileMath::TileKey& key) const -{ - const QRectF rect = patchRect(key); - for (auto it = _patches.cbegin(); it != _patches.cend(); ++it) { - // Touching edges do not intersect, so only true overlaps (the - // quadtree ancestor/descendant replacements) count. Desired degraded - // patches still need cover: their flat fallback is worse than the - // cover. Retiring patches never do (they are on their way out) - - // otherwise a retiring degraded patch would retain itself forever. - if (it.value().retiring) { - continue; - } - if ((!it.value().ready || it.value().degraded) && rect.intersects(patchRect(it.key()))) { - return true; - } - } - return false; -} - -void SurfaceModel::_heightsFailed(int requestId) -{ - const auto it = _requestKeys.constFind(requestId); - if (it == _requestKeys.constEnd()) { - return; // patch was dropped before delivery - } - const auto patchIt = _patches.find(it.value()); - if ((patchIt != _patches.end()) && !patchIt.value().ready && (patchIt.value().retriesLeft > 0)) { - // Transient failures (elevation tile fetch timeouts) self-heal: keep the - // patch pending and re-request after the backoff delay - patchIt.value().retriesLeft--; - patchIt.value().requestId = 0; - const TileMath::TileKey key = patchIt.key(); - _requestKeys.erase(it); - QTimer::singleShot(_heightRetryDelayMs, this, [this, key] { _retryHeights(key); }); - return; - } - // Retries exhausted: degrade to flat ground; a later cull/re-add starts - // fresh. Degraded patches keep any retiring cover (see _overlapsPendingPatch), - // so a real-height predecessor is not replaced by the flat fallback. - if (patchIt != _patches.end()) { - patchIt.value().degraded = true; - } - _heightsReady(requestId, QList()); -} - -void SurfaceModel::_retryHeights(const TileMath::TileKey& key) -{ - const auto it = _patches.find(key); - if ((it == _patches.end()) || it.value().ready || (it.value().requestId != 0)) { - return; // dropped, delivered, or re-requested meanwhile - } - it.value().requestId = _heightSource->requestPatchHeights(key, kGridSize); - _requestKeys.insert(it.value().requestId, key); -} diff --git a/src/GeoMap/SurfaceModel.h b/src/GeoMap/SurfaceModel.h index f57ccd62144f..9142ba63653c 100644 --- a/src/GeoMap/SurfaceModel.h +++ b/src/GeoMap/SurfaceModel.h @@ -13,11 +13,13 @@ #include #include #include + #include #include "TileMath.h" class GeoMapCamera; +class HeightField; class HeightSource; /// Maintains the active set of surface mesh patches for the current camera view. @@ -25,11 +27,15 @@ class HeightSource; /// Patches follow the slippy-tile quadtree. On every camera change the model /// refines the quadtree by screen-space error (a patch subdivides while its /// projected size exceeds the refinement threshold), culls against the visible -/// ground region, and diffs the result against the current set: new patches get -/// height requests from the HeightSource, dropped pending patches get their -/// requests cancelled, and dropped ready patches retire - they keep rendering -/// until the patches replacing them have heights, so LOD churn and panning -/// never open holes in the surface. +/// ground region, and diffs the result against the current set. +/// +/// Patches are views of the continuous HeightField: a new patch meshes +/// immediately from the field's best estimate (never a flat placeholder with +/// real neighbors), tile coverage is requested from the height source, and +/// when data arrives the field's regionChanged re-meshes exactly the patches +/// touching the changed region. A patch being replaced across LOD changes may +/// only be removed once the replacements covering it are resident, so LOD +/// churn and panning never open holes in the surface. /// /// The visible ground region is estimated by sampling screen points through the /// camera and capping horizon misses at a distance-scaled range, so far-horizon @@ -39,7 +45,7 @@ class SurfaceModel : public QObject Q_OBJECT public: - SurfaceModel(GeoMapCamera* camera, HeightSource* heightSource, QObject* parent = nullptr); + SurfaceModel(GeoMapCamera* camera, HeightSource* heightSource, HeightField* field, QObject* parent = nullptr); static constexpr int kGridSize = 16; ///< mesh cells per patch edge /// Budget for the desired patch set; refinement stays coarser rather than exceed @@ -63,17 +69,15 @@ class SurfaceModel : public QObject /// Re-cull when resident terrain grows taller than the last cull assumed by /// more than this (scene units); avoids update churn on every patch delivery static constexpr double kRecullHeightMargin = 25.0; - static constexpr int kMaxHeightRetries = 3; ///< re-requests before a patch degrades to flat - /// Default retry delay: past TerrainTileManager's 5s failed-tile backoff so a - /// retry refetches instead of short-circuiting on the cached failure - static constexpr int kDefaultHeightRetryDelayMs = 6000; struct Patch { TileMath::TileKey key; - QList heights; ///< (kGridSize+1)^2 entries once ready; empty = flat (pending or failed request) - bool ready = false; - bool covered = false; ///< pending and overlapped by a ready patch: suppress rendering + QList heights; ///< (kGridSize+1)^2 field samples, row-major from the NW corner + /// Always true: patches mesh immediately from the field's estimate. + /// Kept until the downstream covered/ready plumbing is removed (step 8). + bool ready = true; + bool covered = false; ///< always false (see ready) }; /// Recompute the active patch set from the current camera state. Called @@ -90,6 +94,11 @@ class SurfaceModel : public QObject /// Single-patch lookup; std::nullopt when the key is not resident std::optional patch(const TileMath::TileKey& key) const; + /// How many LOD levels coarser the resident neighbor across each edge + /// renders, as {north, south, west, east} for PatchGeometry stitching. + /// 0 = same/finer/no neighbor (unconstrained). + QList edgeLodDeltas(const TileMath::TileKey& key) const; + /// update() call/duration counters for the perf overlay struct UpdateStats { @@ -106,12 +115,11 @@ class SurfaceModel : public QObject int patchCount() const { return _patches.count(); } - int pendingCount() const; + /// Always 0: patches mesh immediately from the field. Kept for the perf + /// overlay bindings until the downstream plumbing is removed (step 8). + int pendingCount() const { return 0; } #ifdef QGC_UNITTEST_BUILD - /// Test hook: shortens the failed-request retry delay - void setHeightRetryDelayMs(int ms) { _heightRetryDelayMs = ms; } - /// Test hook: runs capped update passes synchronously to completion. /// The caps guarantee every pass makes progress, so this terminates. void drainUpdates() @@ -125,23 +133,21 @@ class SurfaceModel : public QObject signals: void patchAdded(const TileMath::TileKey& key); - void patchReady(const TileMath::TileKey& key); + /// The patch's mesh content changed (heights re-sampled): consumers must re-pull heights + void patchMeshChanged(const TileMath::TileKey& key); + /// A neighbor add/remove changed the patch's edge LOD deltas: consumers + /// must re-pull edgeLodDeltas (heights are unchanged) + void patchEdgeDeltasChanged(const TileMath::TileKey& key); void patchRemoved(const TileMath::TileKey& key); private slots: - void _heightsReady(int requestId, const QList& heights); - void _heightsFailed(int requestId); + void _fieldRegionChanged(const QRectF& worldRect); private: struct PatchData { - int requestId = 0; ///< 0 = no request in flight (awaiting retry or ready) - QList heights; + QList heights; ///< cached field samples; refreshed on add and regionChanged float maxHeight = 0.0f; ///< cached vertex max so _maxTerrainZ is O(patches) not O(vertices) - bool ready = false; - bool retiring = false; ///< ready but replaced; renders until replacements are ready - bool degraded = false; ///< flat fallback after exhausted retries; keeps its retiring cover - int retriesLeft = kMaxHeightRetries; }; double _projectedPixels(const TileMath::TileKey& key, const QPointF& cameraGround, double cameraHeight) const; @@ -149,21 +155,17 @@ private slots: double cameraHeight) const; QRectF _visibleGroundRect(double terrainZ) const; double _maxTerrainZ() const; - void _retryHeights(const TileMath::TileKey& key); + int _edgeDelta(const TileMath::TileKey& key, int dx, int dy) const; void _scheduleUpdate(); - void _sweepRetiring(); - bool _overlapsPendingPatch(const TileMath::TileKey& key) const; - bool _isCovered(const TileMath::TileKey& key, const PatchData& data) const; GeoMapCamera* const _camera; HeightSource* const _heightSource; + HeightField* const _field; QHash _patches; - QHash _requestKeys; UpdateStats _updateStats; bool _updatePending = false; ///< a coalesced update pass is queued on the event loop bool _addsDeferred = false; ///< the last pass hit the add cap; a follow-up pass is queued bool _removalsDeferred = false; ///< the last pass hit the removal cap; a follow-up pass is queued - int _removalsThisPass = 0; ///< removal budget shared by the cull loop and the retiring sweep + int _removalsThisPass = 0; ///< removal budget for the cull loop double _culledTerrainZ = 0.0; ///< terrain-top height assumed by the last cull (scene units) - int _heightRetryDelayMs = kDefaultHeightRetryDelayMs; }; diff --git a/src/GeoMap/SurfacePatchModel.cc b/src/GeoMap/SurfacePatchModel.cc index 266ca4c7cbb6..dd71dfe238cf 100644 --- a/src/GeoMap/SurfacePatchModel.cc +++ b/src/GeoMap/SurfacePatchModel.cc @@ -8,18 +8,24 @@ #include #include #include + #include #include #include #include "GeoMapCamera.h" #include "GeoScene.h" +#include "HeightField.h" #include "HeightSource.h" +#include "QGCLoggingCategory.h" #include "SurfaceAnalysis.h" #include "SurfaceModel.h" -#include "TerrariumHeightSource.h" +#include "TerrariumTileFetcher.h" #include "TileImageSource.h" +QGC_LOGGING_CATEGORY(GeoMapSurfacePatchModelLog, "GeoMap.SurfacePatchModel") +QGC_LOGGING_CATEGORY(GeoMapSurfacePatchModelVerboseLog, "GeoMap.SurfacePatchModel.Verbose") + SurfacePatchModel::SurfacePatchModel(QObject* parent) : QAbstractListModel(parent) {} SurfacePatchModel::~SurfacePatchModel() = default; @@ -81,6 +87,7 @@ void SurfacePatchModel::setTerrain(bool terrain) if (terrain == _terrain) { return; } + qCDebug(GeoMapSurfacePatchModelLog) << "terrain" << _terrain << "->" << terrain; _terrain = terrain; emit terrainChanged(); _rebuildSurfaceModel(); @@ -91,6 +98,7 @@ void SurfacePatchModel::setDebugHills(bool debugHills) if (debugHills == _debugHills) { return; } + qCDebug(GeoMapSurfacePatchModelLog) << "debugHills" << _debugHills << "->" << debugHills; _debugHills = debugHills; emit debugHillsChanged(); _rebuildSurfaceModel(); @@ -101,6 +109,7 @@ void SurfacePatchModel::setMapType(const QString& mapType) if (mapType == _mapType) { return; } + qCDebug(GeoMapSurfacePatchModelLog) << "mapType" << _mapType << "->" << mapType; _mapType = mapType; emit mapTypeChanged(); @@ -108,7 +117,7 @@ void SurfacePatchModel::setMapType(const QString& mapType) delete _tileSource; _tileSource = nullptr; if (!_mapType.isEmpty()) { - _tileSource = new TileImageSource(_mapType, this); + _tileSource = new TileImageSource(_mapType, this, _tileImageNetworkManager); connect(_tileSource, &TileImageSource::tileImageReady, this, &SurfacePatchModel::_tileImageReady); connect(_tileSource, &TileImageSource::tileImageFailed, this, &SurfacePatchModel::_tileImageFailed); for (const TileMath::TileKey& key : std::as_const(_keys)) { @@ -127,6 +136,10 @@ void SurfacePatchModel::_resetImagery() } _imageRequestKey.clear(); _imageRequestByKey.clear(); + _failedImageKeys.clear(); + if (_imageRetryTimer) { + _imageRetryTimer->stop(); + } _retiredOrder.clear(); _fallbackCache.clear(); if (!_tileImages.isEmpty()) { @@ -163,6 +176,7 @@ void SurfacePatchModel::_tileImageReady(int requestId, const QImage& image) } _tileImages.insert(key, image); _fallbackCache.remove(key); // own image supersedes any cached miss + qCDebug(GeoMapSurfacePatchModelVerboseLog) << "tile image ready for" << key; _invalidateFallbacks(key); _notifyTileImageChanged(key); } @@ -174,7 +188,7 @@ void SurfacePatchModel::_invalidateFallbacks(const TileMath::TileKey& tile) // parent's composite and every cached descendant's crop. Fallbacks read // only delivered images, never other fallbacks, so invalidation never // cascades further. - if (tile.zoom > 0) { + if (tile.zoom > TileMath::kMinZoom) { _fallbackCache.remove(TileMath::TileKey{tile.x >> 1, tile.y >> 1, tile.zoom - 1}); } for (auto it = _fallbackCache.begin(); it != _fallbackCache.end();) { @@ -197,7 +211,7 @@ void SurfacePatchModel::_notifyTileImageChanged(const TileMath::TileKey& key) const TileMath::TileKey& rowKey = _keys.at(row); bool affected = (rowKey == key); if (!affected && !_tileImages.contains(rowKey)) { - if ((key.zoom > 0) && (rowKey.zoom == (key.zoom - 1))) { + if ((key.zoom > TileMath::kMinZoom) && (rowKey.zoom == (key.zoom - 1))) { affected = (rowKey.x == (key.x >> 1)) && (rowKey.y == (key.y >> 1)); } else if (rowKey.zoom > key.zoom) { const int up = rowKey.zoom - key.zoom; @@ -214,13 +228,44 @@ void SurfacePatchModel::_notifyTileImageChanged(const TileMath::TileKey& key) void SurfacePatchModel::_tileImageFailed(int requestId) { - // Leave the image null: the delegate keeps its fallback, and patch churn re-requests const auto it = _imageRequestKey.constFind(requestId); if (it == _imageRequestKey.constEnd()) { return; } - _imageRequestByKey.remove(it.value()); + const TileMath::TileKey key = it.value(); + _imageRequestByKey.remove(key); _imageRequestKey.erase(it); + + // The delegate keeps its fallback meanwhile; a resident patch retries on + // the paced timer (patch churn cannot be relied on to re-request - a + // patch that stays resident would keep the loading fallback forever) + if (!_keys.contains(key)) { + return; + } + qCDebug(GeoMapSurfacePatchModelLog) << "tile image failed for" << key << "(retry scheduled)"; + _failedImageKeys.insert(key); + if (!_imageRetryTimer) { + _imageRetryTimer = new QTimer(this); + _imageRetryTimer->setSingleShot(true); + _imageRetryTimer->setInterval(kImageRetryMs); + connect(_imageRetryTimer, &QTimer::timeout, this, &SurfacePatchModel::_retryFailedImages); + } + if (!_imageRetryTimer->isActive()) { + _imageRetryTimer->start(); + } +} + +void SurfacePatchModel::_retryFailedImages() +{ + const QSet failed = std::exchange(_failedImageKeys, {}); + for (const TileMath::TileKey& key : failed) { + // Re-check residency and in-flight state: churn may have re-requested + // or removed the patch since the failure + if (!_keys.contains(key) || _tileImages.contains(key) || _imageRequestByKey.contains(key)) { + continue; + } + _requestTileImage(key); + } } void SurfacePatchModel::_rebuildSurfaceModel() @@ -232,19 +277,27 @@ void SurfacePatchModel::_rebuildSurfaceModel() _surfaceModel = nullptr; delete _heightSource; _heightSource = nullptr; + delete _heightField; + _heightField = nullptr; GeoMapCamera* const camera = _camera(); if (camera) { if (_debugHills) { _heightSource = new DebugHeightSource(this); } else if (_terrain) { - _heightSource = new TerrariumHeightSource(this); + _heightSource = new TerrariumTileFetcher(this); } else { _heightSource = new FlatHeightSource(this); } - _surfaceModel = new SurfaceModel(camera, _heightSource, this); + qCDebug(GeoMapSurfacePatchModelLog) << "rebuilding surface model, height source:" + << (_debugHills ? "debug hills" : (_terrain ? "terrain" : "flat")); + _heightField = new HeightField(this); + _heightSource->setHeightField(_heightField); + _surfaceModel = new SurfaceModel(camera, _heightSource, _heightField, this); connect(_surfaceModel, &SurfaceModel::patchAdded, this, &SurfacePatchModel::_patchAdded); - connect(_surfaceModel, &SurfaceModel::patchReady, this, &SurfacePatchModel::_patchReady); + connect(_surfaceModel, &SurfaceModel::patchMeshChanged, this, &SurfacePatchModel::_patchReady); + connect(_surfaceModel, &SurfaceModel::patchEdgeDeltasChanged, this, + &SurfacePatchModel::_patchEdgeDeltasChanged); connect(_surfaceModel, &SurfaceModel::patchRemoved, this, &SurfacePatchModel::_patchRemoved); } endResetModel(); @@ -484,6 +537,10 @@ QVariant SurfacePatchModel::data(const QModelIndex& index, int role) const return span; case ZoomRole: return key.zoom; + case TileXRole: + return key.x; + case TileYRole: + return key.y; case HeightsRole: { const auto patch = _surfaceModel->patch(key); return patch ? QVariant::fromValue(patch->heights) : QVariant::fromValue(QList()); @@ -496,6 +553,8 @@ QVariant SurfacePatchModel::data(const QModelIndex& index, int role) const const auto patch = _surfaceModel->patch(key); return patch ? patch->covered : false; } + case EdgeLodDeltasRole: + return QVariant::fromValue(_surfaceModel->edgeLodDeltas(key)); case TileImageRole: { const QImage own = _tileImages.value(key); if (!own.isNull()) { @@ -585,9 +644,18 @@ QImage SurfacePatchModel::_fallbackImage(const TileMath::TileKey& key) const QHash SurfacePatchModel::roleNames() const { return { - {CenterXRole, "centerX"}, {SpanRole, "span"}, {CenterYRole, "centerY"}, - {ZoomRole, "zoomLevel"}, {HeightsRole, "heights"}, {ReadyRole, "ready"}, - {CoveredRole, "covered"}, {TileImageRole, "tileImage"}, {HasTileImageRole, "hasTileImage"}, + {CenterXRole, "centerX"}, + {SpanRole, "span"}, + {CenterYRole, "centerY"}, + {ZoomRole, "zoomLevel"}, + {HeightsRole, "heights"}, + {ReadyRole, "ready"}, + {CoveredRole, "covered"}, + {TileImageRole, "tileImage"}, + {HasTileImageRole, "hasTileImage"}, + {EdgeLodDeltasRole, "edgeLodDeltas"}, + {TileXRole, "tileX"}, + {TileYRole, "tileY"}, }; } @@ -620,6 +688,16 @@ void SurfacePatchModel::_patchReady(const TileMath::TileKey& key) _scheduleStatsChanged(); } +void SurfacePatchModel::_patchEdgeDeltasChanged(const TileMath::TileKey& key) +{ + const int row = _keys.indexOf(key); + if (row < 0) { + return; + } + const QModelIndex idx = index(row); + emit dataChanged(idx, idx, {EdgeLodDeltasRole}); +} + void SurfacePatchModel::_patchRemoved(const TileMath::TileKey& key) { const int row = _keys.indexOf(key); @@ -642,6 +720,7 @@ void SurfacePatchModel::_patchRemoved(const TileMath::TileKey& key) } } _fallbackCache.remove(key); // row gone: keep the cache bounded by live rows + _failedImageKeys.remove(key); const auto requestIt = _imageRequestByKey.constFind(key); if (requestIt != _imageRequestByKey.constEnd()) { _tileSource->cancelRequest(requestIt.value()); diff --git a/src/GeoMap/SurfacePatchModel.h b/src/GeoMap/SurfacePatchModel.h index 2f96633fe94b..060b06ed1137 100644 --- a/src/GeoMap/SurfacePatchModel.h +++ b/src/GeoMap/SurfacePatchModel.h @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -22,12 +23,15 @@ class GeoScene; class GeoMapCamera; +class HeightField; class HeightSource; +class QNetworkAccessManager; class QTimer; class SurfaceModel; class TileImageSource; Q_MOC_INCLUDE("GeoScene.h") +Q_MOC_INCLUDE("HeightField.h") /// QML bridge between SurfaceModel and the Repeater3D that renders the surface: /// one row per active patch, updated incrementally (no model resets on pan/zoom). @@ -66,6 +70,9 @@ class SurfacePatchModel : public QAbstractListModel CoveredRole, ///< pending but overlapped by a ready patch: delegate suppresses rendering TileImageRole, ///< drapable image: own tile, or ancestor/descendant fallback while loading HasTileImageRole, ///< QImage is opaque to QML; bool validity for bindings + EdgeLodDeltasRole, ///< {N,S,W,E} coarser-neighbor LOD deltas for edge stitching + TileXRole, ///< slippy tile x of the patch key + TileYRole, ///< slippy tile y of the patch key }; GeoScene* scene() const { return _scene; } @@ -74,7 +81,7 @@ class SurfacePatchModel : public QAbstractListModel bool terrain() const { return _terrain; } - /// Real terrain elevations (see TerrariumHeightSource); + /// Real terrain elevations (see TerrariumTileFetcher); /// false gives a flat z=0 surface. debugHills overrides either. void setTerrain(bool terrain); @@ -90,6 +97,10 @@ class SurfacePatchModel : public QAbstractListModel int gridSize() const; + /// The shared terrain field patches sample from. Owned here; replaced + /// whenever the surface model is rebuilt. + HeightField* heightField() const { return _heightField; } + /// SurfaceModel::kMaxRangeMultiplier, exposed so the scene camera's far /// clip plane can cover the full retained patch range double maxRangeMultiplier() const; @@ -99,6 +110,16 @@ class SurfacePatchModel : public QAbstractListModel int pendingCount() const; int maxZoomLevel() const; + /// Tile image requests awaiting delivery or failure + int pendingImageCount() const { return _imageRequestKey.count(); } + + /// Network manager for tile imagery fetches, applied at the next + /// setMapType (test injection seam) + void setTileImageNetworkManager(QNetworkAccessManager* networkManager) + { + _tileImageNetworkManager = networkManager; + } + bool statsEnabled() const { return _statsEnabled; } /// Enables the once-per-second perf counter sampling behind statsText: @@ -143,6 +164,7 @@ class SurfacePatchModel : public QAbstractListModel private slots: void _patchAdded(const TileMath::TileKey& key); void _patchReady(const TileMath::TileKey& key); + void _patchEdgeDeltasChanged(const TileMath::TileKey& key); void _patchRemoved(const TileMath::TileKey& key); void _sceneOriginChanged(); void _tileImageReady(int requestId, const QImage& image); @@ -153,6 +175,7 @@ private slots: void _rebuildSurfaceModel(); void _resetImagery(); void _requestTileImage(const TileMath::TileKey& key); + void _retryFailedImages(); void _notifyTileImageChanged(const TileMath::TileKey& key); void _invalidateFallbacks(const TileMath::TileKey& tile); void _notifyCoveredMayHaveChanged(const TileMath::TileKey& changedKey); @@ -164,15 +187,18 @@ private slots: static constexpr int kMaxRetiredImages = 128; ///< tiles kept after patch removal (fallback source) static constexpr int kMaxAncestorFallbackLevels = 8; ///< how far up the quadtree fallback looks + static constexpr int kImageRetryMs = 3000; ///< pacing for re-requesting failed tile images GeoScene* _scene = nullptr; HeightSource* _heightSource = nullptr; + HeightField* _heightField = nullptr; SurfaceModel* _surfaceModel = nullptr; bool _terrain = false; bool _debugHills = false; QString _mapType; TileImageSource* _tileSource = nullptr; - QHash _tileImages; ///< delivered images: live patches + retired fallbacks + QNetworkAccessManager* _tileImageNetworkManager = nullptr; ///< test injection seam + QHash _tileImages; ///< delivered images: live patches + retired fallbacks /// Built fallbacks (incl. null misses) keyed by patch; rebuilding on every /// data() call cost ~1ms per new patch during zoom churn. Invalidated when /// a source tile changes (see _invalidateFallbacks), dropped with the row. @@ -180,6 +206,8 @@ private slots: QList _retiredOrder; ///< retired keys oldest-first, for eviction QHash _imageRequestKey; ///< in-flight image request id -> patch QHash _imageRequestByKey; ///< reverse map for cancellation + QSet _failedImageKeys; ///< resident patches awaiting an image retry + QTimer* _imageRetryTimer = nullptr; QList _keys; ///< row order; data fetched from SurfaceModel by key // Perf counters sampled by _statsTick (see setStatsEnabled) diff --git a/src/GeoMap/TerrariumHeightSource.cc b/src/GeoMap/TerrariumHeightSource.cc deleted file mode 100644 index c55264d6afaa..000000000000 --- a/src/GeoMap/TerrariumHeightSource.cc +++ /dev/null @@ -1,276 +0,0 @@ -/**************************************************************************** - * - * (c) 2009-2024 QGROUNDCONTROL PROJECT - * - * QGroundControl is licensed according to the terms in the file - * COPYING.md in the root of the source code directory. - * - ****************************************************************************/ - -#include "TerrariumHeightSource.h" - -#include -#include -#include -#include -#include - -#include "ElevationMapProvider.h" -#include "QGCCacheTile.h" -#include "QGCMapEngine.h" -#include "QGCMapTasks.h" -#include "QGCMapUrlEngine.h" -#include "QGeoFileTileCacheQGC.h" -#include "QGeoTileFetcherQGC.h" - -namespace { - -QString terrariumProviderType() -{ - return QString::fromLatin1(TerrariumElevationProvider::kProviderKey); -} - -/// Terrarium RGB decode at one pixel: height = (R*256 + G + B/256) - 32768 meters -double heightAtPixel(const QImage& image, int px, int py) -{ - const QRgb rgb = image.pixel(px, py); - return (qRed(rgb) * 256.0) + qGreen(rgb) + (qBlue(rgb) / 256.0) - 32768.0; -} - -/// Bilinear height at a unit-UV position (origin NW corner), pixel-center -/// convention, clamped at tile edges -double heightAtUV(const QImage& image, double u, double v) -{ - const int w = image.width(); - const int h = image.height(); - const double px = (u * w) - 0.5; - const double py = (v * h) - 0.5; - const int x0 = qBound(0, static_cast(std::floor(px)), w - 1); - const int y0 = qBound(0, static_cast(std::floor(py)), h - 1); - const int x1 = qMin(x0 + 1, w - 1); - const int y1 = qMin(y0 + 1, h - 1); - const double fx = qBound(0.0, px - x0, 1.0); - const double fy = qBound(0.0, py - y0, 1.0); - - const double north = (heightAtPixel(image, x0, y0) * (1.0 - fx)) + (heightAtPixel(image, x1, y0) * fx); - const double south = (heightAtPixel(image, x0, y1) * (1.0 - fx)) + (heightAtPixel(image, x1, y1) * fx); - return (north * (1.0 - fy)) + (south * fy); -} - -/// Samples the (gridSize+1)^2 vertex grid, row-major from the NW corner, from the -/// tile sub-window covering the patch -QList sampleGrid(const QImage& image, const QRectF& subWindow, int gridSize) -{ - QList heights; - heights.reserve(qsizetype(gridSize + 1) * (gridSize + 1)); - for (int row = 0; row <= gridSize; row++) { - const double v = subWindow.y() + (subWindow.height() * row / gridSize); - for (int col = 0; col <= gridSize; col++) { - const double u = subWindow.x() + (subWindow.width() * col / gridSize); - heights.append(static_cast(heightAtUV(image, u, v))); - } - } - return heights; -} - -/// Terrarium tiles are always 256x256; anything else (e.g. a CDN placeholder -/// image) is not elevation data -constexpr int kTileSizePixels = 256; - -/// Decoded sampling-ready tile image; null when the body isn't a valid tile -QImage decodeTile(const QByteArray& data) -{ - QImage image; - if (!image.loadFromData(data) || image.width() != kTileSizePixels || image.height() != kTileSizePixels) { - return QImage(); - } - return image.convertToFormat(QImage::Format_RGB32); -} - -} // namespace - -TerrariumHeightSource::TerrariumHeightSource(QObject* parent, QNetworkAccessManager* networkManager) - : HeightSource(parent), - _networkManager(networkManager ? networkManager : new QNetworkAccessManager(this)), - _mapId(UrlFactory::getQtMapIdFromProviderType(terrariumProviderType())) -{} - -int TerrariumHeightSource::requestPatchHeights(const TileMath::TileKey& key, int gridSize) -{ - const int requestId = _nextRequestId(); - - if ((gridSize <= 0) || (_mapId < 0)) { - _pending.insert(requestId, PendingRequest{}); - _failAsync(requestId); - return requestId; - } - - // Deeper than the dataset: fetch the top-zoom ancestor and sample the - // patch's sub-window of it - PendingRequest pending; - pending.gridSize = gridSize; - if (key.zoom <= kMaxTileZoom) { - pending.fetchKey = key; - pending.subWindow = QRectF(0, 0, 1, 1); - } else { - const int shift = key.zoom - kMaxTileZoom; - pending.fetchKey = TileMath::TileKey{key.x >> shift, key.y >> shift, kMaxTileZoom}; - const double scale = 1.0 / (1 << shift); - pending.subWindow = QRectF((key.x - (pending.fetchKey.x << shift)) * scale, - (key.y - (pending.fetchKey.y << shift)) * scale, scale, scale); - } - _pending.insert(requestId, pending); - _lastFetchKey = pending.fetchKey; - - QList& waiters = _waiters[pending.fetchKey]; - waiters.append(requestId); - if (waiters.size() > 1) { - return requestId; // a fetch for this tile is already in flight; it serves all waiters - } - - const QString providerType = terrariumProviderType(); - QGCFetchTileTask* const task = QGeoFileTileCacheQGC::createFetchTileTask(providerType, pending.fetchKey.x, - pending.fetchKey.y, pending.fetchKey.zoom); - const TileMath::TileKey fetchKey = pending.fetchKey; - connect(task, &QGCFetchTileTask::tileFetched, this, [this, fetchKey](QGCCacheTile* tile) { - const std::unique_ptr guard(tile); // caller-owned per task contract - if (!_waiters.contains(fetchKey)) { - return; // all waiters cancelled while the lookup was in flight - } - if (!tile || tile->img.isEmpty()) { - _failAll(fetchKey); - return; - } - const QImage image = decodeTile(tile->img); - if (image.isNull()) { - _failAll(fetchKey); - return; - } - _deliverAll(fetchKey, image); - }); - connect(task, &QGCMapTask::error, this, [this, fetchKey](QGCMapTask::TaskType, const QString&) { - if (!_waiters.contains(fetchKey)) { - return; - } - _fetchFromNetwork(fetchKey); - }); - - if (!getQGCMapEngine()->addTask(task)) { - task->deleteLater(); // never enqueued: the worker will not clean it up - _waiters.remove(fetchKey); - _failAsync(requestId); - } - return requestId; -} - -void TerrariumHeightSource::cancelRequest(int requestId) -{ - const auto pendingIt = _pending.constFind(requestId); - if (pendingIt == _pending.cend()) { - return; - } - const TileMath::TileKey fetchKey = pendingIt->fetchKey; - _pending.erase(pendingIt); - - const auto waitersIt = _waiters.find(fetchKey); - if (waitersIt == _waiters.end()) { - return; - } - waitersIt->removeOne(requestId); - if (!waitersIt->isEmpty()) { - return; // other requests still wait on this tile's fetch - } - _waiters.erase(waitersIt); - QNetworkReply* const reply = _activeReplies.take(fetchKey); - if (reply) { - reply->abort(); // finished handler is a no-op once no waiters remain - } -} - -void TerrariumHeightSource::_failAsync(int requestId) -{ - // Deliver asynchronously so callers see one consistent flow - QMetaObject::invokeMethod( - this, - [this, requestId] { - if (_pending.contains(requestId)) { - _finishFailed(requestId); - } - }, - Qt::QueuedConnection); -} - -void TerrariumHeightSource::_fetchFromNetwork(const TileMath::TileKey& fetchKey) -{ - if (_activeReplies.contains(fetchKey)) { - return; // a reply is already in flight for this tile - } - const QNetworkRequest request = - QGeoTileFetcherQGC::getNetworkRequest(_mapId, fetchKey.x, fetchKey.y, fetchKey.zoom); - QNetworkReply* const reply = _networkManager->get(request); - _activeReplies.insert(fetchKey, reply); - - connect(reply, &QNetworkReply::finished, this, [this, fetchKey, reply] { - reply->deleteLater(); - if (_activeReplies.value(fetchKey) != reply) { - return; // stale: cancelled (and possibly superseded by a newer fetch) - } - _activeReplies.remove(fetchKey); - if (!_waiters.contains(fetchKey)) { - return; // all waiters cancelled (abort also lands here) - } - if (reply->error() != QNetworkReply::NoError) { - _failAll(fetchKey); - return; - } - const QByteArray data = reply->readAll(); - if (data.isEmpty()) { - _failAll(fetchKey); - return; - } - // Never cache bodies that delivery would reject (e.g. HTTP-200 error - // pages): a cached invalid tile would fail every retry from then on - const QImage image = decodeTile(data); - if (image.isNull()) { - _failAll(fetchKey); - return; - } - // Store back so the shared cache serves this tile from now on - const QString providerType = terrariumProviderType(); - QGeoFileTileCacheQGC::cacheTile(providerType, fetchKey.x, fetchKey.y, fetchKey.zoom, data, - UrlFactory::getImageFormat(providerType, data)); - _deliverAll(fetchKey, image); - }); -} - -void TerrariumHeightSource::_deliverAll(const TileMath::TileKey& fetchKey, const QImage& image) -{ - const QList requestIds = _waiters.take(fetchKey); - for (int requestId : requestIds) { - if (_pending.contains(requestId)) { - _deliver(requestId, image); - } - } -} - -void TerrariumHeightSource::_failAll(const TileMath::TileKey& fetchKey) -{ - const QList requestIds = _waiters.take(fetchKey); - for (int requestId : requestIds) { - if (_pending.contains(requestId)) { - _finishFailed(requestId); - } - } -} - -void TerrariumHeightSource::_deliver(int requestId, const QImage& image) -{ - const PendingRequest pending = _pending.take(requestId); - emit patchHeightsReady(requestId, sampleGrid(image, pending.subWindow, pending.gridSize)); -} - -void TerrariumHeightSource::_finishFailed(int requestId) -{ - _pending.remove(requestId); - emit patchHeightsFailed(requestId); -} diff --git a/src/GeoMap/TerrariumTileFetcher.cc b/src/GeoMap/TerrariumTileFetcher.cc new file mode 100644 index 000000000000..0540b23ad47f --- /dev/null +++ b/src/GeoMap/TerrariumTileFetcher.cc @@ -0,0 +1,411 @@ +/**************************************************************************** + * + * (c) 2009-2024 QGROUNDCONTROL PROJECT + * + * QGroundControl is licensed according to the terms in the file + * COPYING.md in the root of the source code directory. + * + ****************************************************************************/ + +#include "TerrariumTileFetcher.h" + +#include +#include +#include + +#include +#include + +#include "ElevationMapProvider.h" +#include "HeightField.h" +#include "PatchGeometry.h" +#include "QGCCacheTile.h" +#include "QGCLoggingCategory.h" +#include "QGCMapEngine.h" +#include "QGCMapTasks.h" +#include "QGCMapUrlEngine.h" +#include "QGeoFileTileCacheQGC.h" +#include "QGeoTileFetcherQGC.h" + +QGC_LOGGING_CATEGORY(GeoMapTerrariumTileFetcherLog, "GeoMap.TerrariumTileFetcher") +QGC_LOGGING_CATEGORY(GeoMapTerrariumTileFetcherVerboseLog, "GeoMap.TerrariumTileFetcher.Verbose") + +static_assert(TerrariumTileFetcher::kMaxGridSize == PatchGeometry::kMaxGridSize, + "fetcher grid cap must match the patch mesh cap"); + +namespace { + +QString terrariumProviderType() +{ + return QString::fromLatin1(TerrariumElevationProvider::kProviderKey); +} + +/// Terrarium RGB decode at one pixel: height = (R*256 + G + B/256) - 32768 meters +double heightAtPixel(const QImage& image, int px, int py) +{ + const QRgb rgb = image.pixel(px, py); + return (qRed(rgb) * 256.0) + qGreen(rgb) + (qBlue(rgb) / 256.0) - 32768.0; +} + +/// Bilinear height at a unit-UV position (origin NW corner), pixel-center +/// convention, clamped at tile edges. +/// Must stay numerically identical to HeightField.cc's heightAtUV: +/// cross-source vertex identity depends on both samplers agreeing +double heightAtUV(const QImage& image, double u, double v) +{ + const int w = image.width(); + const int h = image.height(); + const double px = (u * w) - 0.5; + const double py = (v * h) - 0.5; + const int x0 = qBound(0, static_cast(std::floor(px)), w - 1); + const int y0 = qBound(0, static_cast(std::floor(py)), h - 1); + const int x1 = qMin(x0 + 1, w - 1); + const int y1 = qMin(y0 + 1, h - 1); + const double fx = qBound(0.0, px - x0, 1.0); + const double fy = qBound(0.0, py - y0, 1.0); + + const double north = (heightAtPixel(image, x0, y0) * (1.0 - fx)) + (heightAtPixel(image, x1, y0) * fx); + const double south = (heightAtPixel(image, x0, y1) * (1.0 - fx)) + (heightAtPixel(image, x1, y1) * fx); + return (north * (1.0 - fy)) + (south * fy); +} + +/// Samples the (gridSize+1)^2 vertex grid, row-major from the NW corner, from the +/// tile sub-window covering the patch +QList sampleGrid(const QImage& image, const QRectF& subWindow, int gridSize) +{ + QList heights; + heights.reserve(qsizetype(gridSize + 1) * (gridSize + 1)); + for (int row = 0; row <= gridSize; row++) { + const double v = subWindow.y() + (subWindow.height() * row / gridSize); + for (int col = 0; col <= gridSize; col++) { + const double u = subWindow.x() + (subWindow.width() * col / gridSize); + heights.append(static_cast(heightAtUV(image, u, v))); + } + } + return heights; +} + +/// Terrarium tiles are always 256x256; anything else (e.g. a CDN placeholder +/// image) is not elevation data +constexpr int kTileSizePixels = 256; + +/// Decoded sampling-ready tile image; null when the body isn't a valid tile +QImage decodeTile(const QByteArray& data) +{ + QImage image; + if (!image.loadFromData(data) || image.width() != kTileSizePixels || image.height() != kTileSizePixels) { + return QImage(); + } + return image.convertToFormat(QImage::Format_RGB32); +} + +/// Full-tile decode to a pyramid grid, row-major from the NW corner +ElevationTilePyramid::Grid gridFromImage(const QImage& image) +{ + ElevationTilePyramid::Grid grid; + grid.width = image.width(); + grid.height = image.height(); + grid.heights.reserve(qsizetype(grid.width) * grid.height); + for (int py = 0; py < grid.height; py++) { + for (int px = 0; px < grid.width; px++) { + grid.heights.append(static_cast(heightAtPixel(image, px, py))); + } + } + return grid; +} + +/// Tile actually fetched for a key: the key itself, or its ancestor at the +/// dataset's top zoom for deeper keys +TileMath::TileKey fetchKeyFor(const TileMath::TileKey& key) +{ + if (key.zoom <= TerrariumTileFetcher::kMaxTileZoom) { + return key; + } + const int shift = key.zoom - TerrariumTileFetcher::kMaxTileZoom; + return TileMath::TileKey{key.x >> shift, key.y >> shift, TerrariumTileFetcher::kMaxTileZoom}; +} + +} // namespace + +TerrariumTileFetcher::TerrariumTileFetcher(QObject* parent, QNetworkAccessManager* networkManager) + : HeightSource(parent), + _networkManager(networkManager ? networkManager : new QNetworkAccessManager(this)), + _mapId(UrlFactory::getQtMapIdFromProviderType(terrariumProviderType())) +{} + +int TerrariumTileFetcher::requestPatchHeights(const TileMath::TileKey& key, int gridSize) +{ + const int requestId = _nextRequestId(); + + if ((gridSize <= 0) || (gridSize > kMaxGridSize) || !TileMath::isValidKey(key)) { + qCWarning(GeoMapTerrariumTileFetcherLog) + << "requestPatchHeights rejected: key" << key << "gridSize" << gridSize; + _pending.insert(requestId, PendingRequest{}); + _failAsync(requestId); + return requestId; + } + if (_mapId < 0) { + qCDebug(GeoMapTerrariumTileFetcherLog) << "requestPatchHeights: elevation provider not registered"; + _pending.insert(requestId, PendingRequest{}); + _failAsync(requestId); + return requestId; + } + + // Deeper than the dataset: fetch the top-zoom ancestor and sample the + // patch's sub-window of it + PendingRequest pending; + pending.gridSize = gridSize; + pending.fetchKey = fetchKeyFor(key); + if (key.zoom <= kMaxTileZoom) { + pending.subWindow = QRectF(0, 0, 1, 1); + } else { + const int shift = key.zoom - kMaxTileZoom; + const double scale = 1.0 / (1LL << shift); + pending.subWindow = QRectF((key.x - (qint64(pending.fetchKey.x) << shift)) * scale, + (key.y - (qint64(pending.fetchKey.y) << shift)) * scale, scale, scale); + } + _pending.insert(requestId, pending); + _lastFetchKey = pending.fetchKey; + + const bool inFlight = _fetchInFlight(pending.fetchKey); + _waiters[pending.fetchKey].append(requestId); + qCDebug(GeoMapTerrariumTileFetcherVerboseLog) + << "requestPatchHeights: key" << key << "requestId" << requestId << "fetchKey" << pending.fetchKey + << (inFlight ? "(joining in-flight fetch)" : "(starting fetch)"); + if (inFlight) { + return requestId; // a fetch for this tile is already in flight; it serves all waiters + } + + if (!_startFetch(pending.fetchKey)) { + qCDebug(GeoMapTerrariumTileFetcherLog) << "requestPatchHeights: could not start fetch for" << pending.fetchKey; + _waiters.remove(pending.fetchKey); + _failAsync(requestId); + } + return requestId; +} + +bool TerrariumTileFetcher::requestTile(const TileMath::TileKey& key) +{ + if (!_heightField || !TileMath::isValidKey(key)) { + qCWarning(GeoMapTerrariumTileFetcherLog) + << "requestTile rejected: key" << key << "heightFieldSet" << (_heightField != nullptr); + return false; + } + if (_mapId < 0) { + qCDebug(GeoMapTerrariumTileFetcherLog) << "requestTile: elevation provider not registered"; + return false; + } + + const TileMath::TileKey fetchKey = fetchKeyFor(key); + if (_heightField->hasTile(fetchKey) || _fieldRequests.contains(fetchKey)) { + return true; + } + + const bool inFlight = _fetchInFlight(fetchKey); + _fieldRequests.insert(fetchKey); + _lastFetchKey = fetchKey; + qCDebug(GeoMapTerrariumTileFetcherVerboseLog) + << "requestTile: fetchKey" << fetchKey << (inFlight ? "(joining in-flight fetch)" : "(starting fetch)"); + if (inFlight) { + return true; // piggyback on the fetch already serving patch waiters + } + + if (!_startFetch(fetchKey)) { + qCDebug(GeoMapTerrariumTileFetcherLog) << "requestTile: could not start fetch for" << fetchKey; + _fieldRequests.remove(fetchKey); + return false; + } + return true; +} + +bool TerrariumTileFetcher::_fetchInFlight(const TileMath::TileKey& fetchKey) const +{ + return _waiters.contains(fetchKey) || _fieldRequests.contains(fetchKey); +} + +bool TerrariumTileFetcher::_startFetch(const TileMath::TileKey& fetchKey) +{ + const QString providerType = terrariumProviderType(); + QGCFetchTileTask* const task = + QGeoFileTileCacheQGC::createFetchTileTask(providerType, fetchKey.x, fetchKey.y, fetchKey.zoom); + connect(task, &QGCFetchTileTask::tileFetched, this, [this, fetchKey](QGCCacheTile* tile) { + const std::unique_ptr guard(tile); // caller-owned per task contract + if (!_fetchInFlight(fetchKey)) { + return; // all interest cancelled while the lookup was in flight + } + // An unusable cached body (empty or corrupt) is a miss, not a + // failure: failing here would re-read the same bytes on every retry, + // blocking the network fallback until cache eviction + const QImage image = tile ? decodeTile(tile->img) : QImage(); + if (image.isNull()) { + qCDebug(GeoMapTerrariumTileFetcherLog) + << "tile" << fetchKey << "cached entry unusable, fetching from network"; + _fetchFromNetwork(fetchKey); + return; + } + qCDebug(GeoMapTerrariumTileFetcherVerboseLog) << "tile" << fetchKey << "served from cache"; + _deliverAll(fetchKey, image); + }); + connect(task, &QGCMapTask::error, this, [this, fetchKey](QGCMapTask::TaskType, const QString&) { + if (!_fetchInFlight(fetchKey)) { + return; + } + qCDebug(GeoMapTerrariumTileFetcherVerboseLog) << "tile" << fetchKey << "not cached, fetching from network"; + _fetchFromNetwork(fetchKey); + }); + + if (!getQGCMapEngine()->addTask(task)) { + qCDebug(GeoMapTerrariumTileFetcherLog) << "could not queue cache lookup for" << fetchKey; + task->deleteLater(); // never enqueued: the worker will not clean it up + return false; + } + return true; +} + +void TerrariumTileFetcher::cancelRequest(int requestId) +{ + const auto pendingIt = _pending.constFind(requestId); + if (pendingIt == _pending.cend()) { + return; + } + const TileMath::TileKey fetchKey = pendingIt->fetchKey; + _pending.erase(pendingIt); + + const auto waitersIt = _waiters.find(fetchKey); + if (waitersIt == _waiters.end()) { + return; + } + waitersIt->removeOne(requestId); + if (!waitersIt->isEmpty()) { + return; // other requests still wait on this tile's fetch + } + _waiters.erase(waitersIt); + if (_fieldRequests.contains(fetchKey)) { + return; // the field still wants this tile: keep the fetch alive + } + QNetworkReply* const reply = _activeReplies.take(fetchKey); + if (reply) { + qCDebug(GeoMapTerrariumTileFetcherVerboseLog) << "cancelRequest: aborting network fetch for" << fetchKey; + reply->abort(); // finished handler is a no-op once no waiters remain + reply->deleteLater(); // don't rely on abort emitting finished for cleanup + } +} + +void TerrariumTileFetcher::_failAsync(int requestId) +{ + // Deliver asynchronously so callers see one consistent flow + QMetaObject::invokeMethod( + this, + [this, requestId] { + if (_pending.contains(requestId)) { + _finishFailed(requestId); + } + }, + Qt::QueuedConnection); +} + +void TerrariumTileFetcher::_fetchFromNetwork(const TileMath::TileKey& fetchKey) +{ + if (_activeReplies.contains(fetchKey)) { + return; // a reply is already in flight for this tile + } + const QNetworkRequest request = + QGeoTileFetcherQGC::getNetworkRequest(_mapId, fetchKey.x, fetchKey.y, fetchKey.zoom); + QNetworkReply* const reply = _networkManager->get(request); + _activeReplies.insert(fetchKey, reply); + + connect(reply, &QNetworkReply::finished, this, [this, fetchKey, reply] { + reply->deleteLater(); + if (_activeReplies.value(fetchKey) != reply) { + return; // stale: cancelled (and possibly superseded by a newer fetch) + } + _activeReplies.remove(fetchKey); + if (!_fetchInFlight(fetchKey)) { + return; // all interest cancelled (abort also lands here) + } + if (reply->error() != QNetworkReply::NoError) { + _failAll(fetchKey, QStringLiteral("network error: %1 (http %2)") + .arg(reply->errorString()) + .arg(reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt())); + return; + } + const QByteArray data = reply->readAll(); + if (data.isEmpty()) { + _failAll(fetchKey, QStringLiteral("network fetch returned empty body")); + return; + } + // Never cache bodies that delivery would reject (e.g. HTTP-200 error + // pages): a cached invalid tile would fail every retry from then on + const QImage image = decodeTile(data); + if (image.isNull()) { + _failAll(fetchKey, QStringLiteral("network body is not a terrarium tile, %1 bytes").arg(data.size())); + return; + } + qCDebug(GeoMapTerrariumTileFetcherVerboseLog) + << "tile" << fetchKey << "fetched from network," << data.size() << "bytes, caching"; + // Store back so the shared cache serves this tile from now on + const QString providerType = terrariumProviderType(); + QGeoFileTileCacheQGC::cacheTile(providerType, fetchKey.x, fetchKey.y, fetchKey.zoom, data, + UrlFactory::getImageFormat(providerType, data)); + _deliverAll(fetchKey, image); + }); +} + +void TerrariumTileFetcher::_deliverAll(const TileMath::TileKey& fetchKey, const QImage& image) +{ + const bool forField = _fieldRequests.remove(fetchKey) && _heightField; + if (forField) { + _heightField->insertTile(fetchKey, gridFromImage(image)); + } + + const QList requestIds = _waiters.take(fetchKey); + qCDebug(GeoMapTerrariumTileFetcherVerboseLog) + << "delivering tile" << fetchKey << "to" << requestIds.count() << "waiters, field insert:" << forField; + for (int requestId : requestIds) { + if (_pending.contains(requestId)) { + _deliver(requestId, image); + } + } +} + +void TerrariumTileFetcher::_failAll(const TileMath::TileKey& fetchKey, const QString& reason) +{ + // A failed tile inserts nothing: the field keeps its current estimate, and + // clearing the in-flight key lets a later request retry + _fieldRequests.remove(fetchKey); + + const QList requestIds = _waiters.take(fetchKey); + if (_shouldWarnFailure()) { + qCWarning(GeoMapTerrariumTileFetcherLog) + << "tile" << fetchKey << "failed:" << reason << "- failing" << requestIds.count() << "waiters"; + } else { + qCDebug(GeoMapTerrariumTileFetcherLog) << "tile" << fetchKey << "failed:" << reason << "- failing" + << requestIds.count() << "waiters (warning suppressed)"; + } + for (int requestId : requestIds) { + if (_pending.contains(requestId)) { + _finishFailed(requestId); + } + } +} + +bool TerrariumTileFetcher::_shouldWarnFailure() +{ + if (_failureWarnTimer.isValid() && (_failureWarnTimer.elapsed() < kFailureWarnIntervalMs)) { + return false; + } + _failureWarnTimer.restart(); + return true; +} + +void TerrariumTileFetcher::_deliver(int requestId, const QImage& image) +{ + const PendingRequest pending = _pending.take(requestId); + emit patchHeightsReady(requestId, sampleGrid(image, pending.subWindow, pending.gridSize)); +} + +void TerrariumTileFetcher::_finishFailed(int requestId) +{ + _pending.remove(requestId); + emit patchHeightsFailed(requestId); +} diff --git a/src/GeoMap/TerrariumHeightSource.h b/src/GeoMap/TerrariumTileFetcher.h similarity index 57% rename from src/GeoMap/TerrariumHeightSource.h rename to src/GeoMap/TerrariumTileFetcher.h index 36a2d09ba7cf..22669bf60a6b 100644 --- a/src/GeoMap/TerrariumHeightSource.h +++ b/src/GeoMap/TerrariumTileFetcher.h @@ -9,43 +9,57 @@ #pragma once +#include #include #include +#include #include "HeightSource.h" +class HeightField; class QImage; class QNetworkAccessManager; class QNetworkReply; -/// HeightSource over the AWS Open Data Terrain Tiles (terrarium encoding): real +/// Fetcher over the AWS Open Data Terrain Tiles (terrarium encoding): real /// elevations at every patch zoom from one slippy PNG tile per patch, cache-first /// through QGC's shared tile database with a direct network fallback on miss /// (fetched tiles are stored back). /// -/// A patch at z/x/y maps to the terrarium tile at the same z/x/y, so fetch cost +/// requestTile delivers whole decoded tiles into an attached HeightField, which +/// notifies consumers via regionChanged. A failed fetch inserts nothing: the +/// field keeps serving its current best estimate. +/// +/// A tile at z/x/y maps to the terrarium tile at the same z/x/y, so fetch cost /// is constant across the LOD range — no flat floor or blend band like the fixed /// resolution Copernicus pipeline needed. Above the dataset's top zoom the z15 -/// ancestor tile is fetched and its sub-window sampled instead. +/// ancestor tile is fetched instead. /// -/// Vertex heights are sampled bilinearly between pixel centers, clamped at tile -/// edges (terrarium tiles don't share edge pixels; the ~half-pixel clamp error -/// can show as hairline cracks between neighboring patches — a neighbor-stitch -/// pass can replace the clamp later without changing this interface). -class TerrariumHeightSource : public HeightSource +/// The legacy per-patch HeightSource API (bilinear vertex-grid sampling with +/// edge clamp) remains until SurfaceModel switches to sampling the field. +class TerrariumTileFetcher : public HeightSource { Q_OBJECT public: /// \a networkManager overrides the internally created one (test injection seam) - explicit TerrariumHeightSource(QObject* parent = nullptr, QNetworkAccessManager* networkManager = nullptr); + explicit TerrariumTileFetcher(QObject* parent = nullptr, QNetworkAccessManager* networkManager = nullptr); /// Highest zoom the terrarium dataset serves (deeper patches sample the ancestor) static constexpr int kMaxTileZoom = 15; + /// Largest supported patch grid (matches PatchGeometry::kMaxGridSize) + static constexpr int kMaxGridSize = 256; + int requestPatchHeights(const TileMath::TileKey& key, int gridSize) final; void cancelRequest(int requestId) final; + /// Ensures the attached field holds this tile (clamped to the z15 ancestor + /// above kMaxTileZoom). Returns false when no field is attached, the key is + /// invalid, or the fetch could not start; true when the tile is already + /// held, already in flight, or a fetch was started. + bool requestTile(const TileMath::TileKey& key) override; + int pendingCount() const { return _pending.count(); } /// Tile key submitted by the most recent fetch (test observability for the @@ -61,16 +75,23 @@ class TerrariumHeightSource : public HeightSource }; void _failAsync(int requestId); + bool _fetchInFlight(const TileMath::TileKey& fetchKey) const; + bool _startFetch(const TileMath::TileKey& fetchKey); void _fetchFromNetwork(const TileMath::TileKey& fetchKey); void _deliverAll(const TileMath::TileKey& fetchKey, const QImage& image); - void _failAll(const TileMath::TileKey& fetchKey); + void _failAll(const TileMath::TileKey& fetchKey, const QString& reason); void _deliver(int requestId, const QImage& image); void _finishFailed(int requestId); + bool _shouldWarnFailure(); + + static constexpr int kFailureWarnIntervalMs = 10000; ///< throttle for fetch-failure warnings QNetworkAccessManager* _networkManager = nullptr; const int _mapId; QHash _pending; QHash> _waiters; ///< request ids sharing the in-flight fetch of a tile + QSet _fieldRequests; ///< tiles awaiting delivery into the field QHash _activeReplies; ///< in-flight network fetches by tile TileMath::TileKey _lastFetchKey; + QElapsedTimer _failureWarnTimer; ///< restarted on each emitted failure warning }; diff --git a/src/GeoMap/TileImageSource.cc b/src/GeoMap/TileImageSource.cc index fc34324a9955..52fcbede91f8 100644 --- a/src/GeoMap/TileImageSource.cc +++ b/src/GeoMap/TileImageSource.cc @@ -12,16 +12,21 @@ #include #include #include + #include #include "MapProvider.h" #include "QGCCacheTile.h" +#include "QGCLoggingCategory.h" #include "QGCMapEngine.h" #include "QGCMapTasks.h" #include "QGCMapUrlEngine.h" #include "QGeoFileTileCacheQGC.h" #include "QGeoTileFetcherQGC.h" +QGC_LOGGING_CATEGORY(GeoMapTileImageSourceLog, "GeoMap.TileImageSource") +QGC_LOGGING_CATEGORY(GeoMapTileImageSourceVerboseLog, "GeoMap.TileImageSource.Verbose") + namespace { // Bing serves a placeholder image instead of an HTTP error where it has no @@ -42,12 +47,16 @@ bool isBingEmptyTile(int mapId, const QByteArray& data) } // namespace -TileImageSource::TileImageSource(const QString& mapType, QObject* parent) +TileImageSource::TileImageSource(const QString& mapType, QObject* parent, QNetworkAccessManager* networkManager) : QObject(parent), _mapType(mapType), _mapId(UrlFactory::getQtMapIdFromProviderType(mapType)), - _networkManager(new QNetworkAccessManager(this)) -{} + _networkManager(networkManager ? networkManager : new QNetworkAccessManager(this)) +{ + if (_mapId < 0) { + qCWarning(GeoMapTileImageSourceLog) << "unknown map type" << _mapType << "- all tile requests will fail"; + } +} int TileImageSource::requestTileImage(const TileMath::TileKey& key) { @@ -58,27 +67,31 @@ int TileImageSource::requestTileImage(const TileMath::TileKey& key) _failAsync(requestId); // unknown provider: don't spam the cache/URL factory per request return requestId; } + qCDebug(GeoMapTileImageSourceVerboseLog) << "requestTileImage: key" << key << "requestId" << requestId; QGCFetchTileTask* const task = QGeoFileTileCacheQGC::createFetchTileTask(_mapType, key.x, key.y, key.zoom); - connect(task, &QGCFetchTileTask::tileFetched, this, [this, requestId](QGCCacheTile* tile) { + connect(task, &QGCFetchTileTask::tileFetched, this, [this, requestId, key](QGCCacheTile* tile) { const std::unique_ptr guard(tile); // caller-owned per task contract if (!_pending.contains(requestId)) { return; // cancelled while the lookup was in flight } if (!tile || tile->img.isEmpty()) { - _finishFailed(requestId); + qCDebug(GeoMapTileImageSourceLog) << "tile" << key << "cache returned empty tile, fetching from network"; + _fetchFromNetwork(requestId, key); return; } - _deliver(requestId, tile->img); + _deliver(requestId, key, tile->img); }); connect(task, &QGCMapTask::error, this, [this, requestId, key](QGCMapTask::TaskType, const QString&) { if (!_pending.contains(requestId)) { return; } + qCDebug(GeoMapTileImageSourceVerboseLog) << "tile" << key << "not cached, fetching from network"; _fetchFromNetwork(requestId, key); }); if (!getQGCMapEngine()->addTask(task)) { + qCDebug(GeoMapTileImageSourceLog) << "could not queue cache lookup for" << key; task->deleteLater(); // never enqueued: the worker will not clean it up _failAsync(requestId); } @@ -122,33 +135,45 @@ void TileImageSource::_fetchFromNetwork(int requestId, const TileMath::TileKey& return; // cancelled (abort also lands here) } if (reply->error() != QNetworkReply::NoError) { + _warnFailure(key, QStringLiteral("network error: %1 (http %2)") + .arg(reply->errorString()) + .arg(reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt())); _finishFailed(requestId); return; } const QByteArray data = reply->readAll(); if (data.isEmpty() || isBingEmptyTile(_mapId, data)) { + _warnFailure(key, data.isEmpty() ? QStringLiteral("network fetch returned empty body") + : QStringLiteral("network fetch returned Bing no-tile placeholder")); _finishFailed(requestId); return; } QImage image; if (!image.loadFromData(data)) { + _warnFailure(key, QStringLiteral("network body failed to decode, %1 bytes").arg(data.size())); _finishFailed(requestId); // never cache undecodable bodies (e.g. HTTP-200 error pages) return; } // Store back so the shared cache serves this tile from now on + qCDebug(GeoMapTileImageSourceVerboseLog) << "tile" << key << "fetched from network, caching"; QGeoFileTileCacheQGC::cacheTile(_mapType, key.x, key.y, key.zoom, data, UrlFactory::getImageFormat(_mapType, data)); _finishSucceeded(requestId, image); }); } -void TileImageSource::_deliver(int requestId, const QByteArray& data) +void TileImageSource::_deliver(int requestId, const TileMath::TileKey& key, const QByteArray& data) { QImage image; if (isBingEmptyTile(_mapId, data) || !image.loadFromData(data)) { - _finishFailed(requestId); + // An unusable cached body (placeholder or corrupt) is a miss, not a + // failure: failing here would re-read the same bytes on every paced + // retry, blocking the network fallback until cache eviction + qCDebug(GeoMapTileImageSourceLog) << "tile" << key << "cached entry unusable, fetching from network"; + _fetchFromNetwork(requestId, key); return; } + qCDebug(GeoMapTileImageSourceVerboseLog) << "tile" << key << "served from cache"; _finishSucceeded(requestId, image); } @@ -163,3 +188,16 @@ void TileImageSource::_finishFailed(int requestId) _pending.remove(requestId); emit tileImageFailed(requestId); } + +void TileImageSource::_warnFailure(const TileMath::TileKey& key, const QString& reason) +{ + // Fetch failures must be visible without logging configuration (matches + // the map tile / terrain query convention), but repeats are throttled so + // an outage doesn't flood the log + if (!_failureWarnTimer.isValid() || (_failureWarnTimer.elapsed() >= kFailureWarnIntervalMs)) { + _failureWarnTimer.restart(); + qCWarning(GeoMapTileImageSourceLog) << "tile" << key << "failed:" << reason; + } else { + qCDebug(GeoMapTileImageSourceLog) << "tile" << key << "failed:" << reason << "(warning suppressed)"; + } +} diff --git a/src/GeoMap/TileImageSource.h b/src/GeoMap/TileImageSource.h index e6e50dbc433e..39e99c038e41 100644 --- a/src/GeoMap/TileImageSource.h +++ b/src/GeoMap/TileImageSource.h @@ -9,6 +9,7 @@ #pragma once +#include #include #include #include @@ -39,7 +40,10 @@ class TileImageSource : public QObject /// mapType is a provider type string from UrlFactory::getProviderTypes() /// (e.g. "Google Satellite"). With an unknown type every request fails /// (asynchronously) without touching the cache or network. - explicit TileImageSource(const QString& mapType, QObject* parent = nullptr); + /// \a networkManager overrides the internally created one (test injection + /// seam); an injected manager is not owned and must outlive this object + explicit TileImageSource(const QString& mapType, QObject* parent = nullptr, + QNetworkAccessManager* networkManager = nullptr); QString mapType() const { return _mapType; } @@ -58,9 +62,12 @@ class TileImageSource : public QObject private: void _fetchFromNetwork(int requestId, const TileMath::TileKey& key); void _failAsync(int requestId); - void _deliver(int requestId, const QByteArray& data); + void _deliver(int requestId, const TileMath::TileKey& key, const QByteArray& data); void _finishSucceeded(int requestId, const QImage& image); void _finishFailed(int requestId); + void _warnFailure(const TileMath::TileKey& key, const QString& reason); + + static constexpr int kFailureWarnIntervalMs = 10000; ///< throttle for fetch-failure warnings const QString _mapType; const int _mapId; @@ -68,4 +75,5 @@ class TileImageSource : public QObject int _requestIdCounter = 0; QSet _pending; ///< ids that have not finished or been cancelled QHash _activeReplies; ///< in-flight network fetches by request id + QElapsedTimer _failureWarnTimer; ///< restarted on each emitted failure warning }; diff --git a/src/GeoMap/TileMath.cc b/src/GeoMap/TileMath.cc index b4a99471a440..17cb5f165eda 100644 --- a/src/GeoMap/TileMath.cc +++ b/src/GeoMap/TileMath.cc @@ -1,11 +1,20 @@ #include "TileMath.h" +#include #include + #include #include namespace TileMath { +QDebug operator<<(QDebug debug, const TileKey& key) +{ + const QDebugStateSaver saver(debug); + debug.nospace() << key.zoom << '/' << key.x << '/' << key.y; + return debug; +} + double worldSize() { return 2.0 * M_PI * kEarthRadius; @@ -32,6 +41,15 @@ double mercatorScale(double latitude) return 1.0 / std::cos(qDegreesToRadians(lat)); } +bool isValidKey(const TileKey& key) +{ + if ((key.zoom < kMinZoom) || (key.zoom > kMaxZoom)) { + return false; + } + const int tilesAtZoom = 1 << key.zoom; + return (key.x >= 0) && (key.x < tilesAtZoom) && (key.y >= 0) && (key.y < tilesAtZoom); +} + double tileSpanAtZoom(int zoom) { return worldSize() / static_cast(1 << std::clamp(zoom, kMinZoom, kMaxZoom)); diff --git a/src/GeoMap/TileMath.h b/src/GeoMap/TileMath.h index 1229c3423ed4..cd709f11613a 100644 --- a/src/GeoMap/TileMath.h +++ b/src/GeoMap/TileMath.h @@ -9,6 +9,7 @@ #pragma once +#include #include #include #include @@ -44,6 +45,9 @@ inline size_t qHash(const TileKey& key, size_t seed = 0) return ::qHashMulti(seed, key.x, key.y, key.zoom); } +/// Streams as slippy "zoom/x/y" notation for logging +QDebug operator<<(QDebug debug, const TileKey& key); + } // namespace TileMath Q_DECLARE_METATYPE(TileMath::TileKey) @@ -53,6 +57,9 @@ namespace TileMath { /// Full mercator world extent (2*pi*R) in world meters double worldSize(); +/// True if zoom is within [kMinZoom, kMaxZoom] and x/y address a tile at that zoom +bool isValidKey(const TileKey& key); + /// Geo -> world meters. Latitude is clamped to +/-kMaxLatitude. QPointF geoToWorld(const QGeoCoordinate& coord); diff --git a/test/GeoMap/CMakeLists.txt b/test/GeoMap/CMakeLists.txt index 7f6488720ae3..217e60eaa060 100644 --- a/test/GeoMap/CMakeLists.txt +++ b/test/GeoMap/CMakeLists.txt @@ -7,10 +7,14 @@ target_sources(${CMAKE_PROJECT_NAME} PRIVATE CheckerboardTextureDataTest.cc CheckerboardTextureDataTest.h + ElevationTilePyramidTest.cc + ElevationTilePyramidTest.h GeoSceneTest.cc GeoSceneTest.h GeoMapCameraTest.cc GeoMapCameraTest.h + HeightFieldTest.cc + HeightFieldTest.h HeightSourceTest.cc HeightSourceTest.h PatchGeometryTest.cc @@ -25,8 +29,8 @@ target_sources(${CMAKE_PROJECT_NAME} SurfacePatchImageryTest.h SurfacePatchModelTest.cc SurfacePatchModelTest.h - TerrariumHeightSourceTest.cc - TerrariumHeightSourceTest.h + TerrariumTileFetcherTest.cc + TerrariumTileFetcherTest.h TileImageSourceTest.cc TileImageSourceTest.h TileMathTest.cc @@ -36,8 +40,10 @@ target_sources(${CMAKE_PROJECT_NAME} target_include_directories(${CMAKE_PROJECT_NAME} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) add_qgc_test(CheckerboardTextureDataTest LABELS Unit) +add_qgc_test(ElevationTilePyramidTest LABELS Unit) add_qgc_test(GeoSceneTest LABELS Unit) add_qgc_test(GeoMapCameraTest LABELS Unit) +add_qgc_test(HeightFieldTest LABELS Unit) add_qgc_test(HeightSourceTest LABELS Unit) add_qgc_test(PatchGeometryTest LABELS Unit) add_qgc_test(PatchTextureDataTest LABELS Unit) @@ -45,6 +51,6 @@ add_qgc_test(SurfaceAnalysisTest LABELS Unit) add_qgc_test(SurfaceModelTest LABELS Unit) add_qgc_test(SurfacePatchImageryTest LABELS Integration) add_qgc_test(SurfacePatchModelTest LABELS Unit) -add_qgc_test(TerrariumHeightSourceTest LABELS Integration Terrain) +add_qgc_test(TerrariumTileFetcherTest LABELS Integration Terrain) add_qgc_test(TileImageSourceTest LABELS Integration) add_qgc_test(TileMathTest LABELS Unit) diff --git a/test/GeoMap/ElevationTilePyramidTest.cc b/test/GeoMap/ElevationTilePyramidTest.cc new file mode 100644 index 000000000000..c1f70531eeab --- /dev/null +++ b/test/GeoMap/ElevationTilePyramidTest.cc @@ -0,0 +1,261 @@ +#include "ElevationTilePyramidTest.h" + +#include + +#include "ElevationTilePyramid.h" + +namespace { + +/// Uniform-height grid, big enough to be a plausible tile +ElevationTilePyramid::Grid makeGrid(float height, int size = 4) +{ + ElevationTilePyramid::Grid grid; + grid.width = size; + grid.height = size; + grid.heights = QList(qsizetype(size) * size, height); + return grid; +} + +} // namespace + +void ElevationTilePyramidTest::_emptyPyramidHasNoView() +{ + const ElevationTilePyramid pyramid; + QCOMPARE(pyramid.tileCount(), 0); + QVERIFY(!pyramid.hasTile(TileMath::TileKey{0, 0, 0})); + QVERIFY(!pyramid.bestTileFor(TileMath::TileKey{5, 6, 3}).isValid()); +} + +void ElevationTilePyramidTest::_insertRejectsInvalidGrid() +{ + ElevationTilePyramid pyramid; + const TileMath::TileKey key{1, 1, 1}; + + QVERIFY(!pyramid.insertTile(key, ElevationTilePyramid::Grid{})); + + // Sample count inconsistent with dimensions + ElevationTilePyramid::Grid bad = makeGrid(10.0f); + bad.heights.removeLast(); + QVERIFY(!pyramid.insertTile(key, bad)); + + // Zero rows with nonzero width + ElevationTilePyramid::Grid zeroRows = makeGrid(10.0f); + zeroRows.height = 0; + QVERIFY(!pyramid.insertTile(key, zeroRows)); + + QCOMPARE(pyramid.tileCount(), 0); + QVERIFY(!pyramid.hasTile(key)); +} + +void ElevationTilePyramidTest::_rejectsInvalidKey() +{ + ElevationTilePyramid pyramid; + QVERIFY(!pyramid.insertTile(TileMath::TileKey{0, 0, TileMath::kMaxZoom + 1}, makeGrid(1.0f))); + QVERIFY(!pyramid.insertTile(TileMath::TileKey{0, 0, -1}, makeGrid(1.0f))); + + // x/y outside the slippy range for the zoom + QVERIFY(!pyramid.insertTile(TileMath::TileKey{-1, 0, 3}, makeGrid(1.0f))); + QVERIFY(!pyramid.insertTile(TileMath::TileKey{0, -1, 3}, makeGrid(1.0f))); + QVERIFY(!pyramid.insertTile(TileMath::TileKey{0, 8, 3}, makeGrid(1.0f))); + QCOMPARE(pyramid.tileCount(), 0); + + // Absurd zoom must fail cleanly, not walk the shift into UB + QVERIFY(pyramid.insertTile(TileMath::TileKey{0, 0, 0}, makeGrid(1.0f))); + QVERIFY(!pyramid.bestTileFor(TileMath::TileKey{0, 0, TileMath::kMaxZoom + 1}).isValid()); + QVERIFY(!pyramid.bestTileFor(TileMath::TileKey{0, 0, 500}).isValid()); + QVERIFY(!pyramid.bestTileFor(TileMath::TileKey{0, 0, -1}).isValid()); + QVERIFY(!pyramid.bestTileFor(TileMath::TileKey{-1, 0, 3}).isValid()); + QVERIFY(!pyramid.bestTileFor(TileMath::TileKey{8, 0, 3}).isValid()); +} + +void ElevationTilePyramidTest::_selfTileWins() +{ + ElevationTilePyramid pyramid; + const TileMath::TileKey key{5, 6, 3}; + QVERIFY(pyramid.insertTile(TileMath::TileKey{1, 1, 1}, makeGrid(100.0f))); + QVERIFY(pyramid.insertTile(key, makeGrid(200.0f))); + QVERIFY(pyramid.hasTile(key)); + + const ElevationTilePyramid::View view = pyramid.bestTileFor(key); + QVERIFY(view.isValid()); + QCOMPARE(view.key, key); + QCOMPARE(view.subWindow, QRectF(0, 0, 1, 1)); + QCOMPARE(view.grid->heights.first(), 200.0f); +} + +void ElevationTilePyramidTest::_nearestAncestorWins() +{ + ElevationTilePyramid pyramid; + // Ancestors of (5,6,3): (2,3,2), (1,1,1), (0,0,0) + QVERIFY(pyramid.insertTile(TileMath::TileKey{0, 0, 0}, makeGrid(1.0f))); + QVERIFY(pyramid.insertTile(TileMath::TileKey{1, 1, 1}, makeGrid(2.0f))); + QVERIFY(pyramid.insertTile(TileMath::TileKey{2, 3, 2}, makeGrid(3.0f))); + + const ElevationTilePyramid::View view = pyramid.bestTileFor(TileMath::TileKey{5, 6, 3}); + QVERIFY(view.isValid()); + QCOMPARE(view.key, (TileMath::TileKey{2, 3, 2})); + QCOMPARE(view.grid->heights.first(), 3.0f); +} + +void ElevationTilePyramidTest::_noDescendantFallback() +{ + ElevationTilePyramid pyramid; + // Child and unrelated sibling of the query — neither covers it + QVERIFY(pyramid.insertTile(TileMath::TileKey{10, 12, 4}, makeGrid(1.0f))); + QVERIFY(pyramid.insertTile(TileMath::TileKey{4, 6, 3}, makeGrid(2.0f))); + + QVERIFY(!pyramid.bestTileFor(TileMath::TileKey{5, 6, 3}).isValid()); +} + +void ElevationTilePyramidTest::_subWindowMath() +{ + ElevationTilePyramid pyramid; + QVERIFY(pyramid.insertTile(TileMath::TileKey{1, 1, 1}, makeGrid(5.0f))); + + // (5,6,3) within ancestor (1,1,1): shift 2, quarter-tile window + const ElevationTilePyramid::View view = pyramid.bestTileFor(TileMath::TileKey{5, 6, 3}); + QVERIFY(view.isValid()); + QCOMPARE(view.key, (TileMath::TileKey{1, 1, 1})); + QCOMPARE(view.subWindow, QRectF(0.25, 0.5, 0.25, 0.25)); +} + +void ElevationTilePyramidTest::_subWindowDeepZoom() +{ + ElevationTilePyramid pyramid; + const TileMath::TileKey ancestor{7, 2, 3}; + QVERIFY(pyramid.insertTile(ancestor, makeGrid(5.0f))); + + // 10 zoom levels deeper: NW-most descendant maps to a tiny window at the + // ancestor's NW corner + const int shift = 10; + const double scale = 1.0 / (1 << shift); + const TileMath::TileKey nwChild{7 << shift, 2 << shift, 3 + shift}; + const ElevationTilePyramid::View nwView = pyramid.bestTileFor(nwChild); + QVERIFY(nwView.isValid()); + QCOMPARE(nwView.subWindow, QRectF(0, 0, scale, scale)); + + // SE-most descendant maps to the far corner + const TileMath::TileKey seChild{(8 << shift) - 1, (3 << shift) - 1, 3 + shift}; + const ElevationTilePyramid::View seView = pyramid.bestTileFor(seChild); + QVERIFY(seView.isValid()); + QCOMPARE(seView.subWindow, QRectF(1.0 - scale, 1.0 - scale, scale, scale)); +} + +void ElevationTilePyramidTest::_insertReplacesTile() +{ + ElevationTilePyramid pyramid; + const TileMath::TileKey key{5, 6, 3}; + QVERIFY(pyramid.insertTile(key, makeGrid(1.0f))); + QVERIFY(pyramid.insertTile(key, makeGrid(2.0f))); + + QCOMPARE(pyramid.tileCount(), 1); + QCOMPARE(pyramid.bestTileFor(key).grid->heights.first(), 2.0f); +} + +void ElevationTilePyramidTest::_lruEvictionAtCap() +{ + ElevationTilePyramid pyramid; + for (int i = 0; i < ElevationTilePyramid::kMaxTiles; i++) { + QVERIFY(pyramid.insertTile(TileMath::TileKey{i, 0, 8}, makeGrid(float(i)))); + } + QCOMPARE(pyramid.tileCount(), ElevationTilePyramid::kMaxTiles); + + // Touch the oldest tile so it is no longer least-recently-used + QVERIFY(pyramid.bestTileFor(TileMath::TileKey{0, 0, 8}).isValid()); + + // Inserting past the cap evicts the least-recently-used tile ({1,0,8}) + QVERIFY(pyramid.insertTile(TileMath::TileKey{200, 0, 8}, makeGrid(1.0f))); + QCOMPARE(pyramid.tileCount(), ElevationTilePyramid::kMaxTiles); + QVERIFY(pyramid.hasTile(TileMath::TileKey{0, 0, 8})); + QVERIFY(!pyramid.hasTile(TileMath::TileKey{1, 0, 8})); + QVERIFY(pyramid.hasTile(TileMath::TileKey{200, 0, 8})); + + // Replacing an existing key stays at the cap without evicting others + QVERIFY(pyramid.insertTile(TileMath::TileKey{200, 0, 8}, makeGrid(2.0f))); + QCOMPARE(pyramid.tileCount(), ElevationTilePyramid::kMaxTiles); + QVERIFY(pyramid.hasTile(TileMath::TileKey{2, 0, 8})); +} + +void ElevationTilePyramidTest::_pinnedTilesSurviveEviction() +{ + // Pinned tiles back rendered patches (and the ancestors resolving them): + // evicting them yanks data out from under a visible mesh, so LRU pressure + // must fall on unpinned tiles only — regardless of recency + ElevationTilePyramid pyramid; + for (int i = 0; i < ElevationTilePyramid::kMaxTiles; i++) { + QVERIFY(pyramid.insertTile(TileMath::TileKey{i, 0, 8}, makeGrid(float(i)))); + } + + // Pin the two least-recently-used tiles + pyramid.setPinnedKeys({TileMath::TileKey{0, 0, 8}, TileMath::TileKey{1, 0, 8}}); + + QVERIFY(pyramid.insertTile(TileMath::TileKey{200, 0, 8}, makeGrid(1.0f))); + QVERIFY(pyramid.insertTile(TileMath::TileKey{201, 0, 8}, makeGrid(1.0f))); + QCOMPARE(pyramid.tileCount(), ElevationTilePyramid::kMaxTiles); + QVERIFY(pyramid.hasTile(TileMath::TileKey{0, 0, 8})); + QVERIFY(pyramid.hasTile(TileMath::TileKey{1, 0, 8})); + QVERIFY(!pyramid.hasTile(TileMath::TileKey{2, 0, 8})); + QVERIFY(!pyramid.hasTile(TileMath::TileKey{3, 0, 8})); + + // Unpinning makes them ordinary LRU victims again + pyramid.setPinnedKeys({}); + QVERIFY(pyramid.insertTile(TileMath::TileKey{202, 0, 8}, makeGrid(1.0f))); + QVERIFY(!pyramid.hasTile(TileMath::TileKey{0, 0, 8})); +} + +void ElevationTilePyramidTest::_allPinnedGrowsPastCap() +{ + // kMaxTiles is a soft cap: when every resident tile is pinned, inserts + // must still succeed (grow past the cap) rather than break a rendered + // patch — the alternative is a cliff + ElevationTilePyramid pyramid; + QSet pinned; + for (int i = 0; i < ElevationTilePyramid::kMaxTiles; i++) { + const TileMath::TileKey key{i, 0, 8}; + QVERIFY(pyramid.insertTile(key, makeGrid(1.0f))); + pinned.insert(key); + } + pyramid.setPinnedKeys(pinned); + + QVERIFY(pyramid.insertTile(TileMath::TileKey{200, 0, 8}, makeGrid(1.0f))); + QCOMPARE(pyramid.tileCount(), ElevationTilePyramid::kMaxTiles + 1); + for (int i = 0; i < ElevationTilePyramid::kMaxTiles; i++) { + QVERIFY(pyramid.hasTile(TileMath::TileKey{i, 0, 8})); + } + + // Pressure releases once pins clear: the next insert trims back via LRU + pyramid.setPinnedKeys({}); + QVERIFY(pyramid.insertTile(TileMath::TileKey{201, 0, 8}, makeGrid(1.0f))); + QCOMPARE_LE(pyramid.tileCount(), ElevationTilePyramid::kMaxTiles + 1); +} + +void ElevationTilePyramidTest::_descendantTracking() +{ + ElevationTilePyramid pyramid; + + // {20,24,5} sits under {5,6,3} via {10,12,4}: every ancestor sees it + QVERIFY(pyramid.insertTile(TileMath::TileKey{20, 24, 5}, makeGrid(1.0f))); + QVERIFY(pyramid.hasDescendant(TileMath::TileKey{10, 12, 4})); + QVERIFY(pyramid.hasDescendant(TileMath::TileKey{5, 6, 3})); + QVERIFY(pyramid.hasDescendant(TileMath::TileKey{0, 0, 0})); + QVERIFY(!pyramid.hasDescendant(TileMath::TileKey{6, 6, 3})); // sibling subtree + QVERIFY(!pyramid.hasDescendant(TileMath::TileKey{20, 24, 5})); // strictly deeper only + QVERIFY(!pyramid.hasDescendant(TileMath::TileKey{40, 48, 6})); // child of stored tile + + // Replacing a stored tile leaves the counts unchanged + QVERIFY(pyramid.insertTile(TileMath::TileKey{20, 24, 5}, makeGrid(2.0f))); + QVERIFY(pyramid.hasDescendant(TileMath::TileKey{5, 6, 3})); + + // Evicting the whole {0,0,1} subtree clears its descendant marks: fill + // the cap under {0,0,1}, then displace it all with the {1,1,1} subtree + for (int i = 0; i < ElevationTilePyramid::kMaxTiles; i++) { + QVERIFY(pyramid.insertTile(TileMath::TileKey{i, 0, 8}, makeGrid(1.0f))); + } + for (int i = 0; i < ElevationTilePyramid::kMaxTiles; i++) { + QVERIFY(pyramid.insertTile(TileMath::TileKey{128 + i, 128, 8}, makeGrid(1.0f))); + } + QVERIFY(!pyramid.hasDescendant(TileMath::TileKey{0, 0, 1})); + QVERIFY(pyramid.hasDescendant(TileMath::TileKey{1, 1, 1})); +} + +UT_REGISTER_TEST_LIGHTWEIGHT(ElevationTilePyramidTest, TestLabel::Unit) diff --git a/test/GeoMap/ElevationTilePyramidTest.h b/test/GeoMap/ElevationTilePyramidTest.h new file mode 100644 index 000000000000..0724724b0a96 --- /dev/null +++ b/test/GeoMap/ElevationTilePyramidTest.h @@ -0,0 +1,23 @@ +#pragma once + +#include "UnitTest.h" + +class ElevationTilePyramidTest : public UnitTest +{ + Q_OBJECT + +private slots: + void _emptyPyramidHasNoView(); + void _insertRejectsInvalidGrid(); + void _rejectsInvalidKey(); + void _selfTileWins(); + void _nearestAncestorWins(); + void _noDescendantFallback(); + void _subWindowMath(); + void _subWindowDeepZoom(); + void _insertReplacesTile(); + void _lruEvictionAtCap(); + void _pinnedTilesSurviveEviction(); + void _allPinnedGrowsPastCap(); + void _descendantTracking(); +}; diff --git a/test/GeoMap/GeoMapCameraTest.cc b/test/GeoMap/GeoMapCameraTest.cc index 34c5ac1456fb..13e394e3bbae 100644 --- a/test/GeoMap/GeoMapCameraTest.cc +++ b/test/GeoMap/GeoMapCameraTest.cc @@ -4,6 +4,7 @@ #include #include #include + #include #include "GeoMapCamera.h" diff --git a/test/GeoMap/GeoSceneTest.cc b/test/GeoMap/GeoSceneTest.cc index 9032f65a3000..621bc9d36bd0 100644 --- a/test/GeoMap/GeoSceneTest.cc +++ b/test/GeoMap/GeoSceneTest.cc @@ -1,6 +1,7 @@ #include "GeoSceneTest.h" #include + #include #include "GeoMapCamera.h" diff --git a/test/GeoMap/HeightFieldTest.cc b/test/GeoMap/HeightFieldTest.cc new file mode 100644 index 000000000000..18a79b71264b --- /dev/null +++ b/test/GeoMap/HeightFieldTest.cc @@ -0,0 +1,402 @@ +#include "HeightFieldTest.h" + +#include +#include + +#include +#include + +#include "Benchmarking.h" +#include "HeightField.h" + +using namespace TileMath; + +namespace { + +/// 4x4 gradient grid h(row, col) = col*10 + row*40: linear in both axes, so +/// bilinear sampling must reproduce the plane exactly (uniform grids couldn't +/// catch sub-window or axis-swap errors) +ElevationTilePyramid::Grid gradientGrid() +{ + ElevationTilePyramid::Grid grid; + grid.width = 4; + grid.height = 4; + for (int row = 0; row < 4; row++) { + for (int col = 0; col < 4; col++) { + grid.heights.append(float((col * 10) + (row * 40))); + } + } + return grid; +} + +ElevationTilePyramid::Grid uniformGrid(float height) +{ + ElevationTilePyramid::Grid grid; + grid.width = 4; + grid.height = 4; + grid.heights = QList(16, height); + return grid; +} + +/// Transposed gradient h(row, col) = col*40 + row*10: deliberately disagrees +/// with gradientGrid() everywhere off the diagonal, so a patch clamping its +/// own tile at a shared edge cannot accidentally match its neighbor +ElevationTilePyramid::Grid transposedGradientGrid() +{ + ElevationTilePyramid::Grid grid; + grid.width = 4; + grid.height = 4; + for (int row = 0; row < 4; row++) { + for (int col = 0; col < 4; col++) { + grid.heights.append(float((col * 40) + (row * 10))); + } + } + return grid; +} + +/// World position of a patch vertex, row-major from the NW corner +QPointF vertexWorld(const TileKey& key, int gridSize, int row, int col) +{ + const QPointF corner = tileMinCorner(key); + const double span = tileSpanAtZoom(key.zoom); + return QPointF(corner.x() + (span * col / gridSize), corner.y() + (span * (gridSize - row) / gridSize)); +} + +} // namespace + +void HeightFieldTest::_zeroWhenEmpty() +{ + const HeightField field; + QCOMPARE(field.tileCount(), 0); + QCOMPARE(field.heightAt(QPointF(0, 0)), 0.0); + QCOMPARE(field.heightAt(QPointF(worldSize() / 4, -worldSize() / 4)), 0.0); + + // Patches still mesh on an empty field: full-size, all-zero drape + const QList heights = field.samplePatch(TileKey{5, 6, 3}, 4); + QCOMPARE(heights.size(), 25); + for (const float h : heights) { + QCOMPARE(h, 0.0f); + } +} + +void HeightFieldTest::_invalidRequests() +{ + ignoreLogMessage("GeoMap.HeightField", QtWarningMsg, QRegularExpression("^(insertTile|samplePatch) rejected:")); + + HeightField field; + QVERIFY(!field.insertTile(TileKey{0, 0, -1}, gradientGrid())); + QVERIFY(!field.insertTile(TileKey{0, 0, 0}, ElevationTilePyramid::Grid{})); + QCOMPARE(field.tileCount(), 0); + + QVERIFY(field.samplePatch(TileKey{5, 6, 3}, 0).isEmpty()); + QVERIFY(field.samplePatch(TileKey{5, 6, 3}, -1).isEmpty()); + QVERIFY(field.samplePatch(TileKey{5, 6, 3}, HeightField::kMaxGridSize + 1).isEmpty()); + QVERIFY(field.samplePatch(TileKey{0, 0, -1}, 4).isEmpty()); + QVERIFY(field.samplePatch(TileKey{0, 0, kMaxZoom + 1}, 4).isEmpty()); + QVERIFY(field.samplePatch(TileKey{-3, 0, 3}, 4).isEmpty()); + QVERIFY(field.samplePatch(TileKey{0, 8, 3}, 4).isEmpty()); +} + +void HeightFieldTest::_exactValuesAtSampleCenters() +{ + HeightField field; + QVERIFY(field.insertTile(TileKey{0, 0, 0}, gradientGrid())); + + // Sample center of grid cell (col 2, row 1) in the z0 tile: u=(2+0.5)/4, + // v=(1+0.5)/4 from the NW corner + const double span = worldSize(); + const double half = span / 2.0; + const QPointF world((0.625 * span) - half, half - (0.375 * span)); + QCOMPARE(field.heightAt(world), 60.0); // 2*10 + 1*40 +} + +void HeightFieldTest::_bilinearBetweenCenters() +{ + HeightField field; + QVERIFY(field.insertTile(TileKey{0, 0, 0}, gradientGrid())); + + // Halfway between the (col 1, row 1) and (col 2, row 1) centers: + // px = 1.5, py = 1.0 on the plane col*10 + row*40 + const double span = worldSize(); + const double half = span / 2.0; + const QPointF world((0.5 * span) - half, half - (0.375 * span)); + QCOMPARE(field.heightAt(world), 55.0); +} + +void HeightFieldTest::_clampAtTileEdges() +{ + HeightField field; + QVERIFY(field.insertTile(TileKey{0, 0, 0}, gradientGrid())); + + // Beyond the outermost sample centers the height clamps to the edge value + const double half = worldSize() / 2.0; + QCOMPARE(field.heightAt(QPointF(-half, half)), 0.0); // NW corner: h(0,0) + QCOMPARE(field.heightAt(QPointF(half, -half)), 150.0); // SE corner: h(3,3) +} + +void HeightFieldTest::_finestTileWins() +{ + HeightField field; + QVERIFY(field.insertTile(TileKey{0, 0, 0}, uniformGrid(100.0f))); + QVERIFY(field.insertTile(TileKey{5, 6, 3}, uniformGrid(200.0f))); + + // Inside the fine tile: fine data; outside it: the z0 estimate + const QPointF insideFine = vertexWorld(TileKey{5, 6, 3}, 2, 1, 1); + const QPointF outsideFine = vertexWorld(TileKey{1, 1, 3}, 2, 1, 1); + QCOMPARE(field.heightAt(insideFine), 200.0); + QCOMPARE(field.heightAt(outsideFine), 100.0); +} + +void HeightFieldTest::_pinnedTilesSurviveInsertPressure() +{ + // Tiles backing rendered patches are pinned: no amount of insert + // pressure may evict them, or a visible mesh re-samples coarser data + // next to an intact neighbor — a cliff + HeightField field; + const TileKey fineKey{5, 6, 3}; + QVERIFY(field.insertTile(fineKey, uniformGrid(200.0f))); + field.setPinnedKeys({fineKey}); + + for (int i = 0; i < ElevationTilePyramid::kMaxTiles + 8; i++) { + QVERIFY(field.insertTile(TileKey{i, 100, 8}, uniformGrid(50.0f))); + } + + QVERIFY(field.hasTile(fineKey)); + QCOMPARE(field.heightAt(vertexWorld(fineKey, 2, 1, 1)), 200.0); +} + +void HeightFieldTest::_evictionEmitsRegionChanged() +{ + // Eviction changes what the field answers over the evicted region, so it + // must notify like any other data change: consumers re-mesh to the + // coarser estimate instead of rendering stale fine samples next to + // freshly sampled neighbors. + HeightField field; + QVERIFY(field.insertTile(TileKey{0, 0, 0}, uniformGrid(100.0f))); + const int fineZoom = 8; + for (int i = 0; i < ElevationTilePyramid::kMaxTiles - 1; i++) { + QVERIFY(field.insertTile(TileKey{i, 100, fineZoom}, uniformGrid(200.0f))); + } + QCOMPARE(field.tileCount(), ElevationTilePyramid::kMaxTiles); + + // The next insert evicts the least-recently-used tile (the z0 root): + // its region must be announced alongside the inserted tile's own + QSignalSpy regionSpy(&field, &HeightField::regionChanged); + QVERIFY(field.insertTile(TileKey{200, 100, fineZoom}, uniformGrid(200.0f))); + QCOMPARE(field.tileCount(), ElevationTilePyramid::kMaxTiles); + bool evictedRegionAnnounced = false; + const QPointF probe = vertexWorld(TileKey{50, 50, fineZoom}, 2, 1, 1); // far from any fine tile + for (const QList& args : regionSpy) { + if (args.first().toRectF().contains(probe)) { + evictedRegionAnnounced = true; + } + } + QVERIFY2(evictedRegionAnnounced, "no regionChanged covering the evicted tile's region"); +} + +void HeightFieldTest::_sharedEdgeIdentity() +{ + HeightField field; + QVERIFY(field.insertTile(TileKey{0, 0, 0}, gradientGrid())); + + // Two patches sharing a vertical edge: east column of A and west column + // of B sample identical world positions, so heights must match exactly + const int gridSize = 4; + const QList a = field.samplePatch(TileKey{5, 6, 3}, gridSize); + const QList b = field.samplePatch(TileKey{6, 6, 3}, gridSize); + QCOMPARE(a.size(), 25); + QCOMPARE(b.size(), 25); + for (int row = 0; row <= gridSize; row++) { + QCOMPARE(a[(row * (gridSize + 1)) + gridSize], b[row * (gridSize + 1)]); + } +} + +void HeightFieldTest::_sharedEdgeIdentityAcrossBackingTiles() +{ + HeightField field; + QVERIFY(field.insertTile(TileKey{5, 6, 3}, gradientGrid())); + QVERIFY(field.insertTile(TileKey{6, 6, 3}, transposedGradientGrid())); + + // Adjacent exact tiles with disagreeing data: both patches must resolve + // the shared edge canonically (east side owns it), or A clamps its own + // last pixel column while B reads its first — a crack + const int gridSize = 4; + const QList a = field.samplePatch(TileKey{5, 6, 3}, gridSize); + const QList b = field.samplePatch(TileKey{6, 6, 3}, gridSize); + for (int row = 0; row <= gridSize; row++) { + QCOMPARE_EQ(a[(row * (gridSize + 1)) + gridSize], b[row * (gridSize + 1)]); + } +} + +void HeightFieldTest::_sharedEdgeIdentityFineNextToAncestorBacked() +{ + HeightField field; + QVERIFY(field.insertTile(TileKey{0, 0, 0}, gradientGrid())); + QVERIFY(field.insertTile(TileKey{6, 6, 3}, transposedGradientGrid())); + + // Patch A {5,6,3} is ancestor-backed, neighbor B {6,6,3} has its exact + // tile: their shared edge must still sample one canonical grid + const int gridSize = 4; + const QList a = field.samplePatch(TileKey{5, 6, 3}, gridSize); + const QList b = field.samplePatch(TileKey{6, 6, 3}, gridSize); + for (int row = 0; row <= gridSize; row++) { + QCOMPARE_EQ(a[(row * (gridSize + 1)) + gridSize], b[row * (gridSize + 1)]); + } +} + +void HeightFieldTest::_crossZoomVertexIdentity() +{ + HeightField field; + QVERIFY(field.insertTile(TileKey{0, 0, 0}, gradientGrid())); + + // Child patch {10,12,4} is the NW quarter of {5,6,3} and its half-density + // grid lands exactly on parent vertices (r,c) for r,c in 0..2: coincident + // positions must sample identical heights across the LOD boundary + const QList parent = field.samplePatch(TileKey{5, 6, 3}, 4); + const QList child = field.samplePatch(TileKey{10, 12, 4}, 2); + + for (int row = 0; row <= 2; row++) { + for (int col = 0; col <= 2; col++) { + QCOMPARE(child[(row * 3) + col], parent[(row * 5) + col]); + } + } +} + +void HeightFieldTest::_regionChangedOnInsert() +{ + HeightField field; + QSignalSpy spy(&field, &HeightField::regionChanged); + QVERIFY(spy.isValid()); + + // Fine tile: rect is exactly the tile's world extent + const TileKey fineKey{5, 6, 3}; + QVERIFY(field.insertTile(fineKey, uniformGrid(10.0f))); + QCOMPARE(spy.count(), 1); + const QPointF fineCorner = tileMinCorner(fineKey); + const double fineSpan = tileSpanAtZoom(fineKey.zoom); + QCOMPARE(spy[0][0].toRectF(), QRectF(fineCorner.x(), fineCorner.y(), fineSpan, fineSpan)); + + // Ancestor tile: notifies its full (here: whole-world) area + QVERIFY(field.insertTile(TileKey{0, 0, 0}, uniformGrid(20.0f))); + QCOMPARE(spy.count(), 2); + const double world = worldSize(); + QCOMPARE(spy[1][0].toRectF(), QRectF(-world / 2.0, -world / 2.0, world, world)); + + // Replacing a tile changes heights under the same area: must re-notify + QVERIFY(field.insertTile(fineKey, uniformGrid(30.0f))); + QCOMPARE(spy.count(), 3); + QCOMPARE(spy[2][0].toRectF(), spy[0][0].toRectF()); +} + +void HeightFieldTest::_noRegionChangedOnRejectedInsert() +{ + ignoreLogMessage("GeoMap.HeightField", QtWarningMsg, QRegularExpression("^insertTile rejected:")); + + HeightField field; + QSignalSpy spy(&field, &HeightField::regionChanged); + QVERIFY(spy.isValid()); + + QVERIFY(!field.insertTile(TileKey{0, 0, -1}, uniformGrid(10.0f))); + QVERIFY(!field.insertTile(TileKey{0, 0, 0}, ElevationTilePyramid::Grid{})); + QCOMPARE(spy.count(), 0); +} + +void HeightFieldTest::_heightAtMemoAvoidsRepeatLookups() +{ + HeightField field; + const TileKey key{5, 6, 3}; + QVERIFY(field.insertTile(key, uniformGrid(50.0f))); + + // Memoization gate: the first heightAt resolves the backing tile and + // primes the memo; further queries strictly inside the same tile must + // answer from it without new pyramid lookups + const int gridSize = 16; + QCOMPARE(field.heightAt(vertexWorld(key, gridSize, 1, 1)), 50.0); + const qint64 primed = field.lookupCountForTest(); + for (int row = 1; row < gridSize; row++) { + for (int col = 1; col < gridSize; col++) { + QCOMPARE(field.heightAt(vertexWorld(key, gridSize, row, col)), 50.0); + } + } + QCOMPARE(field.lookupCountForTest(), primed); +} + +void HeightFieldTest::_samplePatchLookupCountGateMixedZooms() +{ + HeightField field; + QVERIFY(field.insertTile(TileKey{5, 6, 3}, uniformGrid(50.0f))); + + // A deeper tile far from the sampled patch must not disable memoization: + // only a descendant of the resolved tile could override its answer + QVERIFY(field.insertTile(TileKey{0, 0, 5}, uniformGrid(80.0f))); + + const qint64 before = field.lookupCountForTest(); + const int gridSize = 16; + const QList heights = field.samplePatch(TileKey{5, 6, 3}, gridSize); + QCOMPARE(heights.size(), (gridSize + 1) * (gridSize + 1)); + QCOMPARE_LE(field.lookupCountForTest() - before, 40); +} + +void HeightFieldTest::_samplePatchLookupsBounded() +{ + // Interior vertices resolve the patch's backing view once; boundary + // vertices resolve canonically by position through a per-tile memo, so + // lookups are bounded by the few tiles the boundary touches (own plus + // up to 8 neighbors), never per-vertex + HeightField field; + QVERIFY(field.insertTile(TileKey{5, 6, 3}, uniformGrid(50.0f))); + + const qint64 before = field.lookupCountForTest(); + const QList heights = field.samplePatch(TileKey{5, 6, 3}, 16); + QCOMPARE(heights.size(), 17 * 17); + QCOMPARE_LE(field.lookupCountForTest() - before, 10); +} + +void HeightFieldTest::_backingKeyFor() +{ + HeightField field; + + // Empty field: nothing backs any key + QVERIFY(!TileMath::isValidKey(field.backingKeyFor(TileKey{5, 6, 3}))); + + // Only an ancestor stored: it backs the descendant query + QVERIFY(field.insertTile(TileKey{0, 0, 0}, uniformGrid(1.0f))); + QCOMPARE(field.backingKeyFor(TileKey{5, 6, 3}), (TileKey{0, 0, 0})); + + // The exact tile wins once stored + QVERIFY(field.insertTile(TileKey{5, 6, 3}, uniformGrid(2.0f))); + QCOMPARE(field.backingKeyFor(TileKey{5, 6, 3}), (TileKey{5, 6, 3})); + + // Siblings are unaffected: still the ancestor + QCOMPARE(field.backingKeyFor(TileKey{6, 6, 3}), (TileKey{0, 0, 0})); +} + +void HeightFieldTest::_memoInvalidatedOnInsert() +{ + HeightField field; + QVERIFY(field.insertTile(TileKey{0, 0, 0}, uniformGrid(100.0f))); + + // First query primes the memoized view on the coarse tile + const QPointF p = vertexWorld(TileKey{5, 6, 3}, 2, 1, 1); + QCOMPARE(field.heightAt(p), 100.0); + + // A finer tile over the same position must win immediately: the insert + // invalidates the memoized coarse view + QVERIFY(field.insertTile(TileKey{5, 6, 3}, uniformGrid(200.0f))); + QCOMPARE(field.heightAt(p), 200.0); +} + +void HeightFieldTest::_samplePatchBenchmark() +{ + HeightField field; + QVERIFY(field.insertTile(TileKey{5, 6, 3}, uniformGrid(50.0f))); + + const TileKey key{5, 6, 3}; + QBENCHMARK + { + const QList heights = field.samplePatch(key, 64); + QT_BENCHMARK_KEEP(heights); + } +} + +UT_REGISTER_TEST_LIGHTWEIGHT(HeightFieldTest, TestLabel::Unit) diff --git a/test/GeoMap/HeightFieldTest.h b/test/GeoMap/HeightFieldTest.h new file mode 100644 index 000000000000..b1d364696c0b --- /dev/null +++ b/test/GeoMap/HeightFieldTest.h @@ -0,0 +1,30 @@ +#pragma once + +#include "UnitTest.h" + +class HeightFieldTest : public UnitTest +{ + Q_OBJECT + +private slots: + void _zeroWhenEmpty(); + void _invalidRequests(); + void _exactValuesAtSampleCenters(); + void _bilinearBetweenCenters(); + void _clampAtTileEdges(); + void _finestTileWins(); + void _pinnedTilesSurviveInsertPressure(); + void _evictionEmitsRegionChanged(); + void _sharedEdgeIdentity(); + void _sharedEdgeIdentityAcrossBackingTiles(); + void _sharedEdgeIdentityFineNextToAncestorBacked(); + void _crossZoomVertexIdentity(); + void _regionChangedOnInsert(); + void _noRegionChangedOnRejectedInsert(); + void _heightAtMemoAvoidsRepeatLookups(); + void _samplePatchLookupCountGateMixedZooms(); + void _samplePatchLookupsBounded(); + void _backingKeyFor(); + void _memoInvalidatedOnInsert(); + void _samplePatchBenchmark(); +}; diff --git a/test/GeoMap/HeightSourceTest.cc b/test/GeoMap/HeightSourceTest.cc index 067e92a89744..c39f60f7e155 100644 --- a/test/GeoMap/HeightSourceTest.cc +++ b/test/GeoMap/HeightSourceTest.cc @@ -1,6 +1,7 @@ #include "HeightSourceTest.h" #include + #include #include "HeightSource.h" diff --git a/test/GeoMap/PatchGeometryTest.cc b/test/GeoMap/PatchGeometryTest.cc index 40a0946ce45b..f6204435f9cb 100644 --- a/test/GeoMap/PatchGeometryTest.cc +++ b/test/GeoMap/PatchGeometryTest.cc @@ -1,7 +1,11 @@ #include "PatchGeometryTest.h" -#include +#include +#include +#include + +#include "HeightField.h" #include "PatchGeometry.h" namespace { @@ -37,6 +41,61 @@ QList rampHeights(int gridSize) return heights; } +ElevationTilePyramid::Grid uniformGrid(float height) +{ + ElevationTilePyramid::Grid grid; + grid.width = 4; + grid.height = 4; + grid.heights = QList(16, height); + return grid; +} + +/// Linear in both axes so bilinear sampling reproduces the plane and edge +/// vertices carry distinct values (uniform grids couldn't catch edge bugs) +ElevationTilePyramid::Grid gradientGrid() +{ + ElevationTilePyramid::Grid grid; + grid.width = 4; + grid.height = 4; + for (int row = 0; row < 4; row++) { + for (int col = 0; col < 4; col++) { + grid.heights.append(float((col * 10) + (row * 40))); + } + } + return grid; +} + +/// Quadratic along y: fine-LOD edge samples deviate from the coarse edge's +/// linear segments, so unstitched edges produce detectable T-junction gaps +/// (a linear field would make stitched and unstitched edges identical) +ElevationTilePyramid::Grid quadraticGrid() +{ + ElevationTilePyramid::Grid grid; + grid.width = 4; + grid.height = 4; + for (int row = 0; row < 4; row++) { + for (int col = 0; col < 4; col++) { + grid.heights.append(float(row * row * 40)); + } + } + return grid; +} + +/// Quadratic along x, linear along y: samples vary along a north/south edge +/// (catches along-axis mistakes) and rows differ (catches edge-side flips) +ElevationTilePyramid::Grid quadraticGridX() +{ + ElevationTilePyramid::Grid grid; + grid.width = 4; + grid.height = 4; + for (int row = 0; row < 4; row++) { + for (int col = 0; col < 4; col++) { + grid.heights.append(float((col * col * 40) + (row * 100))); + } + } + return grid; +} + } // namespace void PatchGeometryTest::_flatMeshLayout() @@ -170,4 +229,418 @@ void PatchGeometryTest::_gridSizeClamped() QCOMPARE(geometry.gridSize(), PatchGeometry::kMaxGridSize); } +void PatchGeometryTest::_sampleFromFieldDisplacesVertices() +{ + HeightField field; + QVERIFY(field.insertTile(TileMath::TileKey{0, 0, 0}, uniformGrid(100.0f))); + + PatchGeometry geometry; + geometry.setGridSize(kGrid); + geometry.setSpan(kSpan); + geometry.setHeightField(&field); + QVERIFY(geometry.sampleFromField(TileMath::TileKey{5, 6, 3})); + + const QByteArray data = geometry.vertexData(); + for (int i = 0; i < gridVertexCount(kGrid); i++) { + QCOMPARE(vertexAt(data, i)[2], 100.0f); + } +} + +void PatchGeometryTest::_sampleFromFieldWithoutFieldWarns() +{ + PatchGeometry geometry; + geometry.setGridSize(kGrid); + + expectLogMessage("GeoMap.PatchGeometry", QtWarningMsg, QRegularExpression("^sampleFromField rejected:")); + QVERIFY(!geometry.sampleFromField(TileMath::TileKey{5, 6, 3})); + verifyExpectedLogMessage(); + + // With a field set, an invalid key is rejected by both layers + HeightField field; + geometry.setHeightField(&field); + expectLogMessage("GeoMap.HeightField", QtWarningMsg, QRegularExpression("^samplePatch rejected:")); + expectLogMessage("GeoMap.PatchGeometry", QtWarningMsg, QRegularExpression("^sampleFromField rejected:")); + QVERIFY(!geometry.sampleFromField(TileMath::TileKey{0, 0, -1})); + verifyExpectedLogMessage(); + verifyExpectedLogMessage(); +} + +void PatchGeometryTest::_adjacentPatchesShareEdgeHeights() +{ + HeightField field; + QVERIFY(field.insertTile(TileMath::TileKey{0, 0, 0}, gradientGrid())); + + PatchGeometry west; + west.setGridSize(kGrid); + west.setSpan(kSpan); + west.setHeightField(&field); + QVERIFY(west.sampleFromField(TileMath::TileKey{5, 6, 3})); + + PatchGeometry east; + east.setGridSize(kGrid); + east.setSpan(kSpan); + east.setHeightField(&field); + QVERIFY(east.sampleFromField(TileMath::TileKey{6, 6, 3})); + + // Core drape invariant: the shared edge samples identical world positions + // in the same field, so the meshed heights must match bit-for-bit + const QByteArray westData = west.vertexData(); + const QByteArray eastData = east.vertexData(); + for (int row = 0; row <= kGrid; row++) { + const float westEdgeZ = vertexAt(westData, (row * (kGrid + 1)) + kGrid)[2]; + const float eastEdgeZ = vertexAt(eastData, row * (kGrid + 1))[2]; + QCOMPARE(westEdgeZ, eastEdgeZ); + } + + // Sanity: the gradient varies along the edge, so the comparison is real + QCOMPARE_NE(vertexAt(westData, kGrid)[2], vertexAt(westData, (kGrid * (kGrid + 1)) + kGrid)[2]); +} + +void PatchGeometryTest::_resampleAfterFieldGainsData() +{ + HeightField field; + PatchGeometry geometry; + geometry.setGridSize(kGrid); + geometry.setSpan(kSpan); + geometry.setHeightField(&field); + QSignalSpy spy(&geometry, &PatchGeometry::heightsChanged); + QVERIFY(spy.isValid()); + + // Empty field: sampling succeeds with the zero best-estimate everywhere + const TileMath::TileKey key{5, 6, 3}; + QVERIFY(geometry.sampleFromField(key)); + const QByteArray flatData = geometry.vertexData(); + for (int i = 0; i < gridVertexCount(kGrid); i++) { + QCOMPARE(vertexAt(flatData, i)[2], 0.0f); + } + const int emptySampleSignals = spy.count(); + + // Field gains data: resampling picks it up and notifies + QVERIFY(field.insertTile(TileMath::TileKey{0, 0, 0}, uniformGrid(100.0f))); + QVERIFY(geometry.sampleFromField(key)); + const QByteArray sampledData = geometry.vertexData(); + for (int i = 0; i < gridVertexCount(kGrid); i++) { + QCOMPARE(vertexAt(sampledData, i)[2], 100.0f); + } + QCOMPARE_GT(spy.count(), emptySampleSignals); +} + +void PatchGeometryTest::_stitchedEdgeLiesOnCoarseSegments() +{ + // Fine patch {11,12,4} is the NE quarter of {5,6,3}: its east edge is the + // upper half of coarse neighbor {6,6,3}'s west edge. The z3 tile under + // the coarse patch is quadratic in y, so the fine edge bows away from the + // coarse edge's linear segments unless stitched. + HeightField field; + QVERIFY(field.insertTile(TileMath::TileKey{0, 0, 0}, uniformGrid(0.0f))); + QVERIFY(field.insertTile(TileMath::TileKey{6, 6, 3}, quadraticGrid())); + + PatchGeometry coarse; + coarse.setGridSize(kGrid); + coarse.setSpan(kSpan); + coarse.setHeightField(&field); + QVERIFY(coarse.sampleFromField(TileMath::TileKey{6, 6, 3})); + + PatchGeometry fine; + fine.setGridSize(kGrid); + fine.setSpan(kSpan); + fine.setHeightField(&field); + fine.setEdgeLodDeltas(0, 0, 0, 1); // east neighbor renders one level coarser + QVERIFY(fine.sampleFromField(TileMath::TileKey{11, 12, 4})); + + const QByteArray fineData = fine.vertexData(); + const QByteArray coarseData = coarse.vertexData(); + const auto fineEastZ = [&](int row) { return vertexAt(fineData, (row * (kGrid + 1)) + kGrid)[2]; }; + const auto coarseWestZ = [&](int row) { return vertexAt(coarseData, row * (kGrid + 1))[2]; }; + + // Fine row r maps to coarse row r/2 (fine edge covers the coarse edge's + // north half). Even rows coincide with coarse vertices (corners included); + // odd rows must lie exactly on the coarse segment between them. + for (int row = 0; row <= kGrid; row += 2) { + QCOMPARE(fineEastZ(row), coarseWestZ(row / 2)); + } + for (int row = 1; row <= kGrid; row += 2) { + const float a = coarseWestZ((row - 1) / 2); + const float b = coarseWestZ((row + 1) / 2); + QCOMPARE(fineEastZ(row), a + ((b - a) * 0.5f)); + } + + // Sanity: the quadratic field actually bows, so stitching changed values + PatchGeometry unstitched; + unstitched.setGridSize(kGrid); + unstitched.setSpan(kSpan); + unstitched.setHeightField(&field); + QVERIFY(unstitched.sampleFromField(TileMath::TileKey{11, 12, 4})); + const QByteArray unstitchedData = unstitched.vertexData(); + QCOMPARE_NE(vertexAt(unstitchedData, (1 * (kGrid + 1)) + kGrid)[2], fineEastZ(1)); +} + +void PatchGeometryTest::_stitchedNorthEdgeLiesOnCoarseRenderedRow() +{ + // Fine patch {11,12,4} sits in the top row of parent {5,6,3}: its north + // neighbor {11,11,4} renders one level coarser as {5,5,3}, whose rendered + // SOUTH row canonically resolves to {5,6,3}'s north row (the south side + // of the shared line wins) — the same backing the fine patch's own north + // samples come from. That backing is quadratic along x, so unstitched + // odd columns bow away from the coarse row's linear segments. + HeightField field; + QVERIFY(field.insertTile(TileMath::TileKey{0, 0, 0}, uniformGrid(0.0f))); + QVERIFY(field.insertTile(TileMath::TileKey{5, 6, 3}, quadraticGridX())); + + PatchGeometry coarse; + coarse.setGridSize(kGrid); + coarse.setSpan(kSpan); + coarse.setHeightField(&field); + QVERIFY(coarse.sampleFromField(TileMath::TileKey{5, 5, 3})); + + PatchGeometry fine; + fine.setGridSize(kGrid); + fine.setSpan(kSpan); + fine.setHeightField(&field); + fine.setEdgeLodDeltas(1, 0, 0, 0); // north neighbor renders one level coarser + QVERIFY(fine.sampleFromField(TileMath::TileKey{11, 12, 4})); + + const QByteArray fineData = fine.vertexData(); + const QByteArray coarseData = coarse.vertexData(); + const auto fineNorthZ = [&](int col) { return vertexAt(fineData, col)[2]; }; + const auto coarseSouthZ = [&](int col) { return vertexAt(coarseData, (kGrid * (kGrid + 1)) + col)[2]; }; + + // Fine col c maps to coarse col 2 + c/2; even cols coincide with coarse + // vertices, odd cols must lie exactly on the coarse segment between them + for (int col = 0; col <= kGrid; col += 2) { + QCOMPARE(fineNorthZ(col), coarseSouthZ(2 + (col / 2))); + } + for (int col = 1; col <= kGrid; col += 2) { + const float a = coarseSouthZ(2 + ((col - 1) / 2)); + const float b = coarseSouthZ(2 + ((col + 1) / 2)); + QCOMPARE(fineNorthZ(col), a + ((b - a) * 0.5f)); + } + + // Sanity: the shared line carries real data, not the flat root + QCOMPARE_NE(fineNorthZ(0), 0.0f); +} + +void PatchGeometryTest::_stitchAppliesToAllFourEdges() +{ + // Heights quadratic in both directions so every edge bows away from its + // linear segments; all four deltas active at once + PatchGeometry geometry; + geometry.setGridSize(kGrid); + geometry.setSpan(kSpan); + QList heights; + for (int row = 0; row <= kGrid; row++) { + for (int col = 0; col <= kGrid; col++) { + heights.append(((row * row) + (col * col)) * 10.0f); + } + } + geometry.setHeights(heights); + geometry.setEdgeLodDeltas(1, 1, 1, 1); + + const QByteArray data = geometry.vertexData(); + const auto z = [&](int row, int col) { return vertexAt(data, (row * (kGrid + 1)) + col)[2]; }; + const auto raw = [&](int row, int col) { return heights.at((row * (kGrid + 1)) + col); }; + const auto lerpMid = [&](float a, float b) { return a + ((b - a) * 0.5f); }; + + for (int i = 1; i < kGrid; i += 2) { + QCOMPARE(z(0, i), lerpMid(raw(0, i - 1), raw(0, i + 1))); // north + QCOMPARE(z(kGrid, i), lerpMid(raw(kGrid, i - 1), raw(kGrid, i + 1))); // south + QCOMPARE(z(i, 0), lerpMid(raw(i - 1, 0), raw(i + 1, 0))); // west + QCOMPARE(z(i, kGrid), lerpMid(raw(i - 1, kGrid), raw(i + 1, kGrid))); // east + QCOMPARE_NE(z(0, i), raw(0, i)); // sanity: stitching actually moved it + } + + // Interior vertices are untouched + QCOMPARE(z(1, 1), raw(1, 1)); + QCOMPARE(z(2, 3), raw(2, 3)); +} + +void PatchGeometryTest::_stitchInvalidDeltaWarns() +{ + PatchGeometry geometry; + geometry.setGridSize(kGrid); + + expectLogMessage("GeoMap.PatchGeometry", QtWarningMsg, QRegularExpression("^setEdgeLodDeltas rejected:")); + geometry.setEdgeLodDeltas(-1, 0, 0, 0); // negative delta + verifyExpectedLogMessage(); + + // 2^3 = 8 does not divide gridSize 4: coincident vertices would not exist + expectLogMessage("GeoMap.PatchGeometry", QtWarningMsg, QRegularExpression("^setEdgeLodDeltas rejected:")); + geometry.setEdgeLodDeltas(0, 0, 0, 3); + verifyExpectedLogMessage(); + + // Rejected calls leave the mesh unconstrained: ramp heights pass through + geometry.setHeights(rampHeights(kGrid)); + const QByteArray data = geometry.vertexData(); + for (int row = 0; row <= kGrid; row++) { + QCOMPARE(vertexAt(data, (row * (kGrid + 1)) + kGrid)[2], 10.0f * row); + } +} + +void PatchGeometryTest::_declarativeHeightsStitchLikeSampleFromField() +{ + // The QML render path binds heights (from the patch model) and edge + // deltas as independent declarative properties — it never calls + // sampleFromField. The resulting mesh must match the imperative + // sampleFromField path bit for bit: the model heights already carry + // canonical edge values, so no field or key context is needed. + HeightField field; + QVERIFY(field.insertTile(TileMath::TileKey{0, 0, 0}, uniformGrid(0.0f))); + QVERIFY(field.insertTile(TileMath::TileKey{6, 6, 3}, quadraticGrid())); + + PatchGeometry reference; + reference.setGridSize(kGrid); + reference.setSpan(kSpan); + reference.setHeightField(&field); + reference.setEdgeLodDeltas(0, 0, 0, 1); + QVERIFY(reference.sampleFromField(TileMath::TileKey{11, 12, 4})); + + PatchGeometry declarative; + declarative.setGridSize(kGrid); + declarative.setSpan(kSpan); + declarative.setHeights(field.samplePatch(TileMath::TileKey{11, 12, 4}, kGrid)); + declarative.setEdgeLodDeltas(0, 0, 0, 1); + QCOMPARE(declarative.vertexData(), reference.vertexData()); +} + +void PatchGeometryTest::_gridSizeChangeResetsInvalidDeltas() +{ + PatchGeometry geometry; + geometry.setGridSize(8); + geometry.setEdgeLodDeltas(0, 0, 0, 3); // valid: 2^3 divides 8 + + // Shrinking the grid strands the delta: it must be reset with a warning + expectLogMessage("GeoMap.PatchGeometry", QtWarningMsg, QRegularExpression("^setGridSize reset edge LOD deltas")); + geometry.setGridSize(kGrid); + verifyExpectedLogMessage(); + + // A still-valid delta survives a grid size change + geometry.setEdgeLodDeltas(0, 0, 0, 1); + geometry.setGridSize(8); // 2^1 divides 8: no reset, no warning + QList heights; + for (int row = 0; row <= 8; row++) { + for (int col = 0; col <= 8; col++) { + heights.append(float(row * row)); + } + } + geometry.setHeights(heights); + const QByteArray data = geometry.vertexData(); + const auto eastZ = [&](int row) { return vertexAt(data, (row * 9) + 8)[2]; }; + QCOMPARE(eastZ(1), eastZ(0) + ((eastZ(2) - eastZ(0)) * 0.5f)); // stitched, not raw 1.0f +} + +void PatchGeometryTest::_edgeLodDeltasListProperty() +{ + PatchGeometry viaList; + viaList.setGridSize(8); + viaList.setHeights(rampHeights(8)); + QSignalSpy changeSpy(&viaList, &PatchGeometry::edgeLodDeltasChanged); + + // The QML-bindable list form matches the 4-arg setter exactly + viaList.setEdgeLodDeltas(QList{0, 1, 0, 1}); + QCOMPARE(viaList.edgeLodDeltas(), (QList{0, 1, 0, 1})); + QCOMPARE(changeSpy.count(), 1); + + PatchGeometry viaArgs; + viaArgs.setGridSize(8); + viaArgs.setHeights(rampHeights(8)); + viaArgs.setEdgeLodDeltas(0, 1, 0, 1); + QCOMPARE(viaList.vertexData(), viaArgs.vertexData()); + + // Wrong-size list is rejected with a warning and changes nothing + expectLogMessage("GeoMap.PatchGeometry", QtWarningMsg, QRegularExpression("^setEdgeLodDeltas rejected:")); + viaList.setEdgeLodDeltas(QList{1, 1, 1}); + verifyExpectedLogMessage(); + QCOMPARE(viaList.edgeLodDeltas(), (QList{0, 1, 0, 1})); + QCOMPARE(changeSpy.count(), 1); +} + +void PatchGeometryTest::_replacingLiveFieldDisconnectsOld() +{ + // Switching from one live field to another must disconnect the old one, + // including its destroyed handler — otherwise deleting the abandoned + // field would null out the geometry's now-current field. + auto oldField = std::make_unique(); + QVERIFY(oldField->insertTile(TileMath::TileKey{0, 0, 0}, uniformGrid(0.0f))); + HeightField newField; + QVERIFY(newField.insertTile(TileMath::TileKey{0, 0, 0}, uniformGrid(0.0f))); + + PatchGeometry geometry; + geometry.setGridSize(kGrid); + geometry.setSpan(kSpan); + geometry.setHeightField(oldField.get()); + geometry.setHeightField(&newField); + QCOMPARE(geometry.heightField(), &newField); + + // Deleting the abandoned field must leave the current one in place + oldField.reset(); + QCOMPARE(geometry.heightField(), &newField); + + // The current field is still usable for sampling + QVERIFY(newField.insertTile(TileMath::TileKey{6, 6, 3}, quadraticGrid())); + geometry.setEdgeLodDeltas(0, 0, 0, 1); + QVERIFY(geometry.sampleFromField(TileMath::TileKey{11, 12, 4})); +} + +void PatchGeometryTest::_fieldSetterIgnoresSameValue() +{ + HeightField field; + QVERIFY(field.insertTile(TileMath::TileKey{0, 0, 0}, uniformGrid(0.0f))); + + PatchGeometry geometry; + geometry.setHeightField(&field); + + QSignalSpy fieldSpy(&geometry, &PatchGeometry::heightFieldChanged); + QVERIFY(fieldSpy.isValid()); + + // Re-setting the current value is a no-op: no notify signal + geometry.setHeightField(&field); + QCOMPARE(fieldSpy.count(), 0); + + // A real change still fires + geometry.setHeightField(nullptr); + QCOMPARE(fieldSpy.count(), 1); +} + +void PatchGeometryTest::_skirtDepthScalesWithCoarsestNeighbor() +{ + // Skirts hide the seam against a coarser LOD neighbor. That seam grows + // with the neighbor's cell size, so the skirt must deepen with the + // coarsest constraining delta — doubling per level, tracking the coarser + // level's geometric error. An unconstrained patch + // keeps the base depth (span x kSkirtDepthFraction). + const auto skirtDepthOf = [](const PatchGeometry& g) { + // NW corner (index 0) sits at z=0 for a flat patch; the first skirt + // vertex duplicates it dropped by the skirt depth + const QByteArray data = g.vertexData(); + return vertexAt(data, 0)[2] - vertexAt(data, gridVertexCount(kGrid))[2]; + }; + + const float base = kSpan * static_cast(PatchGeometry::kSkirtDepthFraction); + + PatchGeometry unconstrained; + unconstrained.setGridSize(kGrid); + unconstrained.setSpan(kSpan); + QCOMPARE(skirtDepthOf(unconstrained), base); + + PatchGeometry oneCoarser; + oneCoarser.setGridSize(kGrid); + oneCoarser.setSpan(kSpan); + oneCoarser.setEdgeLodDeltas(1, 0, 0, 0); // north neighbor one level coarser + QCOMPARE(skirtDepthOf(oneCoarser), base * 2.0f); + + PatchGeometry twoCoarser; + twoCoarser.setGridSize(kGrid); + twoCoarser.setSpan(kSpan); + twoCoarser.setEdgeLodDeltas(0, 0, 0, 2); // east neighbor two levels coarser (2^2 | 4) + QCOMPARE(skirtDepthOf(twoCoarser), base * 4.0f); + + // Multiple non-zero edges: the *max* delta drives scaling, not sum or + // first-non-zero (delta 2 needs 2^2 | kGrid, so max delta is 2 here) + PatchGeometry mixed; + mixed.setGridSize(kGrid); + mixed.setSpan(kSpan); + mixed.setEdgeLodDeltas(1, 2, 0, 0); // max 2 -> base*4 (sum 3 would be base*8, first-nonzero base*2) + QCOMPARE(skirtDepthOf(mixed), base * 4.0f); +} + UT_REGISTER_TEST(PatchGeometryTest, TestLabel::Unit) diff --git a/test/GeoMap/PatchGeometryTest.h b/test/GeoMap/PatchGeometryTest.h index fc06c988b8a8..dc7c2385f81e 100644 --- a/test/GeoMap/PatchGeometryTest.h +++ b/test/GeoMap/PatchGeometryTest.h @@ -15,4 +15,18 @@ private slots: void _flatNormalsPointUp(); void _boundsIncludeSkirt(); void _gridSizeClamped(); + void _sampleFromFieldDisplacesVertices(); + void _sampleFromFieldWithoutFieldWarns(); + void _adjacentPatchesShareEdgeHeights(); + void _resampleAfterFieldGainsData(); + void _stitchedEdgeLiesOnCoarseSegments(); + void _stitchedNorthEdgeLiesOnCoarseRenderedRow(); + void _stitchAppliesToAllFourEdges(); + void _stitchInvalidDeltaWarns(); + void _declarativeHeightsStitchLikeSampleFromField(); + void _gridSizeChangeResetsInvalidDeltas(); + void _edgeLodDeltasListProperty(); + void _replacingLiveFieldDisconnectsOld(); + void _fieldSetterIgnoresSameValue(); + void _skirtDepthScalesWithCoarsestNeighbor(); }; diff --git a/test/GeoMap/SurfaceModelTest.cc b/test/GeoMap/SurfaceModelTest.cc index 157bc2610d28..f85ee9397803 100644 --- a/test/GeoMap/SurfaceModelTest.cc +++ b/test/GeoMap/SurfaceModelTest.cc @@ -1,13 +1,21 @@ #include "SurfaceModelTest.h" +#include #include -#include +#include +#include #include #include + #include +#include +#include +#include "ElevationTilePyramid.h" #include "GeoMapCamera.h" +#include "HeightField.h" #include "HeightSource.h" +#include "PatchGeometry.h" #include "SurfaceModel.h" #include "TileMath.h" @@ -16,132 +24,334 @@ namespace { const QGeoCoordinate kCenter(47.3977419, 8.5455938); constexpr QSizeF kViewport(800, 600); -/// Fails every request asynchronously, mimicking an unreachable terrain backend -class FailingHeightSource : public HeightSource +/// Constant-height decoded tile grid +ElevationTilePyramid::Grid constantGrid(float height, int size = 8) +{ + ElevationTilePyramid::Grid grid; + grid.width = size; + grid.height = size; + grid.heights = QList(qsizetype(size) * size, height); + return grid; +} + +QRectF tileRect(const TileMath::TileKey& key) +{ + const double span = TileMath::tileSpanAtZoom(key.zoom); + return QRectF(TileMath::tileMinCorner(key), QSizeF(span, span)); +} + +/// Inflated-rect region contact, matching the model's re-mesh predicate: +/// patch edge vertices exactly on the region boundary sample the changed +/// data, but QRectF::intersects is false for edge-only contact +bool touchesRegion(const TileMath::TileKey& key, const QRectF& region) +{ + const QRectF rect = tileRect(key); + const double margin = rect.width() * 1e-6; + return rect.marginsAdded(QMarginsF(margin, margin, margin, margin)).intersects(region); +} + +/// Flat source that records coverage requests (the field stays empty) +class RecordingCoverageSource : public FlatHeightSource { public: - using HeightSource::HeightSource; + using FlatHeightSource::FlatHeightSource; - int requestPatchHeights(const TileMath::TileKey& key, int gridSize) override + bool requestTile(const TileMath::TileKey& key) override { - Q_UNUSED(key); - Q_UNUSED(gridSize); - const int requestId = _nextRequestId(); - QMetaObject::invokeMethod( - this, [this, requestId] { emit patchHeightsFailed(requestId); }, Qt::QueuedConnection); - return requestId; + requested.append(key); + return false; } - void cancelRequest(int requestId) override { Q_UNUSED(requestId); } + QList requested; }; -/// Fails the first failCount requests, then delivers flat zeros: a terrain -/// backend recovering from transient fetch errors. -1 fails forever. -class RecoveringHeightSource : public HeightSource +/// Multi-octave analytic terrain rough at every patch scale, so any +/// unconstrained LOD edge shows a real height mismatch +class RoughHeightSource : public ProceduralHeightSource { public: - explicit RecoveringHeightSource(int failCount, QObject* parent = nullptr) - : HeightSource(parent), _failuresRemaining(failCount) - {} + using ProceduralHeightSource::ProceduralHeightSource; - void setFailuresRemaining(int n) { _failuresRemaining = n; } + /// Lipschitz bound on the octave sum: |∂f/∂x| ≤ Σ amplitude/xScale = + /// 300/20000 + 150/1900 + 80/280 + 40/61 ≈ 1.04, |∂f/∂y| ≈ 1.01, + /// |∇f| ≤ √(1.04² + 1.01²) < 1.45 + static constexpr double kMaxGradient = 1.45; - int requestPatchHeights(const TileMath::TileKey& key, int gridSize) override + /// Total possible height swing: 2 × Σ octave amplitudes + static constexpr double kHeightRange = 2.0 * (300.0 + 150.0 + 80.0 + 40.0); + +protected: + float heightAtWorld(const QPointF& world) const override { - Q_UNUSED(key); - const int requestId = _nextRequestId(); - if (_failuresRemaining != 0) { - if (_failuresRemaining > 0) { - _failuresRemaining--; - } - QMetaObject::invokeMethod( - this, [this, requestId] { emit patchHeightsFailed(requestId); }, Qt::QueuedConnection); - } else { - const QList heights((gridSize + 1) * (gridSize + 1), 0.0f); - QMetaObject::invokeMethod( - this, [this, requestId, heights] { emit patchHeightsReady(requestId, heights); }, Qt::QueuedConnection); - } - return requestId; + const double x = world.x(); + const double y = world.y(); + return static_cast((300.0 * std::sin(x / 20000.0) * std::cos(y / 26000.0)) + + (150.0 * std::sin(x / 1900.0) * std::cos(y / 1300.0)) + + (80.0 * std::sin((x / 280.0) + 1.0) * std::cos(y / 240.0)) + + (40.0 * std::sin(x / 61.0) * std::cos(y / 73.0))); } +}; - void cancelRequest(int requestId) override { Q_UNUSED(requestId); } +constexpr int kFloatsPerVertex = 8; // position 3, normal 3, uv 2 -private: - int _failuresRemaining = 0; -}; +/// z of grid vertex (row, col) in a built PatchGeometry mesh (grid vertices +/// precede skirt vertices, row-major from the north-west corner) +double meshZ(const QByteArray& vertexData, int row, int col) +{ + const int vpe = SurfaceModel::kGridSize + 1; + const float* vertex = + reinterpret_cast(vertexData.constData()) + (qsizetype((row * vpe) + col) * kFloatsPerVertex); + return double(vertex[2]); +} -/// Holds every request until deliverAll(): pins pending patches the way a -/// slow terrain backend does, so tests can accumulate retiring covers -class HoldingHeightSource : public HeightSource +/// Rendered height along a mesh edge at fractional grid coordinate t: the +/// mesh interpolates linearly between adjacent edge vertices. edge is +/// 'N','S','W','E'. +double meshEdgeHeight(const QByteArray& vertexData, QChar edge, double t) { -public: - using HeightSource::HeightSource; + constexpr int kGrid = SurfaceModel::kGridSize; + const auto vertex = [&](int idx) { + switch (edge.unicode()) { + case u'N': + return meshZ(vertexData, 0, idx); + case u'S': + return meshZ(vertexData, kGrid, idx); + case u'W': + return meshZ(vertexData, idx, 0); + default: + return meshZ(vertexData, idx, kGrid); + } + }; + t = std::clamp(t, 0.0, double(kGrid)); + const int i0 = std::min(int(std::floor(t)), kGrid - 1); + const double frac = t - i0; + return (vertex(i0) * (1.0 - frac)) + (vertex(i0 + 1) * frac); +} - int requestPatchHeights(const TileMath::TileKey& key, int gridSize) override - { - Q_UNUSED(key); - const int requestId = _nextRequestId(); - _held.append(qMakePair(requestId, (gridSize + 1) * (gridSize + 1))); - return requestId; +/// Largest rendered mismatch between two patch meshes along the world +/// interval [lo, hi] of their shared edge line (horizontal: runs east-west) +double segmentMaxStep(const QByteArray& meshA, QChar edgeA, const QRectF& rectA, const QByteArray& meshB, QChar edgeB, + const QRectF& rectB, bool horizontal, double lo, double hi) +{ + constexpr int kGrid = SurfaceModel::kGridSize; + constexpr int kSamples = 65; // denser than any vertex spacing involved + double maxStep = 0.0; + for (int i = 0; i <= kSamples; i++) { + const double pos = lo + ((hi - lo) * i / kSamples); + // Fractional grid coordinate of pos on each patch's edge. Slippy y + // grows south while world y grows north: row/col index t runs from + // the NW corner, so along x t follows +x, along y t follows -y. + const double tA = horizontal ? ((pos - rectA.left()) / rectA.width()) * kGrid + : ((rectA.bottom() - pos) / rectA.height()) * kGrid; + const double tB = horizontal ? ((pos - rectB.left()) / rectB.width()) * kGrid + : ((rectB.bottom() - pos) / rectB.height()) * kGrid; + maxStep = std::max(maxStep, std::abs(meshEdgeHeight(meshA, edgeA, tA) - meshEdgeHeight(meshB, edgeB, tB))); } + return maxStep; +} - void cancelRequest(int requestId) override - { - for (int i = 0; i < _held.count(); i++) { - if (_held.at(i).first == requestId) { - _held.removeAt(i); - return; +int maxZoomOf(const QList& patches) +{ + int maxZoom = 0; + for (const SurfaceModel::Patch& patch : patches) { + maxZoom = std::max(maxZoom, patch.key.zoom); + } + return maxZoom; +} + +/// Zoom of the finest resident patch containing the world point, or -1 +int finestResidentZoomAt(const QList& patches, const QPointF& world) +{ + int zoom = -1; + for (const SurfaceModel::Patch& patch : patches) { + if (tileRect(patch.key).contains(world)) { + zoom = std::max(zoom, patch.key.zoom); + } + } + return zoom; +} + +/// Screen-grid coverage check independent of the model's own visibility +/// estimate: every pick-ray ground hit within the coverage contract range +/// must be inside a resident patch. Returns a failure description or empty. +QString coverageHole(const SurfaceModel& model, const GeoMapCamera& camera) +{ + constexpr int kCols = 13; + constexpr int kRows = 9; + // Slightly inside the full contract: the range cap itself is exact only + // along sampled rays + const double demandRange = SurfaceModel::kMaxRangeMultiplier * camera.distance() * 0.9; + const QPointF cameraGround = camera.cameraGroundPosition(); + const QList patches = model.patches(); + for (int row = 0; row < kRows; row++) { + for (int col = 0; col < kCols; col++) { + const QPointF screenPos((camera.viewportSize().width() * col) / (kCols - 1), + (camera.viewportSize().height() * row) / (kRows - 1)); + const auto ground = camera.screenToGround(screenPos); + if (!ground) { + continue; // sky + } + const double range = std::hypot(ground->x() - cameraGround.x(), ground->y() - cameraGround.y()); + if (range > demandRange) { + continue; // beyond the coverage contract + } + if (finestResidentZoomAt(patches, *ground) < 0) { + return QStringLiteral("hole at screen (%1,%2) world (%3,%4) range %5 (tilt %6 dist %7 heading %8)") + .arg(screenPos.x()) + .arg(screenPos.y()) + .arg(ground->x()) + .arg(ground->y()) + .arg(range) + .arg(camera.tilt()) + .arg(camera.distance()) + .arg(camera.heading()); } } } + return {}; +} - void deliverAll() - { - const auto held = _held; - _held.clear(); - for (const auto& request : held) { - emit patchHeightsReady(request.first, QList(request.second, 0.0f)); +} // namespace + +void SurfaceModelTest::_patchRendersEstimateImmediately() +{ + GeoMapCamera camera; + FlatHeightSource source; + HeightField field; + QVERIFY(field.insertTile(TileMath::TileKey{0, 0, 0}, constantGrid(100.0f))); + SurfaceModel model(&camera, &source, &field); + + camera.setViewportSize(kViewport); + camera.lookAt(kCenter, 0, 0, 2000); + model.drainUpdates(); + + // No event-loop wait: every patch must carry the field estimate the + // moment it is added (never flat-zero-with-hole) + QCOMPARE_GT(model.patchCount(), 0); + const int expectedHeights = (SurfaceModel::kGridSize + 1) * (SurfaceModel::kGridSize + 1); + for (const SurfaceModel::Patch& patch : model.patches()) { + QVERIFY(patch.ready); + QCOMPARE(patch.heights.count(), expectedHeights); + QCOMPARE(patch.heights.first(), 100.0f); + } +} + +void SurfaceModelTest::_patchesAreViewsOfField() +{ + GeoMapCamera camera; + FlatHeightSource source; + HeightField field; + ElevationTilePyramid::Grid grid; + grid.width = 8; + grid.height = 8; + for (int row = 0; row < 8; row++) { + for (int col = 0; col < 8; col++) { + grid.heights.append(float((row * 31) + (col * 7))); } } + QVERIFY(field.insertTile(TileMath::TileKey{0, 0, 0}, std::move(grid))); + SurfaceModel model(&camera, &source, &field); -private: - QList> _held; -}; + camera.setViewportSize(kViewport); + camera.lookAt(kCenter, 0, 0, 2000); + model.drainUpdates(); -int maxZoomOf(const QList& patches) + // Patches are views: their heights are exactly the field's samples, so + // any two patches asking about the same world position always agree + QCOMPARE_GT(model.patchCount(), 0); + for (const SurfaceModel::Patch& patch : model.patches()) { + QCOMPARE(patch.heights, field.samplePatch(patch.key, SurfaceModel::kGridSize)); + } +} + +void SurfaceModelTest::_reMeshOnDataArrival() { - int maxZoom = 0; - for (const SurfaceModel::Patch& patch : patches) { - maxZoom = std::max(maxZoom, patch.key.zoom); + GeoMapCamera camera; + FlatHeightSource source; + HeightField field; + SurfaceModel model(&camera, &source, &field); + + camera.setViewportSize(kViewport); + camera.lookAt(kCenter, 0, 0, 2000); + model.drainUpdates(); + QCOMPARE_GT(model.patchCount(), 0); + for (const SurfaceModel::Patch& patch : model.patches()) { + QCOMPARE(patch.heights.first(), 0.0f); // empty field: flat estimate + } + + QSignalSpy meshSpy(&model, &SurfaceModel::patchMeshChanged); + QVERIFY(field.insertTile(TileMath::TileKey{0, 0, 0}, constantGrid(100.0f))); + + // Data arrival re-meshes synchronously: the patches lift, no stale flats + QCOMPARE_GT(meshSpy.count(), 0); + for (const SurfaceModel::Patch& patch : model.patches()) { + QCOMPARE(patch.heights.first(), 100.0f); } - return maxZoom; } -/// Constant tall terrain everywhere -class TallHeightSource : public ProceduralHeightSource +void SurfaceModelTest::_reMeshScopeGate() { -public: - explicit TallHeightSource(float height, QObject* parent = nullptr) : ProceduralHeightSource(parent), _height(height) - {} + GeoMapCamera camera; + FlatHeightSource source; + HeightField field; + SurfaceModel model(&camera, &source, &field); -protected: - float heightAtWorld(const QPointF& world) const override - { - Q_UNUSED(world); - return _height; + camera.setViewportSize(kViewport); + camera.lookAt(kCenter, 0, 45, 2000); + model.drainUpdates(); + QCOMPARE_GT(model.patchCount(), 8); + + // A fine tile at the camera position changes a small region only + const int fineZoom = maxZoomOf(model.patches()); + const TileMath::TileKey fineKey = TileMath::tileForWorld(TileMath::geoToWorld(kCenter), fineZoom); + const QRectF region = tileRect(fineKey); + + QSignalSpy meshSpy(&model, &SurfaceModel::patchMeshChanged); + QVERIFY(field.insertTile(fineKey, constantGrid(50.0f))); + + // Gate: exactly the patches touching the changed region re-mesh, once each + QSet remeshed; + for (const QList& args : meshSpy) { + remeshed.insert(args.first().value()); } + int touching = 0; + for (const SurfaceModel::Patch& patch : model.patches()) { + if (touchesRegion(patch.key, region)) { + QVERIFY2(remeshed.contains(patch.key), "patch touching the changed region was not re-meshed"); + touching++; + } else { + QVERIFY2(!remeshed.contains(patch.key), "patch outside the changed region was re-meshed"); + } + } + QCOMPARE_GT(touching, 0); + QCOMPARE_LT(touching, model.patchCount()); + QCOMPARE(meshSpy.count(), touching); +} -private: - const float _height; -}; +void SurfaceModelTest::_requestsTileCoverage() +{ + GeoMapCamera camera; + RecordingCoverageSource source; + HeightField field; + source.setHeightField(&field); + SurfaceModel model(&camera, &source, &field); -} // namespace + camera.setViewportSize(kViewport); + camera.lookAt(kCenter, 0, 0, 2000); + model.drainUpdates(); + + QCOMPARE_GT(model.patchCount(), 0); + for (const SurfaceModel::Patch& patch : model.patches()) { + QVERIFY2(source.requested.contains(patch.key), "no tile coverage requested for resident patch"); + } +} void SurfaceModelTest::_noViewportNoPatches() { GeoMapCamera camera; FlatHeightSource source; - SurfaceModel model(&camera, &source); + HeightField field; + SurfaceModel model(&camera, &source, &field); model.update(); QCOMPARE(model.patchCount(), 0); @@ -151,7 +361,8 @@ void SurfaceModelTest::_unpositionedCameraNoPatches() { GeoMapCamera camera; FlatHeightSource source; - SurfaceModel model(&camera, &source); + HeightField field; + SurfaceModel model(&camera, &source, &field); // A sized viewport alone must not build patches: the camera still holds // the null-island default pose (doomed terrain fetches would follow) @@ -168,7 +379,8 @@ void SurfaceModelTest::_coarseWhenFar() { GeoMapCamera camera; FlatHeightSource source; - SurfaceModel model(&camera, &source); + HeightField field; + SurfaceModel model(&camera, &source, &field); camera.setViewportSize(kViewport); camera.lookAt(kCenter, 0, 0, GeoMapCamera::kMaxDistance); @@ -183,7 +395,8 @@ void SurfaceModelTest::_refinesWhenNear() { GeoMapCamera camera; FlatHeightSource source; - SurfaceModel model(&camera, &source); + HeightField field; + SurfaceModel model(&camera, &source, &field); camera.setViewportSize(kViewport); camera.lookAt(kCenter, 0, 0, GeoMapCamera::kMaxDistance); @@ -202,7 +415,8 @@ void SurfaceModelTest::_patchCountBounded() { GeoMapCamera camera; FlatHeightSource source; - SurfaceModel model(&camera, &source); + HeightField field; + SurfaceModel model(&camera, &source, &field); camera.setViewportSize(kViewport); for (double distance : {50.0, 2000.0, 100000.0, GeoMapCamera::kMaxDistance}) { @@ -218,7 +432,8 @@ void SurfaceModelTest::_budgetExhaustedKeepsCoverage() { GeoMapCamera camera; FlatHeightSource source; - SurfaceModel model(&camera, &source); + HeightField field; + SurfaceModel model(&camera, &source, &field); // A huge viewport shrinks meters-per-pixel so refinement demand far // exceeds the patch budget @@ -263,38 +478,16 @@ void SurfaceModelTest::_budgetExhaustedKeepsCoverage() } } -void SurfaceModelTest::_heightsArrive() -{ - GeoMapCamera camera; - FlatHeightSource source; - SurfaceModel model(&camera, &source); - QSignalSpy readySpy(&model, &SurfaceModel::patchReady); - - camera.setViewportSize(kViewport); - camera.lookAt(kCenter, 0, 0, 2000); - model.drainUpdates(); - - QCOMPARE_GT(model.patchCount(), 0); - QTRY_COMPARE_WITH_TIMEOUT(model.pendingCount(), 0, 5000); - QCOMPARE(readySpy.count(), model.patchCount()); - - const int expectedHeights = (SurfaceModel::kGridSize + 1) * (SurfaceModel::kGridSize + 1); - for (const SurfaceModel::Patch& patch : model.patches()) { - QVERIFY(patch.ready); - QCOMPARE(patch.heights.count(), expectedHeights); - } -} - void SurfaceModelTest::_diffOnMove() { GeoMapCamera camera; FlatHeightSource source; - SurfaceModel model(&camera, &source); + HeightField field; + SurfaceModel model(&camera, &source, &field); camera.setViewportSize(kViewport); camera.lookAt(kCenter, 0, 0, 2000); model.drainUpdates(); - QTRY_COMPARE_WITH_TIMEOUT(model.pendingCount(), 0, 5000); QSignalSpy addedSpy(&model, &SurfaceModel::patchAdded); QSignalSpy removedSpy(&model, &SurfaceModel::patchRemoved); @@ -304,18 +497,14 @@ void SurfaceModelTest::_diffOnMove() model.drainUpdates(); QCOMPARE_GT(addedSpy.count(), 0); QCOMPARE_GT(removedSpy.count(), 0); - - QTRY_COMPARE_WITH_TIMEOUT(model.pendingCount(), 0, 5000); - for (const SurfaceModel::Patch& patch : model.patches()) { - QVERIFY(patch.ready); - } } void SurfaceModelTest::_noChurnOnIdenticalUpdate() { GeoMapCamera camera; FlatHeightSource source; - SurfaceModel model(&camera, &source); + HeightField field; + SurfaceModel model(&camera, &source, &field); camera.setViewportSize(kViewport); camera.lookAt(kCenter, 0, 0, 2000); @@ -332,7 +521,8 @@ void SurfaceModelTest::_cullsInvisibleRegion() { GeoMapCamera camera; FlatHeightSource source; - SurfaceModel model(&camera, &source); + HeightField field; + SurfaceModel model(&camera, &source, &field); camera.setViewportSize(kViewport); camera.lookAt(kCenter, 0, 0, 2000); @@ -348,292 +538,462 @@ void SurfaceModelTest::_cullsInvisibleRegion() } } -void SurfaceModelTest::_failedHeightsDegradeToFlat() +void SurfaceModelTest::_coverageMaintainedDuringLodChurn() { GeoMapCamera camera; - FailingHeightSource source; - SurfaceModel model(&camera, &source); - model.setHeightRetryDelayMs(10); + FlatHeightSource source; + HeightField field; + SurfaceModel model(&camera, &source, &field); camera.setViewportSize(kViewport); - camera.lookAt(kCenter, 0, 0, 2000); + camera.lookAt(kCenter, 0, 0, GeoMapCamera::kMaxDistance); model.drainUpdates(); - - // Every patch burns through its retries before degrading to flat QCOMPARE_GT(model.patchCount(), 0); - QTRY_COMPARE_WITH_TIMEOUT(model.pendingCount(), 0, 5000); - for (const SurfaceModel::Patch& patch : model.patches()) { - QVERIFY(patch.ready); - QVERIFY(patch.heights.isEmpty()); // empty = flat fallback - } + + // Refine toward the ground one capped pass at a time: after every single + // pass the resident set must still cover the visible region — a coarse + // patch may only go once its replacements are resident + camera.lookAt(kCenter, 0, 0, 2000); + const double maxRange = camera.distance() * SurfaceModel::kMaxRangeMultiplier; + const double half = TileMath::worldSize() / 2.0; + constexpr int sampleGrid = 5; + int passes = 0; + do { + model.update(); + passes++; + const QList patches = model.patches(); + for (int row = 0; row < sampleGrid; row++) { + for (int col = 0; col < sampleGrid; col++) { + const QPointF screenPos((camera.viewportSize().width() * col) / (sampleGrid - 1), + (camera.viewportSize().height() * row) / (sampleGrid - 1)); + const auto ground = camera.groundPointCapped(screenPos, maxRange); + if (!ground || (qAbs(ground->x()) > half) || (qAbs(ground->y()) > half)) { + continue; + } + bool covered = false; + for (const SurfaceModel::Patch& patch : patches) { + if (tileRect(patch.key).contains(*ground)) { + covered = true; + break; + } + } + QVERIFY2(covered, qPrintable(QStringLiteral("hole after pass %1 at screen (%2, %3)") + .arg(passes) + .arg(screenPos.x()) + .arg(screenPos.y()))); + } + } + } while (!model.updateSettled() && (passes < 400)); + QVERIFY(model.updateSettled()); } -void SurfaceModelTest::_failedHeightsRetryAndRecover() +void SurfaceModelTest::_renderedEdgeContractAcrossLodBoundaries() { + // Layered edge contract, replacing the retired 0.5 m + // C0-exactness assertion, which promised more + // than sample-center tiles can deliver. Across every shared patch edge of + // the real rendered meshes: + // (a) neighbors resolved from the same backing tile match bit-exactly; + // (T) a stitched fine edge lies on its coarse neighbor's rendered + // polyline to float-lerp precision; + // (b) same-level neighbors backed by different tiles mismatch at most by + // the source's variation over each side's sampling reach — the + // residual that skirts hide (contract (c), the skirt sizing step). + // Corner vertices can be captured by a perpendicular edge's constraint + // (N,S,W,E precedence, see PatchGeometry::_heightAt), pulling the + // outermost mesh segment off this neighbor's polyline; skirts hide those + // corner nicks, so (a)/(T) apply to the segment interior (one coarse + // render cell in from each end) and the full segment gets the (b) bound. GeoMapCamera camera; - RecoveringHeightSource source(1); // first request fails, retry succeeds - SurfaceModel model(&camera, &source); - model.setHeightRetryDelayMs(10); - + RoughHeightSource source; + HeightField field; + source.setHeightField(&field); + SurfaceModel model(&camera, &source, &field); camera.setViewportSize(kViewport); - camera.lookAt(kCenter, 0, 0, 2000); - model.drainUpdates(); - QCOMPARE_GT(model.patchCount(), 0); - QTRY_COMPARE_WITH_TIMEOUT(model.pendingCount(), 0, 5000); - for (const SurfaceModel::Patch& patch : model.patches()) { - QVERIFY(patch.ready); - QVERIFY(!patch.heights.isEmpty()); // delivered heights, not the empty flat fallback + // Per-side sampling reach of a rendered edge value around its query + // point: one render cell (mesh/constraint lerp span) plus 1.5 backing + // texels (bilinear support + the half-texel border clamp of + // sample-center grids); the source varies by at most kMaxGradient·reach + const auto reach = [](int renderZoom, int backingZoom) { + return (TileMath::tileSpanAtZoom(renderZoom) / SurfaceModel::kGridSize) + + (1.5 * TileMath::tileSpanAtZoom(backingZoom) / ProceduralHeightSource::kSynthGridSize); + }; + const auto stepBound = [&reach](int renderZoomA, int backingZoomA, int renderZoomB, int backingZoomB) { + return std::min( + RoughHeightSource::kMaxGradient * (reach(renderZoomA, backingZoomA) + reach(renderZoomB, backingZoomB)), + RoughHeightSource::kHeightRange); + }; + + // Oblique poses with a far horizon maximize the LOD spread across the + // resident set (the screenshot pose family) + struct Pose + { + qreal tilt; + qreal distance; + }; + + const QList poses = {{80, 3000}, {GeoMapCamera::kMaxTilt, 15000}}; + + int stitchedPairs = 0; // (T) pairs seen: proves the stitch contract ran + for (const Pose& pose : poses) { + camera.lookAt(kCenter, 0, pose.tilt, pose.distance); + + // Settle: drain model churn, deliver queued tile inserts, and repeat + // until a full pass changes nothing (deliveries re-mesh and can + // re-cull, which can request more tiles) + QSignalSpy regionSpy(&field, &HeightField::regionChanged); + int guard = 200; + do { + regionSpy.clear(); + model.drainUpdates(); + QCoreApplication::processEvents(); + } while (((regionSpy.count() > 0) || !model.updateSettled()) && (--guard > 0)); + QVERIFY(guard > 0); + + const QList patches = model.patches(); + QCOMPARE_GT(patches.count(), 8); + + // Build each patch's real rendered mesh exactly as GeoMap.qml binds + // PatchGeometry: model heights + edge deltas + QHash meshes; + for (const SurfaceModel::Patch& patch : patches) { + QVERIFY(patch.ready); + PatchGeometry geometry; + geometry.setGridSize(SurfaceModel::kGridSize); + geometry.setSpan(tileRect(patch.key).width()); + geometry.setHeights(patch.heights); + geometry.setEdgeLodDeltas(model.edgeLodDeltas(patch.key)); + meshes.insert(patch.key, geometry.vertexData()); + } + + for (const SurfaceModel::Patch& a : patches) { + const QRectF rectA = tileRect(a.key); + for (const SurfaceModel::Patch& b : patches) { + if (a.key == b.key) { + continue; + } + const QRectF rectB = tileRect(b.key); + for (const QChar edgeA : {u'N', u'S', u'W', u'E'}) { + // The world line A's edge lies on; B must sit across it + const double edgeLineA = (edgeA == u'N') ? rectA.bottom() // north = max y + : (edgeA == u'S') ? rectA.top() + : (edgeA == u'W') ? rectA.left() + : rectA.right(); + const double edgeLineB = (edgeA == u'N') ? rectB.top() + : (edgeA == u'S') ? rectB.bottom() + : (edgeA == u'W') ? rectB.right() + : rectB.left(); + const double tolerance = std::min(rectA.width(), rectB.width()) * 1e-9; + if (std::abs(edgeLineA - edgeLineB) > tolerance) { + continue; + } + const bool horizontal = (edgeA == u'N') || (edgeA == u'S'); + const double lo = + horizontal ? std::max(rectA.left(), rectB.left()) : std::max(rectA.top(), rectB.top()); + const double hi = + horizontal ? std::min(rectA.right(), rectB.right()) : std::min(rectA.bottom(), rectB.bottom()); + if (hi <= lo) { + continue; + } + const QChar edgeB = (edgeA == u'N') ? u'S' : (edgeA == u'S') ? u'N' : (edgeA == u'W') ? u'E' : u'W'; + const auto pairText = [&](double step, const char* what) { + return QStringLiteral("%1: %2 m step, z%3 (%4,%5) %6 edge vs z%7 (%8,%9), tilt %10 dist %11") + .arg(QLatin1StringView(what)) + .arg(step, 0, 'f', 3) + .arg(a.key.zoom) + .arg(a.key.x) + .arg(a.key.y) + .arg(edgeA) + .arg(b.key.zoom) + .arg(b.key.x) + .arg(b.key.y) + .arg(pose.tilt) + .arg(pose.distance); + }; + + const TileMath::TileKey backA = field.backingKeyFor(a.key); + const TileMath::TileKey backB = field.backingKeyFor(b.key); + QVERIFY(TileMath::isValidKey(backA) && TileMath::isValidKey(backB)); + + // Full segment, corners included: bounded by the source's + // variation over each side's worst effective resolution — + // its backing, or the coarsest perpendicular constraint + // that may have captured a corner + const QList deltasA = model.edgeLodDeltas(a.key); + const QList deltasB = model.edgeLodDeltas(b.key); + const int zEffA = + std::min(backA.zoom, a.key.zoom - *std::max_element(deltasA.cbegin(), deltasA.cend())); + const int zEffB = + std::min(backB.zoom, b.key.zoom - *std::max_element(deltasB.cbegin(), deltasB.cend())); + const double fullStep = + segmentMaxStep(meshes[a.key], edgeA, rectA, meshes[b.key], edgeB, rectB, horizontal, lo, hi); + QVERIFY2(fullStep <= stepBound(zEffA, zEffA, zEffB, zEffB), + qPrintable(pairText(fullStep, "(b) full-segment bound exceeded"))); + + // Interior (clear of corner capture): exact contracts + const double cell = std::max(rectA.width(), rectB.width()) / SurfaceModel::kGridSize; + const double loIn = lo + cell; + const double hiIn = hi - cell; + if (hiIn <= loIn) { + continue; + } + const double innerStep = segmentMaxStep(meshes[a.key], edgeA, rectA, meshes[b.key], edgeB, rectB, + horizontal, loIn, hiIn); + if (a.key.zoom != b.key.zoom) { + // (T) LOD boundary: the fine edge is constrained to + // the coarse neighbor's own rendered polyline, so + // only float-lerp rounding remains + QVERIFY2(innerStep <= 1e-3, qPrintable(pairText(innerStep, "(T) stitch broken"))); + stitchedPairs++; + } else if (backA == backB) { + // (a) same backing tile: dyadic subwindow arithmetic + // makes coincident edge samples bit-exact + QCOMPARE(innerStep, 0.0); + } else { + // (b) different backings at the same level: interior + // mismatch bounded by the border texel variation + QVERIFY2(innerStep <= stepBound(a.key.zoom, backA.zoom, b.key.zoom, backB.zoom), + qPrintable(pairText(innerStep, "(b) interior bound exceeded"))); + } + } + } + } } + QCOMPARE_GT(stitchedPairs, 0); // the pose family must exercise LOD boundaries } -void SurfaceModelTest::_degradedPatchesKeepRetiringCover() +void SurfaceModelTest::_tallTerrainRecullsOnDataArrival() { + // Terrain (500 m) rises far above the camera eye (~229 up at tilt 55, + // distance 400). Ground-plane culling alone would drop the ground under + // and just behind the bottom screen edge, leaving a hole where that tall + // terrain should render. When tall tile data arrives the model must + // schedule a terrain-aware re-cull without any camera movement. GeoMapCamera camera; - RecoveringHeightSource source(0); // healthy backend first - SurfaceModel model(&camera, &source); - model.setHeightRetryDelayMs(10); + FlatHeightSource source; + HeightField field; + SurfaceModel model(&camera, &source, &field); camera.setViewportSize(kViewport); - camera.lookAt(kCenter, 0, 0, GeoMapCamera::kMaxDistance); + camera.lookAt(kCenter, 0, 55, 400); model.drainUpdates(); - QTRY_COMPARE_WITH_TIMEOUT(model.pendingCount(), 0, 5000); - const int coarseMaxZoom = maxZoomOf(model.patches()); + QVERIFY(model.updateSettled()); - // Backend dies, then a refine replaces the ready coarse set: the fine - // patches exhaust their retries and degrade to flat, but the coarse - // real-height patches must keep covering instead of being swept - source.setFailuresRemaining(-1); - camera.lookAt(kCenter, 0, 0, 2000); - model.drainUpdates(); - QTRY_COMPARE_WITH_TIMEOUT(model.pendingCount(), 0, 5000); + QVERIFY(field.insertTile(TileMath::TileKey{0, 0, 0}, constantGrid(500.0f))); + QVERIFY2(!model.updateSettled(), "tall data arrival did not schedule a re-cull"); - bool hasReadyCoarse = false; + QTRY_VERIFY_WITH_TIMEOUT(model.updateSettled(), 5000); + const QPointF cameraGround = camera.cameraGroundPosition(); + QVERIFY(model.visibleGroundRect().contains(cameraGround)); + bool renderedUnderCamera = false; for (const SurfaceModel::Patch& patch : model.patches()) { - hasReadyCoarse |= (patch.ready && !patch.heights.isEmpty() && (patch.key.zoom <= coarseMaxZoom)); + if (tileRect(patch.key).contains(cameraGround)) { + renderedUnderCamera = true; + break; + } } - QVERIFY(hasReadyCoarse); + QVERIFY2(renderedUnderCamera, "no rendered patch spans the camera ground position"); } -void SurfaceModelTest::_keepsReadyPatchesDuringLodChurn() +void SurfaceModelTest::_cameraGroundTileResidentOverFlatTerrain() { + // The terrain ceiling comes only from resident patches, so tall terrain + // living solely in never-requested tiles (e.g. a cliff under the camera + // with flat water everywhere visible) could never be discovered. The + // visible region must therefore include the camera ground point even + // when every resident patch is flat. GeoMapCamera camera; - FlatHeightSource source; - SurfaceModel model(&camera, &source); - camera.setViewportSize(kViewport); - camera.lookAt(kCenter, 0, 0, GeoMapCamera::kMaxDistance); - model.drainUpdates(); - QTRY_COMPARE_WITH_TIMEOUT(model.pendingCount(), 0, 5000); - const int coarseMaxZoom = maxZoomOf(model.patches()); + camera.lookAt(kCenter, 0, 55, 400); + const QPointF cameraGround = camera.cameraGroundPosition(); - // Refine: the coarse ready patches must keep covering the view (retiring) - // until the fine replacements have heights - no holes during LOD churn - camera.lookAt(kCenter, 0, 0, 2000); + FlatHeightSource source; + HeightField field; + SurfaceModel model(&camera, &source, &field); model.drainUpdates(); - QCOMPARE_GT(model.pendingCount(), 0); - bool hasReadyCoarse = false; - for (const SurfaceModel::Patch& patch : model.patches()) { - hasReadyCoarse |= (patch.ready && (patch.key.zoom <= coarseMaxZoom)); - } - QVERIFY(hasReadyCoarse); - // Once the fine set is ready the retired coarse patches are swept - QTRY_COMPARE_WITH_TIMEOUT(model.pendingCount(), 0, 5000); + QVERIFY(model.visibleGroundRect().contains(cameraGround)); + + bool renderedUnderCamera = false; for (const SurfaceModel::Patch& patch : model.patches()) { - QVERIFY(patch.ready); - QCOMPARE_GT(patch.key.zoom, coarseMaxZoom); + if (tileRect(patch.key).contains(cameraGround)) { + renderedUnderCamera = true; + break; + } } + QVERIFY2(renderedUnderCamera, "no rendered patch spans the camera ground position"); } -void SurfaceModelTest::_degradedRetiringPatchesSwept() +void SurfaceModelTest::_edgeLodDeltasMatchResidentNeighbors() { GeoMapCamera camera; - RecoveringHeightSource source(-1); // backend down for good - SurfaceModel model(&camera, &source); - model.setHeightRetryDelayMs(10); + FlatHeightSource source; + HeightField field; + SurfaceModel model(&camera, &source, &field); + // Tilted view: LOD rings guarantee coarser neighbors across ring boundaries camera.setViewportSize(kViewport); - camera.lookAt(kCenter, 0, 0, 2000); - model.drainUpdates(); - QTRY_COMPARE_WITH_TIMEOUT(model.pendingCount(), 0, 5000); // all degraded to flat - - QSet oldKeys; - for (const SurfaceModel::Patch& patch : model.patches()) { - oldKeys.insert(patch.key); - } - - // Pan far away: the degraded patches become undesired and must be swept - // (a degraded flat fallback is not worth retaining as cover), otherwise - // movement after terrain failures grows the model without bound - camera.lookAt(QGeoCoordinate(40.0, -105.0), 0, 0, 2000); + camera.lookAt(kCenter, 0, 45, 2000); model.drainUpdates(); - QTRY_COMPARE_WITH_TIMEOUT(model.pendingCount(), 0, 5000); + QCOMPARE_GT(model.patchCount(), 8); - for (const SurfaceModel::Patch& patch : model.patches()) { - QVERIFY2(!oldKeys.contains(patch.key), "degraded retiring patch was never swept"); + // Semantics: each edge's delta is how many levels coarser the finest + // resident patch just across that edge renders (same/finer/absent = 0) + const QList patches = model.patches(); + int positiveDeltas = 0; + for (const SurfaceModel::Patch& patch : patches) { + const QRectF rect = tileRect(patch.key); + const double eps = rect.width() * 0.01; + // {N,S,W,E}: world y grows north, slippy y grows south + const QList acrossPoints = { + QPointF(rect.center().x(), rect.bottom() + eps), // north (max y) + QPointF(rect.center().x(), rect.top() - eps), // south (min y) + QPointF(rect.left() - eps, rect.center().y()), // west + QPointF(rect.right() + eps, rect.center().y()), // east + }; + const QList deltas = model.edgeLodDeltas(patch.key); + QCOMPARE(deltas.count(), 4); + for (int edge = 0; edge < 4; edge++) { + const int neighborZoom = finestResidentZoomAt(patches, acrossPoints[edge]); + const int expected = + ((neighborZoom >= 0) && (neighborZoom < patch.key.zoom)) ? (patch.key.zoom - neighborZoom) : 0; + QVERIFY2( + deltas[edge] == expected, + qPrintable(QStringLiteral("edge %1: delta %2, expected %3").arg(edge).arg(deltas[edge]).arg(expected))); + if (deltas[edge] > 0) { + positiveDeltas++; + } + } } + QCOMPARE_GT(positiveDeltas, 0); // the view must actually exercise stitching } -void SurfaceModelTest::_pendingPatchesCoveredDuringLodChurn() +void SurfaceModelTest::_edgeDeltasNotifiedOnNeighborChurn() { GeoMapCamera camera; FlatHeightSource source; - SurfaceModel model(&camera, &source); + HeightField field; + SurfaceModel model(&camera, &source, &field); camera.setViewportSize(kViewport); camera.lookAt(kCenter, 0, 0, GeoMapCamera::kMaxDistance); model.drainUpdates(); - QTRY_COMPARE_WITH_TIMEOUT(model.pendingCount(), 0, 5000); - // Refine: every pending replacement overlaps a retained ready patch, so it - // reports covered - renderers suppress it instead of drawing a flat - // placeholder that z-fights the retiring cover - camera.lookAt(kCenter, 0, 0, 2000); + // Refinement churn adds/removes neighbors: resident patches whose edge + // deltas may have changed must be notified so consumers re-pull them + QSignalSpy deltasSpy(&model, &SurfaceModel::patchEdgeDeltasChanged); + camera.lookAt(kCenter, 0, 45, 2000); model.drainUpdates(); - QCOMPARE_GT(model.pendingCount(), 0); - int pendingCovered = 0; - for (const SurfaceModel::Patch& patch : model.patches()) { - if (patch.ready) { - QVERIFY(!patch.covered); - } else { - QVERIFY(patch.covered); - pendingCovered++; - } - } - QCOMPARE_GT(pendingCovered, 0); - // Once ready nothing is covered - QTRY_COMPARE_WITH_TIMEOUT(model.pendingCount(), 0, 5000); - for (const SurfaceModel::Patch& patch : model.patches()) { - QVERIFY(!patch.covered); + QCOMPARE_GT(deltasSpy.count(), 0); + for (const QList& args : deltasSpy) { + QVERIFY(TileMath::isValidKey(args.first().value())); } } -void SurfaceModelTest::_tallTerrainKeepsCameraTileResident() +void SurfaceModelTest::_coverageSweepAcrossPoses() { - // Terrain (500 m) rises far above the camera eye (~229 up at tilt 55, - // distance 400). Ground-plane culling alone would drop the ground under - // and just behind the bottom screen edge, leaving a hole where that tall - // terrain should render. Once heights arrive the model must re-cull - // terrain-aware without any camera movement. GeoMapCamera camera; - TallHeightSource source(500.0f); - SurfaceModel model(&camera, &source); - + FlatHeightSource source; + HeightField field; + // Real terrain heights change the cull: sweep with data resident too + QVERIFY(field.insertTile(TileMath::TileKey{0, 0, 0}, constantGrid(400.0f))); + SurfaceModel model(&camera, &source, &field); camera.setViewportSize(kViewport); - camera.lookAt(kCenter, 0, 55, 400); - model.drainUpdates(); - QTRY_COMPARE_WITH_TIMEOUT(model.pendingCount(), 0, 5000); - const QPointF cameraGround = camera.cameraGroundPosition(); - QTRY_VERIFY_WITH_TIMEOUT(model.visibleGroundRect().contains(cameraGround), 5000); - - // The re-cull spawns fresh height fetches; wait for those too - QTRY_COMPARE_WITH_TIMEOUT(model.pendingCount(), 0, 5000); - bool renderedUnderCamera = false; - for (const SurfaceModel::Patch& patch : model.patches()) { - const double span = TileMath::tileSpanAtZoom(patch.key.zoom); - const QRectF rect(TileMath::tileMinCorner(patch.key), QSizeF(span, span)); - if (!patch.covered && rect.contains(cameraGround)) { - renderedUnderCamera = true; - break; + // Sequential poses on purpose: each settle starts from the previous + // pose's resident set, like interactive use. Oblique tilts stress the + // AABB visibility estimate the most. + const QList tilts = {0, 30, 55, 70, 80, GeoMapCamera::kMaxTilt}; + const QList distances = {200, 2000, 50000, 2000000}; + const QList headings = {0, 135}; + for (const qreal heading : headings) { + for (const qreal distance : distances) { + for (const qreal tilt : tilts) { + camera.lookAt(kCenter, heading, tilt, distance); + model.drainUpdates(); + const QString hole = coverageHole(model, camera); + QVERIFY2(hole.isEmpty(), qPrintable(hole)); + } } } - QVERIFY2(renderedUnderCamera, "no rendered patch spans the camera ground position"); } -void SurfaceModelTest::_cameraGroundTileResidentOverFlatTerrain() +void SurfaceModelTest::_coverageAfterInteractiveGesture() { - // The terrain ceiling comes only from resident patches, so tall terrain - // living solely in never-requested tiles (e.g. a cliff under the camera - // with flat water everywhere visible) could never be discovered. The - // visible region must therefore include the camera ground point even - // when every resident patch is flat. GeoMapCamera camera; - camera.setViewportSize(kViewport); - camera.lookAt(kCenter, 0, 55, 400); - const QPointF cameraGround = camera.cameraGroundPosition(); - FlatHeightSource source; - SurfaceModel model(&camera, &source); - model.drainUpdates(); - - QVERIFY(model.visibleGroundRect().contains(cameraGround)); + HeightField field; + QVERIFY(field.insertTile(TileMath::TileKey{0, 0, 0}, constantGrid(400.0f))); + SurfaceModel model(&camera, &source, &field); + camera.setViewportSize(kViewport); - QTRY_COMPARE_WITH_TIMEOUT(model.pendingCount(), 0, 5000); - bool renderedUnderCamera = false; - for (const SurfaceModel::Patch& patch : model.patches()) { - const double span = TileMath::tileSpanAtZoom(patch.key.zoom); - const QRectF rect(TileMath::tileMinCorner(patch.key), QSizeF(span, span)); - if (!patch.covered && rect.contains(cameraGround)) { - renderedUnderCamera = true; - break; + struct Pose + { + QGeoCoordinate center; + qreal heading; + qreal tilt; + qreal distance; + }; + + const QGeoCoordinate kPannedCenter(kCenter.latitude() + 0.05, kCenter.longitude() + 0.05); + // Drag-like path: tilt down to max oblique, zoom out, rotate, pan, zoom + // back in. Interpolated per-frame with a single update pass per frame so + // the camera outruns the add/removal caps, exactly like interactive use. + const QList waypoints = { + {kCenter, 0, 0, 2000}, + {kCenter, 0, GeoMapCamera::kMaxTilt, 2000}, + {kCenter, 0, GeoMapCamera::kMaxTilt, 200000}, + {kCenter, 180, GeoMapCamera::kMaxTilt, 200000}, + {kPannedCenter, 180, GeoMapCamera::kMaxTilt, 200000}, + {kPannedCenter, 180, 70, 500}, + }; + constexpr int kStepsPerSegment = 30; + for (int seg = 1; seg < waypoints.count(); seg++) { + const Pose& from = waypoints[seg - 1]; + const Pose& to = waypoints[seg]; + for (int step = 1; step <= kStepsPerSegment; step++) { + const qreal t = qreal(step) / kStepsPerSegment; + const QGeoCoordinate center( + from.center.latitude() + ((to.center.latitude() - from.center.latitude()) * t), + from.center.longitude() + ((to.center.longitude() - from.center.longitude()) * t)); + camera.lookAt(center, from.heading + ((to.heading - from.heading) * t), + from.tilt + ((to.tilt - from.tilt) * t), + from.distance * std::pow(to.distance / from.distance, t)); + model.update(); // one pass per frame: camera moves faster than the model settles } + // Camera stops: the model must clean up to full coverage + model.drainUpdates(); + QVERIFY(model.updateSettled()); + const QString hole = coverageHole(model, camera); + QVERIFY2(hole.isEmpty(), qPrintable(QStringLiteral("segment %1: %2").arg(seg).arg(hole))); } - QVERIFY2(renderedUnderCamera, "no rendered patch spans the camera ground position"); } -void SurfaceModelTest::_slowHeightsChurnSettlesWithoutHoles() +void SurfaceModelTest::_pinsPatchBackingTiles() { - // Stress: LOD churn with heights held pins retiring covers while adds - // stream. Once heights drain, refinement must resume, settle, and leave - // full ready coverage - no frozen holes and no unbounded resident growth. + // Tiles that back resident patches (their keys and resolving ancestors) + // must be pinned against LRU eviction: an evicted backing tile silently + // coarsens a rendered mesh next to an intact neighbor — a cliff GeoMapCamera camera; - HoldingHeightSource source; - SurfaceModel model(&camera, &source); - + FlatHeightSource source; + HeightField field; + SurfaceModel model(&camera, &source, &field); camera.setViewportSize(kViewport); - camera.lookAt(kCenter, 0, 0, GeoMapCamera::kMaxDistance); + camera.lookAt(kCenter, 0, 0, 2000); model.drainUpdates(); - source.deliverAll(); - - // Churn through LOD levels with heights held: every refine retires the - // previous ready set while its replacements stay pinned pending - for (double distance : {100000.0, 20000.0, 4000.0, 800.0}) { - camera.lookAt(kCenter, 0, 55, distance); - model.drainUpdates(); - } - QCOMPARE_GT(model.pendingCount(), 0); + QCOMPARE_GT(model.patchCount(), 0); - // Drain deliveries and scheduled passes alternately until settled; the - // event loop must pump so queued update passes clear their pending flag - for (int i = 0; (i < 200) && !(model.updateSettled() && (model.pendingCount() == 0)); i++) { - source.deliverAll(); - QCoreApplication::processEvents(); + // Give one resident patch backing data at its own key, then flood with + // unrelated tiles far past the pyramid cap + const TileMath::TileKey backingKey = model.patches().first().key; + QVERIFY(field.insertTile(backingKey, constantGrid(123.0f))); + for (int i = 0; i < ElevationTilePyramid::kMaxTiles + 8; i++) { + QVERIFY(field.insertTile(TileMath::TileKey{i, 200, 9}, constantGrid(1.0f))); } - QCOMPARE(model.pendingCount(), 0); - QVERIFY(model.updateSettled()); - // Settled resident set: desired patches plus swept-down remainder stays - // within ~2x the refinement budget (see kMaxPatches doc) - QCOMPARE_LE(model.patchCount(), 2 * SurfaceModel::kMaxPatches); - - // No holes: every visible ground sample lies inside a resident ready patch - const QList patches = model.patches(); - const double maxRange = camera.distance() * SurfaceModel::kMaxRangeMultiplier; - const double half = TileMath::worldSize() / 2.0; - constexpr int sampleGrid = 9; - for (int row = 0; row < sampleGrid; row++) { - for (int col = 0; col < sampleGrid; col++) { - const QPointF screenPos((camera.viewportSize().width() * col) / (sampleGrid - 1), - (camera.viewportSize().height() * row) / (sampleGrid - 1)); - const auto ground = camera.groundPointCapped(screenPos, maxRange); - if (!ground || (qAbs(ground->x()) > half) || (qAbs(ground->y()) > half)) { - continue; - } - bool covered = false; - for (const SurfaceModel::Patch& patch : patches) { - const double span = TileMath::tileSpanAtZoom(patch.key.zoom); - if (patch.ready && QRectF(TileMath::tileMinCorner(patch.key), QSizeF(span, span)).contains(*ground)) { - covered = true; - break; - } - } - QVERIFY2(covered, - qPrintable(QStringLiteral("hole at screen (%1, %2)").arg(screenPos.x()).arg(screenPos.y()))); - } - } + QVERIFY2(field.hasTile(backingKey), "patch-backing tile was evicted"); } UT_REGISTER_TEST_LIGHTWEIGHT(SurfaceModelTest, TestLabel::Unit) diff --git a/test/GeoMap/SurfaceModelTest.h b/test/GeoMap/SurfaceModelTest.h index c27ab0b8a534..73125335b903 100644 --- a/test/GeoMap/SurfaceModelTest.h +++ b/test/GeoMap/SurfaceModelTest.h @@ -7,23 +7,27 @@ class SurfaceModelTest : public UnitTest Q_OBJECT private slots: + void _patchRendersEstimateImmediately(); + void _patchesAreViewsOfField(); + void _reMeshOnDataArrival(); + void _reMeshScopeGate(); + void _requestsTileCoverage(); void _noViewportNoPatches(); void _unpositionedCameraNoPatches(); void _coarseWhenFar(); void _refinesWhenNear(); void _patchCountBounded(); void _budgetExhaustedKeepsCoverage(); - void _heightsArrive(); void _diffOnMove(); void _noChurnOnIdenticalUpdate(); void _cullsInvisibleRegion(); - void _failedHeightsDegradeToFlat(); - void _failedHeightsRetryAndRecover(); - void _keepsReadyPatchesDuringLodChurn(); - void _degradedPatchesKeepRetiringCover(); - void _degradedRetiringPatchesSwept(); - void _pendingPatchesCoveredDuringLodChurn(); - void _tallTerrainKeepsCameraTileResident(); + void _coverageMaintainedDuringLodChurn(); + void _coverageSweepAcrossPoses(); + void _coverageAfterInteractiveGesture(); + void _renderedEdgeContractAcrossLodBoundaries(); + void _tallTerrainRecullsOnDataArrival(); void _cameraGroundTileResidentOverFlatTerrain(); - void _slowHeightsChurnSettlesWithoutHoles(); + void _edgeLodDeltasMatchResidentNeighbors(); + void _edgeDeltasNotifiedOnNeighborChurn(); + void _pinsPatchBackingTiles(); }; diff --git a/test/GeoMap/SurfacePatchImageryTest.cc b/test/GeoMap/SurfacePatchImageryTest.cc index f4f11c0e9330..75ce09884cec 100644 --- a/test/GeoMap/SurfacePatchImageryTest.cc +++ b/test/GeoMap/SurfacePatchImageryTest.cc @@ -1,11 +1,15 @@ #include "SurfacePatchImageryTest.h" +#include +#include +#include #include #include "GeoMapCamera.h" #include "GeoScene.h" #include "QGCMapUrlEngine.h" #include "SurfacePatchModel.h" +#include "UnitTestTileGenerator.h" namespace { @@ -55,6 +59,48 @@ int rowsReportingImage(const SurfacePatchModel& model) return count; } +/// Reply that fails asynchronously without touching the network +class FailingReply : public QNetworkReply +{ +public: + explicit FailingReply(const QNetworkRequest& request, QObject* parent) : QNetworkReply(parent) + { + setRequest(request); + setOperation(QNetworkAccessManager::GetOperation); + open(ReadOnly); + QMetaObject::invokeMethod( + this, + [this] { + setError(ContentNotFoundError, QStringLiteral("canned failure")); + emit errorOccurred(ContentNotFoundError); + setFinished(true); + emit finished(); + }, + Qt::QueuedConnection); + } + + void abort() final {} + +protected: + qint64 readData(char*, qint64) final { return -1; } +}; + +/// Every network fetch fails: models a provider/connectivity outage +class FailingNam : public QNetworkAccessManager +{ +public: + using QNetworkAccessManager::QNetworkAccessManager; + + int requestCount = 0; + +protected: + QNetworkReply* createRequest(Operation, const QNetworkRequest& request, QIODevice*) final + { + requestCount++; + return new FailingReply(request, this); + } +}; + } // namespace void SurfacePatchImageryTest::_imagesArriveForAllPatches() @@ -158,4 +204,97 @@ void SurfacePatchImageryTest::_fallbackCoversLodChanges() QCOMPARE(rowsWithImage(model), rowsReportingImage(model)); } +void SurfacePatchImageryTest::_imagesRecoverAfterGestureChurn() +{ + const QString type = mapType(); + QVERIFY2(!type.isEmpty(), "no non-elevation map provider registered"); + + GeoMapCamera camera; + GeoScene scene; + SurfacePatchModel model; + attach(model, scene, camera); + model.setMapType(type); + QTRY_COMPARE_WITH_TIMEOUT(rowsWithImage(model), model.rowCount(), 5000); + + // Drag-like camera motion: per-frame poses with the event loop pumped + // between frames, so async image deliveries and cancellations interleave + // with patch churn mid-gesture (a settled-pose test never sees this). + // Path mirrors the field report: tilt down to max oblique, zoom out, + // rotate, pan, zoom back in. + struct Pose + { + QGeoCoordinate center; + qreal heading; + qreal tilt; + qreal distance; + }; + + const QGeoCoordinate kPanned(kCenter.latitude() + 0.05, kCenter.longitude() + 0.05); + const QList waypoints = { + {kCenter, 0, 0, 2000}, + {kCenter, 0, GeoMapCamera::kMaxTilt, 2000}, + {kCenter, 0, GeoMapCamera::kMaxTilt, 200000}, + {kPanned, 180, GeoMapCamera::kMaxTilt, 200000}, + {kPanned, 180, 70, 500}, + }; + constexpr int kStepsPerSegment = 30; + for (int seg = 1; seg < waypoints.count(); seg++) { + const Pose& from = waypoints[seg - 1]; + const Pose& to = waypoints[seg]; + for (int step = 1; step <= kStepsPerSegment; step++) { + const qreal t = qreal(step) / kStepsPerSegment; + const QGeoCoordinate center( + from.center.latitude() + ((to.center.latitude() - from.center.latitude()) * t), + from.center.longitude() + ((to.center.longitude() - from.center.longitude()) * t)); + camera.lookAt(center, from.heading + ((to.heading - from.heading) * t), + from.tilt + ((to.tilt - from.tilt) * t), + from.distance * std::pow(to.distance / from.distance, t)); + QCoreApplication::processEvents(); // one frame: deliveries land mid-gesture + } + // Camera stops: every patch must end up imaged. A row whose + // hasTileImage never turns true renders the dark loading fallback + // forever - the visible "hole" from the field report. + model.drainUpdates(); + QTRY_COMPARE_WITH_TIMEOUT(rowsReportingImage(model), model.rowCount(), 10000); + QTRY_COMPARE_WITH_TIMEOUT(rowsWithImage(model), model.rowCount(), 10000); + } +} + +void SurfacePatchImageryTest::_imagesRecoverAfterTransientOutage() +{ + const QString type = mapType(); + QVERIFY2(!type.isEmpty(), "no non-elevation map provider registered"); + + GeoMapCamera camera; + GeoScene scene; + FailingNam nam; // must outlive the model: its TileImageSource borrows the manager + SurfacePatchModel model; + model.setTileImageNetworkManager(&nam); + attach(model, scene, camera); + model.setMapType(type); + QTRY_COMPARE_WITH_TIMEOUT(rowsWithImage(model), model.rowCount(), 5000); + + // Transient imagery outage: cache lookups miss and the network fallback + // fails while a pan brings in new patches (the field scenario: gesture + // during flaky connectivity) + UnitTestTileGenerator::setForcedMissCount(10000); + const auto guard = qScopeGuard([] { UnitTestTileGenerator::setForcedMissCount(0); }); + // Fetch failures must be visible without logging configuration; repeats + // within the throttle window stay at debug + expectLogMessage("GeoMap.TileImageSource", QtWarningMsg, QRegularExpression(QStringLiteral("failed"))); + camera.setCenter(QGeoCoordinate(kCenter.latitude() + 0.02, kCenter.longitude() + 0.02)); + model.drainUpdates(); + QCOMPARE_GT(model.rowCount(), 0); + QTRY_COMPARE_WITH_TIMEOUT(model.pendingImageCount(), 0, 10000); // all outage-era requests resolved + QCOMPARE_GT(nam.requestCount, 0); + QCOMPARE_LT(rowsWithImage(model), model.rowCount()); // the outage left imageless patches + verifyExpectedLogMessage(); + // Outage ends with the camera at rest: every resident patch must still + // end up imaged. A row stuck without image (or fallback) renders the dark + // loading fallback forever - the visible "hole" from the field report. + UnitTestTileGenerator::setForcedMissCount(0); + QTRY_COMPARE_WITH_TIMEOUT(rowsReportingImage(model), model.rowCount(), 10000); + QTRY_COMPARE_WITH_TIMEOUT(rowsWithImage(model), model.rowCount(), 10000); +} + UT_REGISTER_TEST(SurfacePatchImageryTest, TestLabel::Integration) diff --git a/test/GeoMap/SurfacePatchImageryTest.h b/test/GeoMap/SurfacePatchImageryTest.h index 02409688e0fd..3656134bcb7f 100644 --- a/test/GeoMap/SurfacePatchImageryTest.h +++ b/test/GeoMap/SurfacePatchImageryTest.h @@ -11,4 +11,6 @@ private slots: void _emptyMapTypeDisablesImagery(); void _mapTypeSwitchRefetches(); void _fallbackCoversLodChanges(); + void _imagesRecoverAfterGestureChurn(); + void _imagesRecoverAfterTransientOutage(); }; diff --git a/test/GeoMap/SurfacePatchModelTest.cc b/test/GeoMap/SurfacePatchModelTest.cc index ebd8dcca9ce4..5d7e183a63dd 100644 --- a/test/GeoMap/SurfacePatchModelTest.cc +++ b/test/GeoMap/SurfacePatchModelTest.cc @@ -1,10 +1,13 @@ #include "SurfacePatchModelTest.h" +#include #include + #include #include "GeoMapCamera.h" #include "GeoScene.h" +#include "HeightField.h" #include "SurfaceModel.h" #include "SurfacePatchModel.h" #include "TileMath.h" @@ -180,23 +183,24 @@ void SurfacePatchModelTest::_debugHillsSwitchResets() model.drainUpdates(); QCOMPARE_GT(resetSpy.count(), 0); QCOMPARE_GT(model.rowCount(), 0); - QTRY_COMPARE_WITH_TIMEOUT(model.pendingCount(), 0, 5000); - // Debug hills deliver non-flat heights - bool nonZeroSeen = false; - for (int row = 0; (row < model.rowCount()) && !nonZeroSeen; row++) { - const auto heights = model.data(model.index(row), SurfacePatchModel::HeightsRole).value>(); - for (float h : heights) { - if (h > 0.0f) { - nonZeroSeen = true; - break; + // Debug hills deliver non-flat heights once the synthesized tiles land in + // the field (delivered via the event loop) + const auto nonZeroSeen = [&model]() { + for (int row = 0; row < model.rowCount(); row++) { + const auto heights = model.data(model.index(row), SurfacePatchModel::HeightsRole).value>(); + for (float h : heights) { + if (h > 0.0f) { + return true; + } } } - } - QVERIFY(nonZeroSeen); + return false; + }; + QTRY_VERIFY_WITH_TIMEOUT(nonZeroSeen(), 5000); } -void SurfacePatchModelTest::_pendingRowsCoveredDuringLodChurn() +void SurfacePatchModelTest::_rowsAlwaysMeshedDuringLodChurn() { GeoMapCamera camera; GeoScene scene; @@ -204,29 +208,106 @@ void SurfacePatchModelTest::_pendingRowsCoveredDuringLodChurn() camera.setViewportSize(kViewport); camera.lookAt(kCenter, 0, 0, GeoMapCamera::kMaxDistance); attach(model, scene, camera); - QTRY_COMPARE_WITH_TIMEOUT(model.pendingCount(), 0, 5000); - // Refine: pending replacements report covered so the delegate hides their - // empty flat mesh instead of z-fighting the retained retiring cover + // Refine hard: every row must present a full mesh from the field's + // estimate - the delegate never sees a pending/hidden row. (Per-pass + // coverage during churn is guarded at the SurfaceModel level.) camera.lookAt(kCenter, 0, 0, 2000); model.drainUpdates(); - QCOMPARE_GT(model.pendingCount(), 0); - int coveredRows = 0; + QCOMPARE_GT(model.rowCount(), 0); for (int row = 0; row < model.rowCount(); row++) { const QModelIndex idx = model.index(row); - const bool ready = model.data(idx, SurfacePatchModel::ReadyRole).toBool(); - const bool covered = model.data(idx, SurfacePatchModel::CoveredRole).toBool(); - QCOMPARE(covered, !ready); - if (covered) { - coveredRows++; + QVERIFY(model.data(idx, SurfacePatchModel::ReadyRole).toBool()); + QVERIFY(!model.data(idx, SurfacePatchModel::CoveredRole).toBool()); + const auto heights = model.data(idx, SurfacePatchModel::HeightsRole).value>(); + QCOMPARE(heights.count(), (SurfaceModel::kGridSize + 1) * (SurfaceModel::kGridSize + 1)); + } +} + +void SurfacePatchModelTest::_edgeLodDeltasRoleStitchesLodRings() +{ + GeoMapCamera camera; + GeoScene scene; + SurfacePatchModel model; + camera.setViewportSize(kViewport); + // Tilted view: LOD rings guarantee coarser neighbors across ring boundaries + camera.lookAt(kCenter, 0, 45, 2000); + attach(model, scene, camera); + QCOMPARE_GT(model.rowCount(), 8); + + QVERIFY(model.roleNames().value(SurfacePatchModel::EdgeLodDeltasRole) == QByteArray("edgeLodDeltas")); + + // Every row exposes {N,S,W,E}; the mixed-LOD view must exercise stitching + int positiveDeltas = 0; + for (int row = 0; row < model.rowCount(); row++) { + const auto deltas = model.data(model.index(row), SurfacePatchModel::EdgeLodDeltasRole).value>(); + QCOMPARE(deltas.count(), 4); + for (const int delta : deltas) { + QCOMPARE_GE(delta, 0); + if (delta > 0) { + positiveDeltas++; + } } } - QCOMPARE_GT(coveredRows, 0); + QCOMPARE_GT(positiveDeltas, 0); + + // Neighbor churn refreshes the role so delegates re-stitch + QSignalSpy dataSpy(&model, &QAbstractItemModel::dataChanged); + camera.lookAt(kCenter, 0, 45, 1000); + model.drainUpdates(); + bool deltasRefreshed = false; + for (const QList& args : dataSpy) { + const auto roles = args.at(2).value>(); + if (roles.contains(SurfacePatchModel::EdgeLodDeltasRole)) { + deltasRefreshed = true; + break; + } + } + QVERIFY2(deltasRefreshed, "no dataChanged carried EdgeLodDeltasRole during LOD churn"); +} + +void SurfacePatchModelTest::_tileKeyAndHeightFieldExposedToDelegates() +{ + // The tile key roles (tileX, tileY + the existing zoomLevel) must address + // the patch actually rendered at each row, and the model's field is the + // live one the row heights were sampled from. + GeoMapCamera camera; + GeoScene scene; + SurfacePatchModel model; + setupCamera(camera); + attach(model, scene, camera); + QCOMPARE_GT(model.rowCount(), 0); + + QVERIFY(model.roleNames().value(SurfacePatchModel::TileXRole) == QByteArray("tileX")); + QVERIFY(model.roleNames().value(SurfacePatchModel::TileYRole) == QByteArray("tileY")); + QVERIFY(model.heightField() != nullptr); - QTRY_COMPARE_WITH_TIMEOUT(model.pendingCount(), 0, 5000); for (int row = 0; row < model.rowCount(); row++) { - QVERIFY(!model.data(model.index(row), SurfacePatchModel::CoveredRole).toBool()); + const QModelIndex idx = model.index(row); + const TileMath::TileKey key{model.data(idx, SurfacePatchModel::TileXRole).toInt(), + model.data(idx, SurfacePatchModel::TileYRole).toInt(), + model.data(idx, SurfacePatchModel::ZoomRole).toInt()}; + QVERIFY(TileMath::isValidKey(key)); + + // The key must address the patch actually rendered there: its tile + // rect (scene-relative) matches the row's center and span + const double span = TileMath::tileSpanAtZoom(key.zoom); + QCOMPARE(model.data(idx, SurfacePatchModel::SpanRole).toDouble(), span); + const QPointF minCorner = TileMath::tileMinCorner(key); + const QPointF sceneCenter(minCorner.x() + (span / 2.0) - scene.sceneOrigin().x(), + minCorner.y() + (span / 2.0) - scene.sceneOrigin().y()); + QCOMPARE(model.data(idx, SurfacePatchModel::CenterXRole).toDouble(), sceneCenter.x()); + QCOMPARE(model.data(idx, SurfacePatchModel::CenterYRole).toDouble(), sceneCenter.y()); } + + // The exposed field is the live one: model heights come from it + QTRY_COMPARE_WITH_TIMEOUT(model.pendingCount(), 0, 5000); + const QModelIndex first = model.index(0); + const TileMath::TileKey firstKey{model.data(first, SurfacePatchModel::TileXRole).toInt(), + model.data(first, SurfacePatchModel::TileYRole).toInt(), + model.data(first, SurfacePatchModel::ZoomRole).toInt()}; + const auto heights = model.data(first, SurfacePatchModel::HeightsRole).value>(); + QCOMPARE(model.heightField()->samplePatch(firstKey, model.gridSize()), heights); } UT_REGISTER_TEST_LIGHTWEIGHT(SurfacePatchModelTest, TestLabel::Unit) diff --git a/test/GeoMap/SurfacePatchModelTest.h b/test/GeoMap/SurfacePatchModelTest.h index 2533c391d123..5ed958f25fde 100644 --- a/test/GeoMap/SurfacePatchModelTest.h +++ b/test/GeoMap/SurfacePatchModelTest.h @@ -14,5 +14,7 @@ private slots: void _reanchorsOnLargeMove(); void _cameraSwapAnchorsFresh(); void _debugHillsSwitchResets(); - void _pendingRowsCoveredDuringLodChurn(); + void _rowsAlwaysMeshedDuringLodChurn(); + void _edgeLodDeltasRoleStitchesLodRings(); + void _tileKeyAndHeightFieldExposedToDelegates(); }; diff --git a/test/GeoMap/TerrariumHeightSourceTest.cc b/test/GeoMap/TerrariumTileFetcherTest.cc similarity index 51% rename from test/GeoMap/TerrariumHeightSourceTest.cc rename to test/GeoMap/TerrariumTileFetcherTest.cc index dd19f9e5d6cb..afe7f295ee7a 100644 --- a/test/GeoMap/TerrariumHeightSourceTest.cc +++ b/test/GeoMap/TerrariumTileFetcherTest.cc @@ -1,15 +1,23 @@ -#include "TerrariumHeightSourceTest.h" +#include "TerrariumTileFetcherTest.h" #include +#include +#include #include #include #include #include #include #include + #include +#include +#include -#include "TerrariumHeightSource.h" +#include "ElevationMapProvider.h" +#include "HeightField.h" +#include "QGeoFileTileCacheQGC.h" +#include "TerrariumTileFetcher.h" #include "TileMath.h" #include "UnitTestTileGenerator.h" @@ -93,8 +101,8 @@ class MockNam : public QNetworkAccessManager QByteArray body; int requestCount = 0; - bool holdReplies = false; ///< replies stay open until finished manually - QList replies; ///< creation order, only tracked while holding + bool holdReplies = false; ///< replies stay open until finished manually + QList replies; ///< creation order, only tracked while holding protected: QNetworkReply* createRequest(Operation, const QNetworkRequest& request, QIODevice*) final @@ -109,9 +117,9 @@ class MockNam : public QNetworkAccessManager }; } // namespace -void TerrariumHeightSourceTest::_deliversFlatRegionHeights() +void TerrariumTileFetcherTest::_deliversFlatRegionHeights() { - TerrariumHeightSource source; + TerrariumTileFetcher source; QSignalSpy readySpy(&source, &HeightSource::patchHeightsReady); QSignalSpy failedSpy(&source, &HeightSource::patchHeightsFailed); @@ -132,9 +140,9 @@ void TerrariumHeightSourceTest::_deliversFlatRegionHeights() } } -void TerrariumHeightSourceTest::_deliversSlopeRegionHeights() +void TerrariumTileFetcherTest::_deliversSlopeRegionHeights() { - TerrariumHeightSource source; + TerrariumTileFetcher source; QSignalSpy readySpy(&source, &HeightSource::patchHeightsReady); source.requestPatchHeights(keyOver(linearSlopeRegion().center(), kFineZoom), kGridSize); @@ -158,11 +166,11 @@ void TerrariumHeightSourceTest::_deliversSlopeRegionHeights() } } -void TerrariumHeightSourceTest::_coarseZoomDeliversRealTerrain() +void TerrariumTileFetcherTest::_coarseZoomDeliversRealTerrain() { // Terrarium serves one tile per patch at any zoom, so coarse patches get real // terrain — no flat floor or blend band (the old TerrainHeightSource fetch-storm hack) - TerrariumHeightSource source; + TerrariumTileFetcher source; QSignalSpy readySpy(&source, &HeightSource::patchHeightsReady); QSignalSpy failedSpy(&source, &HeightSource::patchHeightsFailed); @@ -183,11 +191,11 @@ void TerrariumHeightSourceTest::_coarseZoomDeliversRealTerrain() QCOMPARE_GE(maxHeight, static_cast(UnitTestTerrainData::Flat10Region::amslElevation)); } -void TerrariumHeightSourceTest::_deepZoomSamplesAncestorTile() +void TerrariumTileFetcherTest::_deepZoomSamplesAncestorTile() { // The dataset tops out at z15: deeper patches must fetch the z15 ancestor and // sample its sub-window instead of requesting tiles that don't exist - TerrariumHeightSource source; + TerrariumTileFetcher source; QSignalSpy readySpy(&source, &HeightSource::patchHeightsReady); constexpr int deepZoom = 18; @@ -195,9 +203,9 @@ void TerrariumHeightSourceTest::_deepZoomSamplesAncestorTile() source.requestPatchHeights(key, kGridSize); const TileMath::TileKey fetched = source.lastFetchKey(); - QCOMPARE(fetched.zoom, TerrariumHeightSource::kMaxTileZoom); - QCOMPARE(fetched.x, key.x >> (deepZoom - TerrariumHeightSource::kMaxTileZoom)); - QCOMPARE(fetched.y, key.y >> (deepZoom - TerrariumHeightSource::kMaxTileZoom)); + QCOMPARE(fetched.zoom, TerrariumTileFetcher::kMaxTileZoom); + QCOMPARE(fetched.x, key.x >> (deepZoom - TerrariumTileFetcher::kMaxTileZoom)); + QCOMPARE(fetched.y, key.y >> (deepZoom - TerrariumTileFetcher::kMaxTileZoom)); QTRY_COMPARE_WITH_TIMEOUT(readySpy.count(), 1, TestTimeout::mediumMs()); const auto heights = readySpy.first().at(1).value>(); @@ -207,9 +215,9 @@ void TerrariumHeightSourceTest::_deepZoomSamplesAncestorTile() } } -void TerrariumHeightSourceTest::_cancelPreventsDelivery() +void TerrariumTileFetcherTest::_cancelPreventsDelivery() { - TerrariumHeightSource source; + TerrariumTileFetcher source; QSignalSpy readySpy(&source, &HeightSource::patchHeightsReady); QSignalSpy failedSpy(&source, &HeightSource::patchHeightsFailed); @@ -220,13 +228,16 @@ void TerrariumHeightSourceTest::_cancelPreventsDelivery() QVERIFY(failedSpy.isEmpty()); } -void TerrariumHeightSourceTest::_invalidGridSizeFails() +void TerrariumTileFetcherTest::_invalidGridSizeFails() { - TerrariumHeightSource source; + TerrariumTileFetcher source; QSignalSpy readySpy(&source, &HeightSource::patchHeightsReady); QSignalSpy failedSpy(&source, &HeightSource::patchHeightsFailed); + expectLogMessage("GeoMap.TerrariumTileFetcher", QtWarningMsg, + QRegularExpression(QStringLiteral("^requestPatchHeights rejected:"))); const int requestId = source.requestPatchHeights(keyOver(flat10Region().center(), kFineZoom), 0); + verifyExpectedLogMessage(); QCOMPARE_GT(requestId, 0); QVERIFY(failedSpy.isEmpty()); // failure is async too @@ -235,7 +246,49 @@ void TerrariumHeightSourceTest::_invalidGridSizeFails() QVERIFY(readySpy.isEmpty()); } -void TerrariumHeightSourceTest::_networkFallbackDeliversAndCaches() +void TerrariumTileFetcherTest::_oversizeGridSizeFails() +{ + // A gridSize beyond the supported maximum is a programming error: warn and + // fail async like the other invalid inputs + MockNam nam; + TerrariumTileFetcher source(nullptr, &nam); + QSignalSpy readySpy(&source, &HeightSource::patchHeightsReady); + QSignalSpy failedSpy(&source, &HeightSource::patchHeightsFailed); + + expectLogMessage("GeoMap.TerrariumTileFetcher", QtWarningMsg, QRegularExpression(QStringLiteral("gridSize"))); + const int requestId = + source.requestPatchHeights(keyOver(flat10Region().center(), kFineZoom), TerrariumTileFetcher::kMaxGridSize + 1); + verifyExpectedLogMessage(); + QCOMPARE_GT(requestId, 0); + + QTRY_COMPARE_WITH_TIMEOUT(failedSpy.count(), 1, TestTimeout::mediumMs()); + QCOMPARE(failedSpy.first().at(0).toInt(), requestId); + QVERIFY(readySpy.isEmpty()); + QCOMPARE(nam.requestCount, 0); +} + +void TerrariumTileFetcherTest::_invalidKeyFails() +{ + // An invalid key must fail asynchronously before any shift arithmetic or + // fetch machinery runs + MockNam nam; + TerrariumTileFetcher source(nullptr, &nam); + QSignalSpy readySpy(&source, &HeightSource::patchHeightsReady); + QSignalSpy failedSpy(&source, &HeightSource::patchHeightsFailed); + + expectLogMessage("GeoMap.TerrariumTileFetcher", QtWarningMsg, + QRegularExpression(QStringLiteral("^requestPatchHeights rejected:"))); + const int requestId = source.requestPatchHeights(TileMath::TileKey{0, 0, -1}, kGridSize); + verifyExpectedLogMessage(); + QCOMPARE_GT(requestId, 0); + + QTRY_COMPARE_WITH_TIMEOUT(failedSpy.count(), 1, TestTimeout::mediumMs()); + QCOMPARE(failedSpy.first().at(0).toInt(), requestId); + QVERIFY(readySpy.isEmpty()); + QCOMPARE(nam.requestCount, 0); +} + +void TerrariumTileFetcherTest::_networkFallbackDeliversAndCaches() { // Distinct zoom from other tests: write-back puts this tile in the shared // per-process cache DB, which must not shadow other tests' generator path @@ -244,7 +297,7 @@ void TerrariumHeightSourceTest::_networkFallbackDeliversAndCaches() MockNam nam; nam.body = UnitTestTileGenerator::syntheticTerrariumTileData(key.x, key.y, key.zoom); - TerrariumHeightSource source(nullptr, &nam); + TerrariumTileFetcher source(nullptr, &nam); QSignalSpy readySpy(&source, &HeightSource::patchHeightsReady); // Two forced generator misses: the first request must fall back to the @@ -267,26 +320,31 @@ void TerrariumHeightSourceTest::_networkFallbackDeliversAndCaches() QCOMPARE(nam.requestCount, 1); // cached by write-back: no second network fetch } -void TerrariumHeightSourceTest::_networkErrorFails() +void TerrariumTileFetcherTest::_networkErrorFails() { constexpr int zoom = 12; MockNam nam; // empty body: canned network error - TerrariumHeightSource source(nullptr, &nam); + TerrariumTileFetcher source(nullptr, &nam); QSignalSpy readySpy(&source, &HeightSource::patchHeightsReady); QSignalSpy failedSpy(&source, &HeightSource::patchHeightsFailed); UnitTestTileGenerator::setForcedMissCount(1); const auto guard = qScopeGuard([] { UnitTestTileGenerator::setForcedMissCount(0); }); + // Fetch failures must be visible without logging configuration (matches + // the map tile / Copernicus convention); repeats are throttled + expectLogMessage("GeoMap.TerrariumTileFetcher", QtWarningMsg, QRegularExpression(QStringLiteral("failed"))); + const int requestId = source.requestPatchHeights(keyOver(flat10Region().center(), zoom), kGridSize); QTRY_COMPARE_WITH_TIMEOUT(failedSpy.count(), 1, TestTimeout::mediumMs()); QCOMPARE(failedSpy.first().at(0).toInt(), requestId); QCOMPARE(nam.requestCount, 1); QVERIFY(readySpy.isEmpty()); + verifyExpectedLogMessage(); } -void TerrariumHeightSourceTest::_networkGarbageBodyFailsAndNotCached() +void TerrariumTileFetcherTest::_networkGarbageBodyFailsAndNotCached() { // An HTTP-200 non-PNG body (e.g. an error page) must fail the request and // must NOT be written back to the cache @@ -295,13 +353,14 @@ void TerrariumHeightSourceTest::_networkGarbageBodyFailsAndNotCached() MockNam nam; nam.body = QByteArrayLiteral("this is not a png"); - TerrariumHeightSource source(nullptr, &nam); + TerrariumTileFetcher source(nullptr, &nam); QSignalSpy readySpy(&source, &HeightSource::patchHeightsReady); QSignalSpy failedSpy(&source, &HeightSource::patchHeightsFailed); UnitTestTileGenerator::setForcedMissCount(2); const auto guard = qScopeGuard([] { UnitTestTileGenerator::setForcedMissCount(0); }); + ignoreLogMessage("GeoMap.TerrariumTileFetcher", QtWarningMsg, QRegularExpression(QStringLiteral("failed"))); source.requestPatchHeights(key, kGridSize); QTRY_COMPARE_WITH_TIMEOUT(failedSpy.count(), 1, TestTimeout::mediumMs()); QCOMPARE(nam.requestCount, 1); @@ -314,7 +373,7 @@ void TerrariumHeightSourceTest::_networkGarbageBodyFailsAndNotCached() QCOMPARE(nam.requestCount, 2); } -void TerrariumHeightSourceTest::_networkWrongSizeImageFailsAndNotCached() +void TerrariumTileFetcherTest::_networkWrongSizeImageFailsAndNotCached() { // A decodable body that isn't a 256x256 tile (e.g. a CDN placeholder image) // is not elevation data: it must fail delivery, and caching it would poison @@ -329,13 +388,14 @@ void TerrariumHeightSourceTest::_networkWrongSizeImageFailsAndNotCached() QVERIFY(buffer.open(QIODevice::WriteOnly)); QVERIFY(placeholder.save(&buffer, "PNG")); - TerrariumHeightSource source(nullptr, &nam); + TerrariumTileFetcher source(nullptr, &nam); QSignalSpy readySpy(&source, &HeightSource::patchHeightsReady); QSignalSpy failedSpy(&source, &HeightSource::patchHeightsFailed); UnitTestTileGenerator::setForcedMissCount(2); const auto guard = qScopeGuard([] { UnitTestTileGenerator::setForcedMissCount(0); }); + ignoreLogMessage("GeoMap.TerrariumTileFetcher", QtWarningMsg, QRegularExpression(QStringLiteral("failed"))); source.requestPatchHeights(key, kGridSize); QTRY_COMPARE_WITH_TIMEOUT(failedSpy.count(), 1, TestTimeout::mediumMs()); QCOMPARE(nam.requestCount, 1); @@ -348,14 +408,45 @@ void TerrariumHeightSourceTest::_networkWrongSizeImageFailsAndNotCached() QCOMPARE(nam.requestCount, 2); } -void TerrariumHeightSourceTest::_duplicateRequestsShareOneFetch() +void TerrariumTileFetcherTest::_cachedUnusableTileFallsBackToNetwork() +{ + // Pre-seed the shared cache with an undecodable body: the cache-hit path + // must treat it as a miss and recover via the network — failing would + // wedge the tile on the same bytes every retry until cache eviction + constexpr int zoom = 15; // tile span ~0.8km: the +2 offset stays well inside the 11km flat region + const TileMath::TileKey centerKey = keyOver(flat10Region().center(), zoom); + // Offset off other tests' write-back keys: the seeded garbage stays in the + // shared per-process cache DB + const TileMath::TileKey key{centerKey.x + 2, centerKey.y, zoom}; + // The cache worker runs tasks in order: the store lands before the lookup + QGeoFileTileCacheQGC::cacheTile(QString::fromLatin1(TerrariumElevationProvider::kProviderKey), key.x, key.y, + key.zoom, QByteArrayLiteral("garbage"), QStringLiteral("png")); + + MockNam nam; + nam.body = UnitTestTileGenerator::syntheticTerrariumTileData(key.x, key.y, key.zoom); + TerrariumTileFetcher source(nullptr, &nam); + QSignalSpy readySpy(&source, &HeightSource::patchHeightsReady); + QSignalSpy failedSpy(&source, &HeightSource::patchHeightsFailed); + + source.requestPatchHeights(key, kGridSize); + QTRY_COMPARE_WITH_TIMEOUT(readySpy.count(), 1, TestTimeout::mediumMs()); + QCOMPARE(nam.requestCount, 1); // cache hit rejected, recovered from the network + QVERIFY(failedSpy.isEmpty()); + const auto heights = readySpy.first().at(1).value>(); + QCOMPARE(heights.count(), kExpectedCount); + for (float h : heights) { + QCOMPARE(h, static_cast(UnitTestTerrainData::Flat10Region::amslElevation)); + } +} + +void TerrariumTileFetcherTest::_duplicateRequestsShareOneFetch() { constexpr int zoom = 10; const TileMath::TileKey key = keyOver(flat10Region().center(), zoom); MockNam nam; nam.body = UnitTestTileGenerator::syntheticTerrariumTileData(key.x, key.y, key.zoom); - TerrariumHeightSource source(nullptr, &nam); + TerrariumTileFetcher source(nullptr, &nam); QSignalSpy readySpy(&source, &HeightSource::patchHeightsReady); UnitTestTileGenerator::setForcedMissCount(2); @@ -374,14 +465,14 @@ void TerrariumHeightSourceTest::_duplicateRequestsShareOneFetch() QCOMPARE(readyIds, expectedIds); } -void TerrariumHeightSourceTest::_cancelOneWaiterKeepsSharedFetchAlive() +void TerrariumTileFetcherTest::_cancelOneWaiterKeepsSharedFetchAlive() { constexpr int zoom = 9; const TileMath::TileKey key = keyOver(flat10Region().center(), zoom); MockNam nam; nam.body = UnitTestTileGenerator::syntheticTerrariumTileData(key.x, key.y, key.zoom); - TerrariumHeightSource source(nullptr, &nam); + TerrariumTileFetcher source(nullptr, &nam); QSignalSpy readySpy(&source, &HeightSource::patchHeightsReady); QSignalSpy failedSpy(&source, &HeightSource::patchHeightsFailed); @@ -398,7 +489,30 @@ void TerrariumHeightSourceTest::_cancelOneWaiterKeepsSharedFetchAlive() QVERIFY(failedSpy.isEmpty()); } -void TerrariumHeightSourceTest::_staleCancelledReplyDoesNotFailNewRequest() +void TerrariumTileFetcherTest::_cancelDestroysAbortedReply() +{ + // Cleanup must not rely on abort emitting finished (real QNetworkReply does, + // but it isn't guaranteed here — CannedReply::abort is a no-op) + constexpr int zoom = 1; + const TileMath::TileKey key = keyOver(flat10Region().center(), zoom); + + MockNam nam; + nam.body = UnitTestTileGenerator::syntheticTerrariumTileData(key.x, key.y, key.zoom); + nam.holdReplies = true; + TerrariumTileFetcher source(nullptr, &nam); + + UnitTestTileGenerator::setForcedMissCount(1); + const auto guard = qScopeGuard([] { UnitTestTileGenerator::setForcedMissCount(0); }); + + const int requestId = source.requestPatchHeights(key, kGridSize); + QTRY_COMPARE_WITH_TIMEOUT(nam.requestCount, 1, TestTimeout::mediumMs()); + + const QPointer reply(nam.replies.at(0)); + source.cancelRequest(requestId); + QTRY_VERIFY_WITH_TIMEOUT(reply.isNull(), TestTimeout::mediumMs()); +} + +void TerrariumTileFetcherTest::_staleCancelledReplyDoesNotFailNewRequest() { // A cancelled reply whose finished emission lands late must not touch the // state of a newer fetch for the same tile installed in the meantime @@ -408,7 +522,7 @@ void TerrariumHeightSourceTest::_staleCancelledReplyDoesNotFailNewRequest() MockNam nam; nam.body = UnitTestTileGenerator::syntheticTerrariumTileData(key.x, key.y, key.zoom); nam.holdReplies = true; - TerrariumHeightSource source(nullptr, &nam); + TerrariumTileFetcher source(nullptr, &nam); QSignalSpy readySpy(&source, &HeightSource::patchHeightsReady); QSignalSpy failedSpy(&source, &HeightSource::patchHeightsFailed); @@ -419,12 +533,14 @@ void TerrariumHeightSourceTest::_staleCancelledReplyDoesNotFailNewRequest() QTRY_COMPARE_WITH_TIMEOUT(nam.requestCount, 1, TestTimeout::mediumMs()); source.cancelRequest(requestId1); + // Real QNetworkReply::abort() emits finished synchronously inside + // cancelRequest; deliver it now, while the reply is still alive + // (cancelRequest schedules deleteLater on it). The stale guard must + // ignore it and a new fetch for the same tile must deliver normally + nam.replies.at(0)->finishAsAborted(); + const int requestId2 = source.requestPatchHeights(key, kGridSize); QTRY_COMPARE_WITH_TIMEOUT(nam.requestCount, 2, TestTimeout::mediumMs()); - - // The stale reply's abort error arrives only now, after the new fetch owns - // the tile: it must be ignored, and the new reply must deliver normally - nam.replies.at(0)->finishAsAborted(); nam.replies.at(1)->finishNormally(); QTRY_COMPARE_WITH_TIMEOUT(readySpy.count(), 1, TestTimeout::mediumMs()); @@ -432,4 +548,183 @@ void TerrariumHeightSourceTest::_staleCancelledReplyDoesNotFailNewRequest() QVERIFY(failedSpy.isEmpty()); } -UT_REGISTER_TEST(TerrariumHeightSourceTest, TestLabel::Integration, TestLabel::Terrain) +void TerrariumTileFetcherTest::_tileRequestPopulatesField() +{ + HeightField field; + TerrariumTileFetcher source; + source.setHeightField(&field); + QSignalSpy regionSpy(&field, &HeightField::regionChanged); + QVERIFY(regionSpy.isValid()); + + const TileMath::TileKey key = keyOver(flat10Region().center(), kFineZoom); + QVERIFY(source.requestTile(key)); + QCOMPARE(field.tileCount(), 0); // delivery is async, never re-entrant + + QTRY_COMPARE_WITH_TIMEOUT(regionSpy.count(), 1, TestTimeout::mediumMs()); + QCOMPARE(field.tileCount(), 1); + + // 10m encodes exactly in terrarium quanta; bilinear blend of equal corners + // may differ by rounding only + const double height = field.heightAt(TileMath::geoToWorld(flat10Region().center())); + QCOMPARE_LT(qAbs(height - UnitTestTerrainData::Flat10Region::amslElevation), 0.01); +} + +void TerrariumTileFetcherTest::_deepZoomTileRequestFetchesAncestor() +{ + // The dataset tops out at z15: a deeper tile request must fetch and store + // the z15 ancestor (the pyramid sub-windows it at sample time) + HeightField field; + TerrariumTileFetcher source; + source.setHeightField(&field); + QSignalSpy regionSpy(&field, &HeightField::regionChanged); + + constexpr int deepZoom = 18; + const TileMath::TileKey key = keyOver(flat10Region().center(), deepZoom); + QVERIFY(source.requestTile(key)); + + const TileMath::TileKey fetched = source.lastFetchKey(); + QCOMPARE(fetched.zoom, TerrariumTileFetcher::kMaxTileZoom); + QCOMPARE(fetched.x, key.x >> (deepZoom - TerrariumTileFetcher::kMaxTileZoom)); + QCOMPARE(fetched.y, key.y >> (deepZoom - TerrariumTileFetcher::kMaxTileZoom)); + + QTRY_COMPARE_WITH_TIMEOUT(regionSpy.count(), 1, TestTimeout::mediumMs()); + QCOMPARE(field.tileCount(), 1); + QCOMPARE(regionSpy[0][0].toRectF().width(), TileMath::tileSpanAtZoom(TerrariumTileFetcher::kMaxTileZoom)); + + const double height = field.heightAt(TileMath::geoToWorld(flat10Region().center())); + QCOMPARE_LT(qAbs(height - UnitTestTerrainData::Flat10Region::amslElevation), 0.01); +} + +void TerrariumTileFetcherTest::_duplicateTileRequestsShareOneFetch() +{ + constexpr int zoom = 6; + const TileMath::TileKey key = keyOver(flat10Region().center(), zoom); + + MockNam nam; + nam.body = UnitTestTileGenerator::syntheticTerrariumTileData(key.x, key.y, key.zoom); + HeightField field; + TerrariumTileFetcher source(nullptr, &nam); + source.setHeightField(&field); + QSignalSpy regionSpy(&field, &HeightField::regionChanged); + + UnitTestTileGenerator::setForcedMissCount(2); + const auto guard = qScopeGuard([] { UnitTestTileGenerator::setForcedMissCount(0); }); + + // Same tile requested twice before the fetch completes: one shared fetch, + // one insert + QVERIFY(source.requestTile(key)); + QVERIFY(source.requestTile(key)); + QTRY_COMPARE_WITH_TIMEOUT(regionSpy.count(), 1, TestTimeout::mediumMs()); + QCOMPARE(nam.requestCount, 1); + QCOMPARE(field.tileCount(), 1); +} + +void TerrariumTileFetcherTest::_heldTileNotRefetched() +{ + constexpr int zoom = 5; + const TileMath::TileKey key = keyOver(flat10Region().center(), zoom); + + MockNam nam; + nam.body = UnitTestTileGenerator::syntheticTerrariumTileData(key.x, key.y, key.zoom); + HeightField field; + TerrariumTileFetcher source(nullptr, &nam); + source.setHeightField(&field); + QSignalSpy regionSpy(&field, &HeightField::regionChanged); + + UnitTestTileGenerator::setForcedMissCount(3); + const auto guard = qScopeGuard([] { UnitTestTileGenerator::setForcedMissCount(0); }); + + QVERIFY(source.requestTile(key)); + QTRY_COMPARE_WITH_TIMEOUT(regionSpy.count(), 1, TestTimeout::mediumMs()); + QCOMPARE(nam.requestCount, 1); + + // The field already holds this tile: a new request is a satisfied no-op + QVERIFY(source.requestTile(key)); + QVERIFY_NO_SIGNAL_WAIT(regionSpy, TestTimeout::shortMs()); + QCOMPARE(nam.requestCount, 1); +} + +void TerrariumTileFetcherTest::_tileFetchFailureLeavesFieldIntact() +{ + constexpr int zoom = 4; + const TileMath::TileKey key = keyOver(flat10Region().center(), zoom); + + MockNam nam; // empty body: canned network error + HeightField field; + TerrariumTileFetcher source(nullptr, &nam); + source.setHeightField(&field); + QSignalSpy regionSpy(&field, &HeightField::regionChanged); + + UnitTestTileGenerator::setForcedMissCount(2); + const auto guard = qScopeGuard([] { UnitTestTileGenerator::setForcedMissCount(0); }); + + ignoreLogMessage("GeoMap.TerrariumTileFetcher", QtWarningMsg, QRegularExpression(QStringLiteral("failed"))); + QVERIFY(source.requestTile(key)); + QTRY_COMPARE_WITH_TIMEOUT(nam.requestCount, 1, TestTimeout::mediumMs()); + QVERIFY_NO_SIGNAL_WAIT(regionSpy, TestTimeout::shortMs()); + QCOMPARE(field.tileCount(), 0); + + // Failure clears the in-flight key: a retry fetches again and succeeds + nam.body = UnitTestTileGenerator::syntheticTerrariumTileData(key.x, key.y, key.zoom); + QVERIFY(source.requestTile(key)); + QTRY_COMPARE_WITH_TIMEOUT(regionSpy.count(), 1, TestTimeout::mediumMs()); + QCOMPARE(nam.requestCount, 2); + QCOMPARE(field.tileCount(), 1); +} + +void TerrariumTileFetcherTest::_fieldRequestKeepsFetchAliveAfterCancel() +{ + constexpr int zoom = 2; + const TileMath::TileKey key = keyOver(flat10Region().center(), zoom); + + MockNam nam; + nam.body = UnitTestTileGenerator::syntheticTerrariumTileData(key.x, key.y, key.zoom); + nam.holdReplies = true; + HeightField field; + TerrariumTileFetcher source(nullptr, &nam); + source.setHeightField(&field); + QSignalSpy regionSpy(&field, &HeightField::regionChanged); + QSignalSpy readySpy(&source, &HeightSource::patchHeightsReady); + + UnitTestTileGenerator::setForcedMissCount(1); + const auto guard = qScopeGuard([] { UnitTestTileGenerator::setForcedMissCount(0); }); + + const int requestId = source.requestPatchHeights(key, kGridSize); + QTRY_COMPARE_WITH_TIMEOUT(nam.requestCount, 1, TestTimeout::mediumMs()); + + // The field piggybacks on the patch fetch already in flight + QVERIFY(source.requestTile(key)); + QCOMPARE(nam.requestCount, 1); + + // Cancelling the last patch waiter must not abort a fetch the field wants + source.cancelRequest(requestId); + nam.replies.at(0)->finishNormally(); + + QTRY_COMPARE_WITH_TIMEOUT(regionSpy.count(), 1, TestTimeout::mediumMs()); + QCOMPARE(field.tileCount(), 1); + QVERIFY(readySpy.isEmpty()); // the cancelled patch request stays silent +} + +void TerrariumTileFetcherTest::_tileRequestGuards() +{ + MockNam nam; + TerrariumTileFetcher source(nullptr, &nam); + + // No field attached: rejected before any fetch machinery runs + expectLogMessage("GeoMap.TerrariumTileFetcher", QtWarningMsg, + QRegularExpression(QStringLiteral("^requestTile rejected:"))); + QVERIFY(!source.requestTile(keyOver(flat10Region().center(), 3))); + verifyExpectedLogMessage(); + + HeightField field; + source.setHeightField(&field); + expectLogMessage("GeoMap.TerrariumTileFetcher", QtWarningMsg, + QRegularExpression(QStringLiteral("^requestTile rejected:"))); + QVERIFY(!source.requestTile(TileMath::TileKey{0, 0, -1})); + verifyExpectedLogMessage(); + + QCOMPARE(nam.requestCount, 0); + QCOMPARE(field.tileCount(), 0); +} + +UT_REGISTER_TEST(TerrariumTileFetcherTest, TestLabel::Integration, TestLabel::Terrain) diff --git a/test/GeoMap/TerrariumHeightSourceTest.h b/test/GeoMap/TerrariumTileFetcherTest.h similarity index 55% rename from test/GeoMap/TerrariumHeightSourceTest.h rename to test/GeoMap/TerrariumTileFetcherTest.h index c454f37eb061..898bfb3c7423 100644 --- a/test/GeoMap/TerrariumHeightSourceTest.h +++ b/test/GeoMap/TerrariumTileFetcherTest.h @@ -2,7 +2,7 @@ #include "TerrainTest.h" -class TerrariumHeightSourceTest : public TerrainTest +class TerrariumTileFetcherTest : public TerrainTest { Q_OBJECT @@ -13,11 +13,22 @@ private slots: void _deepZoomSamplesAncestorTile(); void _cancelPreventsDelivery(); void _invalidGridSizeFails(); + void _oversizeGridSizeFails(); + void _invalidKeyFails(); void _networkFallbackDeliversAndCaches(); void _networkErrorFails(); void _networkGarbageBodyFailsAndNotCached(); void _networkWrongSizeImageFailsAndNotCached(); + void _cachedUnusableTileFallsBackToNetwork(); void _duplicateRequestsShareOneFetch(); void _cancelOneWaiterKeepsSharedFetchAlive(); + void _cancelDestroysAbortedReply(); void _staleCancelledReplyDoesNotFailNewRequest(); + void _tileRequestPopulatesField(); + void _deepZoomTileRequestFetchesAncestor(); + void _duplicateTileRequestsShareOneFetch(); + void _heldTileNotRefetched(); + void _tileFetchFailureLeavesFieldIntact(); + void _fieldRequestKeepsFetchAliveAfterCancel(); + void _tileRequestGuards(); }; diff --git a/test/GeoMap/TileImageSourceTest.cc b/test/GeoMap/TileImageSourceTest.cc index b369f3fcc83c..11e6ddf6b9cd 100644 --- a/test/GeoMap/TileImageSourceTest.cc +++ b/test/GeoMap/TileImageSourceTest.cc @@ -1,10 +1,21 @@ #include "TileImageSourceTest.h" +#include +#include +#include +#include +#include +#include +#include #include +#include + #include "QGCMapUrlEngine.h" +#include "QGeoFileTileCacheQGC.h" #include "TileImageSource.h" #include "TileMath.h" +#include "UnitTestTileGenerator.h" namespace { @@ -23,6 +34,92 @@ QString mapType() const TileMath::TileKey kKey{4302, 2867, 13}; // Zurich area +/// Serves a canned body (or error) asynchronously; held replies stay open +/// until finished manually +class CannedReply : public QNetworkReply +{ +public: + CannedReply(const QNetworkRequest& request, const QByteArray& body, NetworkError cannedError, QObject* parent, + bool hold) + : QNetworkReply(parent), _body(body), _cannedError(cannedError) + { + setRequest(request); + setOperation(QNetworkAccessManager::GetOperation); + (void) open(ReadOnly); + if (!hold) { + QTimer::singleShot(0, this, &CannedReply::finishNow); + } + } + + void finishNow() + { + if (_cannedError != NoError) { + setError(_cannedError, QStringLiteral("canned error")); + emit errorOccurred(_cannedError); + } + setFinished(true); + emit finished(); + } + + void abort() final {} + + qint64 bytesAvailable() const final { return (_body.size() - _offset) + QNetworkReply::bytesAvailable(); } + +protected: + qint64 readData(char* data, qint64 maxSize) final + { + const qint64 n = qMin(maxSize, _body.size() - _offset); + if (n <= 0) { + return (_offset >= _body.size()) ? -1 : 0; + } + (void) memcpy(data, _body.constData() + _offset, static_cast(n)); + _offset += n; + return n; + } + +private: + const QByteArray _body; + const NetworkError _cannedError; + qint64 _offset = 0; +}; + +/// Counts requests and serves the configured body/error +class MockNam : public QNetworkAccessManager +{ +public: + using QNetworkAccessManager::QNetworkAccessManager; + + QByteArray body; + QNetworkReply::NetworkError error = QNetworkReply::NoError; + int requestCount = 0; + bool holdReplies = false; ///< replies stay open until finished manually + QList replies; ///< creation order, only tracked while holding + +protected: + QNetworkReply* createRequest(Operation, const QNetworkRequest& request, QIODevice*) final + { + requestCount++; + CannedReply* const reply = new CannedReply(request, body, error, this, holdReplies); + if (holdReplies) { + replies.append(reply); + } + return reply; + } +}; + +/// Any decodable image works as a network tile body +QByteArray pngBody() +{ + QImage image(32, 32, QImage::Format_RGB32); + image.fill(Qt::darkGreen); + QByteArray data; + QBuffer buffer(&data); + if (!buffer.open(QIODevice::WriteOnly) || !image.save(&buffer, "PNG")) { + return QByteArray(); + } + return data; +} + } // namespace void TileImageSourceTest::_tileDelivered() @@ -92,11 +189,13 @@ void TileImageSourceTest::_multipleRequestsIndependent() void TileImageSourceTest::_unknownProviderFails() { - // The provider lookup deliberately warns once at construction + // Both the provider lookup and the source itself deliberately warn once at construction expectLogMessage("QtLocationPlugin.QGCMapUrlEngine", QtWarningMsg, QRegularExpression(QStringLiteral("type not found: \"No Such Provider\""))); + expectLogMessage("GeoMap.TileImageSource", QtWarningMsg, QRegularExpression(QStringLiteral("unknown map type"))); TileImageSource source(QStringLiteral("No Such Provider")); verifyExpectedLogMessage(); + verifyExpectedLogMessage(); QSignalSpy readySpy(&source, &TileImageSource::tileImageReady); QSignalSpy failedSpy(&source, &TileImageSource::tileImageFailed); @@ -111,4 +210,202 @@ void TileImageSourceTest::_unknownProviderFails() QCOMPARE(source.pendingCount(), 0); } +void TileImageSourceTest::_networkFallbackDeliversAndCaches() +{ + const QString type = mapType(); + QVERIFY2(!type.isEmpty(), "no non-elevation map provider registered"); + const TileMath::TileKey key{kKey.x + 10, kKey.y, kKey.zoom}; // unique: write-back caches this tile + + MockNam nam; + nam.body = pngBody(); + QVERIFY(!nam.body.isEmpty()); + TileImageSource source(type, nullptr, &nam); + QSignalSpy readySpy(&source, &TileImageSource::tileImageReady); + QSignalSpy failedSpy(&source, &TileImageSource::tileImageFailed); + + // Two forced generator misses: the first request must fall back to the + // network; the second must be served by the write-back cached tile (the + // DB hit precedes the generator, so the second miss stays unconsumed) + UnitTestTileGenerator::setForcedMissCount(2); + const auto guard = qScopeGuard([] { UnitTestTileGenerator::setForcedMissCount(0); }); + + source.requestTileImage(key); + QTRY_COMPARE_WITH_TIMEOUT(readySpy.count(), 1, 5000); + QCOMPARE(nam.requestCount, 1); + QVERIFY(!readySpy.first().at(1).value().isNull()); + + source.requestTileImage(key); + QTRY_COMPARE_WITH_TIMEOUT(readySpy.count(), 2, 5000); + QCOMPARE(nam.requestCount, 1); // cached by write-back: no second network fetch + QVERIFY(failedSpy.isEmpty()); +} + +void TileImageSourceTest::_networkErrorFails() +{ + const QString type = mapType(); + QVERIFY2(!type.isEmpty(), "no non-elevation map provider registered"); + const TileMath::TileKey key{kKey.x + 11, kKey.y, kKey.zoom}; + + MockNam nam; + nam.error = QNetworkReply::ContentNotFoundError; + TileImageSource source(type, nullptr, &nam); + QSignalSpy readySpy(&source, &TileImageSource::tileImageReady); + QSignalSpy failedSpy(&source, &TileImageSource::tileImageFailed); + + UnitTestTileGenerator::setForcedMissCount(1); + const auto guard = qScopeGuard([] { UnitTestTileGenerator::setForcedMissCount(0); }); + + // Fetch failures must be visible without logging configuration + expectLogMessage("GeoMap.TileImageSource", QtWarningMsg, QRegularExpression(QStringLiteral("network error"))); + const int requestId = source.requestTileImage(key); + QTRY_COMPARE_WITH_TIMEOUT(failedSpy.count(), 1, 5000); + verifyExpectedLogMessage(); + QCOMPARE(failedSpy.first().at(0).toInt(), requestId); + QCOMPARE(nam.requestCount, 1); + QVERIFY(readySpy.isEmpty()); +} + +void TileImageSourceTest::_networkEmptyBodyFails() +{ + const QString type = mapType(); + QVERIFY2(!type.isEmpty(), "no non-elevation map provider registered"); + const TileMath::TileKey key{kKey.x + 12, kKey.y, kKey.zoom}; + + MockNam nam; // NoError with an empty body: HTTP success carrying nothing + TileImageSource source(type, nullptr, &nam); + QSignalSpy readySpy(&source, &TileImageSource::tileImageReady); + QSignalSpy failedSpy(&source, &TileImageSource::tileImageFailed); + + UnitTestTileGenerator::setForcedMissCount(1); + const auto guard = qScopeGuard([] { UnitTestTileGenerator::setForcedMissCount(0); }); + + expectLogMessage("GeoMap.TileImageSource", QtWarningMsg, QRegularExpression(QStringLiteral("empty body"))); + const int requestId = source.requestTileImage(key); + QTRY_COMPARE_WITH_TIMEOUT(failedSpy.count(), 1, 5000); + verifyExpectedLogMessage(); + QCOMPARE(failedSpy.first().at(0).toInt(), requestId); + QVERIFY(readySpy.isEmpty()); +} + +void TileImageSourceTest::_networkGarbageBodyFailsAndNotCached() +{ + // An HTTP-200 non-image body (e.g. an error page) must fail the request + // and must NOT be written back to the cache — a cached invalid tile would + // fail every retry from then on + const QString type = mapType(); + QVERIFY2(!type.isEmpty(), "no non-elevation map provider registered"); + const TileMath::TileKey key{kKey.x + 13, kKey.y, kKey.zoom}; + + MockNam nam; + nam.body = QByteArrayLiteral("this is not an image"); + TileImageSource source(type, nullptr, &nam); + QSignalSpy readySpy(&source, &TileImageSource::tileImageReady); + QSignalSpy failedSpy(&source, &TileImageSource::tileImageFailed); + + UnitTestTileGenerator::setForcedMissCount(2); + const auto guard = qScopeGuard([] { UnitTestTileGenerator::setForcedMissCount(0); }); + + expectLogMessage("GeoMap.TileImageSource", QtWarningMsg, QRegularExpression(QStringLiteral("failed to decode"))); + source.requestTileImage(key); + QTRY_COMPARE_WITH_TIMEOUT(failedSpy.count(), 1, 5000); + verifyExpectedLogMessage(); + QCOMPARE(nam.requestCount, 1); + + // Garbage was not cached: the retry misses again and re-fetches, this + // time getting a valid image + nam.body = pngBody(); + source.requestTileImage(key); + QTRY_COMPARE_WITH_TIMEOUT(readySpy.count(), 1, 5000); + QCOMPARE(nam.requestCount, 2); +} + +void TileImageSourceTest::_bingPlaceholderNotDelivered() +{ + // Bing serves a placeholder image instead of an HTTP error where it has + // no imagery: it decodes fine, but delivering or caching it would drape + // it onto patches + QFile file(QStringLiteral(":/res/BingNoTileBytes.dat")); + QVERIFY(file.open(QFile::ReadOnly)); + const QByteArray placeholder = file.readAll(); + QVERIFY(!placeholder.isEmpty()); + + const TileMath::TileKey key{kKey.x + 14, kKey.y, kKey.zoom}; + MockNam nam; + nam.body = placeholder; + TileImageSource source(QStringLiteral("Bing Hybrid"), nullptr, &nam); + QSignalSpy readySpy(&source, &TileImageSource::tileImageReady); + QSignalSpy failedSpy(&source, &TileImageSource::tileImageFailed); + + UnitTestTileGenerator::setForcedMissCount(1); + const auto guard = qScopeGuard([] { UnitTestTileGenerator::setForcedMissCount(0); }); + + expectLogMessage("GeoMap.TileImageSource", QtWarningMsg, QRegularExpression(QStringLiteral("no-tile placeholder"))); + source.requestTileImage(key); + QTRY_COMPARE_WITH_TIMEOUT(failedSpy.count(), 1, 5000); + verifyExpectedLogMessage(); + QCOMPARE(nam.requestCount, 1); + QVERIFY(readySpy.isEmpty()); +} + +void TileImageSourceTest::_cachedUnusableTileFallsBackToNetwork() +{ + // Pre-seed the shared cache with an undecodable body: the cache-hit path + // must treat it as a miss and recover via the network — failing would + // wedge the tile on the same bytes every paced retry + const QString type = mapType(); + QVERIFY2(!type.isEmpty(), "no non-elevation map provider registered"); + const TileMath::TileKey key{kKey.x + 15, kKey.y, kKey.zoom}; + // The cache worker runs tasks in order: the store lands before the lookup + QGeoFileTileCacheQGC::cacheTile(type, key.x, key.y, key.zoom, QByteArrayLiteral("garbage"), QStringLiteral("png")); + + MockNam nam; + nam.body = pngBody(); + QVERIFY(!nam.body.isEmpty()); + TileImageSource source(type, nullptr, &nam); + QSignalSpy readySpy(&source, &TileImageSource::tileImageReady); + QSignalSpy failedSpy(&source, &TileImageSource::tileImageFailed); + + const int requestId = source.requestTileImage(key); + QTRY_COMPARE_WITH_TIMEOUT(readySpy.count(), 1, 5000); + QCOMPARE(readySpy.first().at(0).toInt(), requestId); + QCOMPARE(nam.requestCount, 1); // cache hit rejected, recovered from the network + QVERIFY(failedSpy.isEmpty()); + + // Write-back is INSERT OR IGNORE, so the bad entry survives: a repeat + // request pays another network fetch but still delivers + source.requestTileImage(key); + QTRY_COMPARE_WITH_TIMEOUT(readySpy.count(), 2, 5000); + QCOMPARE(nam.requestCount, 2); + QVERIFY(failedSpy.isEmpty()); +} + +void TileImageSourceTest::_cancelAbortsNetworkFetch() +{ + const QString type = mapType(); + QVERIFY2(!type.isEmpty(), "no non-elevation map provider registered"); + const TileMath::TileKey key{kKey.x + 16, kKey.y, kKey.zoom}; + + MockNam nam; + nam.body = pngBody(); + nam.holdReplies = true; + TileImageSource source(type, nullptr, &nam); + QSignalSpy readySpy(&source, &TileImageSource::tileImageReady); + QSignalSpy failedSpy(&source, &TileImageSource::tileImageFailed); + + UnitTestTileGenerator::setForcedMissCount(1); + const auto guard = qScopeGuard([] { UnitTestTileGenerator::setForcedMissCount(0); }); + + const int requestId = source.requestTileImage(key); + QTRY_COMPARE_WITH_TIMEOUT(nam.requestCount, 1, 5000); + + source.cancelRequest(requestId); + QCOMPARE(source.pendingCount(), 0); + source.cancelRequest(requestId); // double cancel is a no-op + + // The aborted reply's late finished emission must stay silent + nam.replies.at(0)->finishNow(); + QVERIFY_NO_SIGNAL_WAIT(readySpy, TestTimeout::shortMs()); + QVERIFY(failedSpy.isEmpty()); +} + UT_REGISTER_TEST(TileImageSourceTest, TestLabel::Integration) diff --git a/test/GeoMap/TileImageSourceTest.h b/test/GeoMap/TileImageSourceTest.h index f6f7779cdac6..87c30524a053 100644 --- a/test/GeoMap/TileImageSourceTest.h +++ b/test/GeoMap/TileImageSourceTest.h @@ -11,4 +11,11 @@ private slots: void _cancelledRequestSilent(); void _multipleRequestsIndependent(); void _unknownProviderFails(); + void _networkFallbackDeliversAndCaches(); + void _networkErrorFails(); + void _networkEmptyBodyFails(); + void _networkGarbageBodyFailsAndNotCached(); + void _bingPlaceholderNotDelivered(); + void _cachedUnusableTileFallsBackToNetwork(); + void _cancelAbortsNetworkFetch(); }; diff --git a/test/GeoMap/TileMathTest.cc b/test/GeoMap/TileMathTest.cc index 494d468b61c5..4a35a75e2607 100644 --- a/test/GeoMap/TileMathTest.cc +++ b/test/GeoMap/TileMathTest.cc @@ -121,4 +121,18 @@ void TileMathTest::_mercatorScale() QCOMPARE(mercatorScale(89.0), mercatorScale(kMaxLatitude)); } +void TileMathTest::_isValidKey() +{ + QVERIFY(isValidKey(TileKey{0, 0, 0})); + QVERIFY(isValidKey(TileKey{7, 0, 3})); + QVERIFY(isValidKey(TileKey{(1 << kMaxZoom) - 1, (1 << kMaxZoom) - 1, kMaxZoom})); + + QVERIFY(!isValidKey(TileKey{0, 0, -1})); + QVERIFY(!isValidKey(TileKey{0, 0, kMaxZoom + 1})); + QVERIFY(!isValidKey(TileKey{-1, 0, 3})); + QVERIFY(!isValidKey(TileKey{8, 0, 3})); + QVERIFY(!isValidKey(TileKey{0, -1, 3})); + QVERIFY(!isValidKey(TileKey{0, 8, 3})); +} + UT_REGISTER_TEST_LIGHTWEIGHT(TileMathTest, TestLabel::Unit) diff --git a/test/GeoMap/TileMathTest.h b/test/GeoMap/TileMathTest.h index eab451b8868e..36b821cb4bfc 100644 --- a/test/GeoMap/TileMathTest.h +++ b/test/GeoMap/TileMathTest.h @@ -17,4 +17,5 @@ private slots: void _tileEdgeClamp(); void _zoomForMetersPerPixel(); void _mercatorScale(); + void _isValidKey(); }; diff --git a/test/QmlUITests/FlyViewGeoUITest.cc b/test/QmlUITests/FlyViewGeoUITest.cc index b8fd35ff2cab..57b418906aa7 100644 --- a/test/QmlUITests/FlyViewGeoUITest.cc +++ b/test/QmlUITests/FlyViewGeoUITest.cc @@ -10,6 +10,7 @@ #include #include #include + #include #include "Fact.h" @@ -294,7 +295,7 @@ void FlyViewGeoUITest::_testModeToggleAndCompass() // affordance) swaps the terrain height source for analytic sin-hills: toggling // on delivers non-flat heights, toggling off flattens again. The test scene // sits outside the synthetic terrain regions, so the terrain source itself -// delivers all-zero heights here (see TerrariumHeightSourceTest for real +// delivers all-zero heights here (see TerrariumTileFetcherTest for real // elevations). void FlyViewGeoUITest::_testDebugHillsToggle() {