QGroundControl
Ground Control Station for MAVLink Drones
Loading...
Searching...
No Matches
SurfacePatchModel.cc
Go to the documentation of this file.
1#include "SurfacePatchModel.h"
2
3#include <QtCore/QByteArray>
4#include <QtCore/QDateTime>
5#include <QtCore/QDebug>
6#include <QtCore/QDir>
7#include <QtCore/QFile>
8#include <QtCore/QTimer>
9#include <QtCore/QtMath>
10#include <QtGui/QPainter>
11
12#include <algorithm>
13#include <cmath>
14#include <utility>
15
16#include "GeoMapCamera.h"
17#include "GeoScene.h"
18#include "HeightField.h"
19#include "HeightSource.h"
20#include "QGCLoggingCategory.h"
21#include "SurfaceAnalysis.h"
22#include "SurfaceModel.h"
24#include "TileImageSource.h"
25
26QGC_LOGGING_CATEGORY(GeoMapSurfacePatchModelLog, "GeoMap.SurfacePatchModel")
27QGC_LOGGING_CATEGORY(GeoMapSurfacePatchModelVerboseLog, "GeoMap.SurfacePatchModel.Verbose")
28
29SurfacePatchModel::SurfacePatchModel(QObject* parent) : QAbstractListModel(parent) {}
30
32
34{
35 if (scene == _scene) {
36 return;
37 }
38 if (_scene) {
39 disconnect(_scene, nullptr, this, nullptr);
40 }
41 _scene = scene;
42 emit sceneChanged();
43
44 if (_scene) {
45 // cameraChanged also fires on camera destruction (scene clears its pointer)
46 connect(_scene, &GeoScene::cameraChanged, this, &SurfacePatchModel::_rebuildSurfaceModel);
47 connect(_scene, &GeoScene::sceneOriginChanged, this, &SurfacePatchModel::_sceneOriginChanged);
48 connect(_scene, &QObject::destroyed, this, [this] {
49 _scene = nullptr;
50 emit sceneChanged();
51 _rebuildSurfaceModel();
52 });
53 }
54 _rebuildSurfaceModel();
55}
56
57GeoMapCamera* SurfacePatchModel::_camera() const
58{
59 return _scene ? _scene->camera() : nullptr;
60}
61
62void SurfacePatchModel::_scheduleStatsChanged()
63{
64 // Patch events arrive in bursts (up to hundreds/s during churn); one
65 // emit per event-loop pass bounds the QML binding re-evaluations
66 if (_statsNotifyPending) {
67 return;
68 }
69 _statsNotifyPending = true;
70 QTimer::singleShot(0, this, [this] {
71 _statsNotifyPending = false;
72 emit statsChanged();
73 });
74}
75
76#ifdef QGC_UNITTEST_BUILD
77void SurfacePatchModel::drainUpdates()
78{
79 if (_surfaceModel) {
80 _surfaceModel->drainUpdates();
81 }
82}
83#endif
84
86{
87 if (terrain == _terrain) {
88 return;
89 }
90 qCDebug(GeoMapSurfacePatchModelLog) << "terrain" << _terrain << "->" << terrain;
91 _terrain = terrain;
92 emit terrainChanged();
93 _rebuildSurfaceModel();
94}
95
97{
98 if (debugHills == _debugHills) {
99 return;
100 }
101 qCDebug(GeoMapSurfacePatchModelLog) << "debugHills" << _debugHills << "->" << debugHills;
102 _debugHills = debugHills;
103 emit debugHillsChanged();
104 _rebuildSurfaceModel();
105}
106
107void SurfacePatchModel::setMapType(const QString& mapType)
108{
109 if (mapType == _mapType) {
110 return;
111 }
112 qCDebug(GeoMapSurfacePatchModelLog) << "mapType" << _mapType << "->" << mapType;
113 _mapType = mapType;
114 emit mapTypeChanged();
115
116 _resetImagery();
117 delete _tileSource;
118 _tileSource = nullptr;
119 if (!_mapType.isEmpty()) {
120 _tileSource = new TileImageSource(_mapType, this, _tileImageNetworkManager);
121 connect(_tileSource, &TileImageSource::tileImageReady, this, &SurfacePatchModel::_tileImageReady);
122 connect(_tileSource, &TileImageSource::tileImageFailed, this, &SurfacePatchModel::_tileImageFailed);
123 for (const TileMath::TileKey& key : std::as_const(_keys)) {
124 _requestTileImage(key);
125 }
126 }
127}
128
129void SurfacePatchModel::_resetImagery()
130{
131 if (_tileSource) {
132 // Cancelled requests never signal, so iterating the live map is safe
133 for (auto it = _imageRequestKey.cbegin(); it != _imageRequestKey.cend(); ++it) {
134 _tileSource->cancelRequest(it.key());
135 }
136 }
137 _imageRequestKey.clear();
138 _imageRequestByKey.clear();
139 _failedImageKeys.clear();
140 if (_imageRetryTimer) {
141 _imageRetryTimer->stop();
142 }
143 _retiredOrder.clear();
144 _fallbackCache.clear();
145 if (!_tileImages.isEmpty()) {
146 _tileImages.clear();
147 if (!_keys.isEmpty()) {
148 emit dataChanged(index(0), index(_keys.count() - 1), {TileImageRole, HasTileImageRole});
149 }
150 }
151}
152
153void SurfacePatchModel::_requestTileImage(const TileMath::TileKey& key)
154{
155 if (!_tileSource) {
156 return;
157 }
158 const int requestId = _tileSource->requestTileImage(key);
159 _imageRequestKey.insert(requestId, key);
160 _imageRequestByKey.insert(key, requestId);
161}
162
163void SurfacePatchModel::_tileImageReady(int requestId, const QImage& image)
164{
165 const auto it = _imageRequestKey.constFind(requestId);
166 if (it == _imageRequestKey.constEnd()) {
167 return;
168 }
169 const TileMath::TileKey key = it.value();
170 _imageRequestKey.erase(it);
171 _imageRequestByKey.remove(key);
172
173 const int row = _keys.indexOf(key);
174 if (row < 0) {
175 return;
176 }
177 _tileImages.insert(key, image);
178 _fallbackCache.remove(key); // own image supersedes any cached miss
179 qCDebug(GeoMapSurfacePatchModelVerboseLog) << "tile image ready for" << key;
180 _invalidateFallbacks(key);
181 _notifyTileImageChanged(key);
182}
183
184void SurfacePatchModel::_invalidateFallbacks(const TileMath::TileKey& tile)
185{
186 // A patch's fallback sources from its direct children and its ancestors
187 // (up to kMaxAncestorFallbackLevels), so a changed tile invalidates its
188 // parent's composite and every cached descendant's crop. Fallbacks read
189 // only delivered images, never other fallbacks, so invalidation never
190 // cascades further.
191 if (tile.zoom > TileMath::kMinZoom) {
192 _fallbackCache.remove(TileMath::TileKey{tile.x >> 1, tile.y >> 1, tile.zoom - 1});
193 }
194 for (auto it = _fallbackCache.begin(); it != _fallbackCache.end();) {
195 const int down = it.key().zoom - tile.zoom;
196 if ((down > 0) && (down <= kMaxAncestorFallbackLevels) && ((it.key().x >> down) == tile.x) &&
197 ((it.key().y >> down) == tile.y)) {
198 it = _fallbackCache.erase(it);
199 } else {
200 ++it;
201 }
202 }
203}
204
205void SurfacePatchModel::_notifyTileImageChanged(const TileMath::TileKey& key)
206{
207 // A delivered tile changes its own row plus the rows whose fallback
208 // sources from it: descendants (ancestor crop) and the direct parent
209 // (child composite). Rows with their own image are unaffected.
210 for (int row = 0; row < _keys.count(); row++) {
211 const TileMath::TileKey& rowKey = _keys.at(row);
212 bool affected = (rowKey == key);
213 if (!affected && !_tileImages.contains(rowKey)) {
214 if ((key.zoom > TileMath::kMinZoom) && (rowKey.zoom == (key.zoom - 1))) {
215 affected = (rowKey.x == (key.x >> 1)) && (rowKey.y == (key.y >> 1));
216 } else if (rowKey.zoom > key.zoom) {
217 const int up = rowKey.zoom - key.zoom;
218 affected =
219 (up <= kMaxAncestorFallbackLevels) && ((rowKey.x >> up) == key.x) && ((rowKey.y >> up) == key.y);
220 }
221 }
222 if (affected) {
223 const QModelIndex idx = index(row);
224 emit dataChanged(idx, idx, {TileImageRole, HasTileImageRole});
225 }
226 }
227}
228
229void SurfacePatchModel::_tileImageFailed(int requestId)
230{
231 const auto it = _imageRequestKey.constFind(requestId);
232 if (it == _imageRequestKey.constEnd()) {
233 return;
234 }
235 const TileMath::TileKey key = it.value();
236 _imageRequestByKey.remove(key);
237 _imageRequestKey.erase(it);
238
239 // The delegate keeps its fallback meanwhile; a resident patch retries on
240 // the paced timer (patch churn cannot be relied on to re-request - a
241 // patch that stays resident would keep the loading fallback forever)
242 if (!_keys.contains(key)) {
243 return;
244 }
245 qCDebug(GeoMapSurfacePatchModelLog) << "tile image failed for" << key << "(retry scheduled)";
246 _failedImageKeys.insert(key);
247 if (!_imageRetryTimer) {
248 _imageRetryTimer = new QTimer(this);
249 _imageRetryTimer->setSingleShot(true);
250 _imageRetryTimer->setInterval(kImageRetryMs);
251 connect(_imageRetryTimer, &QTimer::timeout, this, &SurfacePatchModel::_retryFailedImages);
252 }
253 if (!_imageRetryTimer->isActive()) {
254 _imageRetryTimer->start();
255 }
256}
257
258void SurfacePatchModel::_retryFailedImages()
259{
260 const QSet<TileMath::TileKey> failed = std::exchange(_failedImageKeys, {});
261 for (const TileMath::TileKey& key : failed) {
262 // Re-check residency and in-flight state: churn may have re-requested
263 // or removed the patch since the failure
264 if (!_keys.contains(key) || _tileImages.contains(key) || _imageRequestByKey.contains(key)) {
265 continue;
266 }
267 _requestTileImage(key);
268 }
269}
270
271void SurfacePatchModel::_rebuildSurfaceModel()
272{
273 beginResetModel();
274 _keys.clear();
275 _resetImagery();
276 delete _surfaceModel;
277 _surfaceModel = nullptr;
278 delete _heightSource;
279 _heightSource = nullptr;
280 delete _heightField;
281 _heightField = nullptr;
282
283 GeoMapCamera* const camera = _camera();
284 if (camera) {
285 if (_debugHills) {
286 _heightSource = new DebugHeightSource(this);
287 } else if (_terrain) {
288 _heightSource = new TerrariumTileFetcher(this);
289 } else {
290 _heightSource = new FlatHeightSource(this);
291 }
292 qCDebug(GeoMapSurfacePatchModelLog) << "rebuilding surface model, height source:"
293 << (_debugHills ? "debug hills" : (_terrain ? "terrain" : "flat"));
294 _heightField = new HeightField(this);
295 _heightSource->setHeightField(_heightField);
297 _surfaceModel = new SurfaceModel(camera, _heightSource, _heightField, this);
298 connect(_surfaceModel, &SurfaceModel::patchAdded, this, &SurfacePatchModel::_patchAdded);
299 connect(_surfaceModel, &SurfaceModel::patchMeshChanged, this, &SurfacePatchModel::_patchReady);
300 connect(_surfaceModel, &SurfaceModel::patchEdgeDeltasChanged, this,
301 &SurfacePatchModel::_patchEdgeDeltasChanged);
302 connect(_surfaceModel, &SurfaceModel::patchRemoved, this, &SurfacePatchModel::_patchRemoved);
303 }
304 endResetModel();
305
306 if (_surfaceModel) {
307 _surfaceModel->update();
308 }
309 emit statsChanged();
310 // Field replacement invalidates every previous height answer
312}
313
314double SurfacePatchModel::terrainHeightAt(const QGeoCoordinate& coordinate) const
315{
316 // Invalid coordinates carry NaN lat/lon, which the height lookup cannot digest
317 if (!_heightField || !coordinate.isValid()) {
318 return 0.0;
319 }
320 return _heightField->heightAt(TileMath::geoToWorld(coordinate));
321}
322
324{
326}
327
332
334{
335 return _surfaceModel ? _surfaceModel->pendingCount() : 0;
336}
337
339{
340 int maxZoom = 0;
341 for (const TileMath::TileKey& key : _keys) {
342 maxZoom = std::max(maxZoom, key.zoom);
343 }
344 return maxZoom;
345}
346
347void SurfacePatchModel::_startStatsSampling()
348{
349 if (!_statsTimer) {
350 _statsTimer = new QTimer(this);
351 _statsTimer->setInterval(1000);
352 connect(_statsTimer, &QTimer::timeout, this, &SurfacePatchModel::_statsTick);
353 }
354 _statAdds = 0;
355 _statRemoves = 0;
356 _statComposites = 0;
357 if (_surfaceModel) {
358 _surfaceModel->takeUpdateStats(); // discard pre-window accumulation
359 }
360 _statsTimer->start();
361}
362
364{
365 if (enabled == _statsEnabled) {
366 return;
367 }
368 _statsEnabled = enabled;
369 emit statsEnabledChanged();
370
371 // The sampler is shared with capture recording, which owns it while
372 // capturing: the overlay toggling must not reset or stop a live capture
373 if (_statsEnabled) {
374 if (!_capturing) {
375 _startStatsSampling();
376 }
377 } else if (!_capturing && _statsTimer) {
378 _statsTimer->stop();
379 }
380 _statsText.clear();
381 emit statsTextChanged();
382}
383
384void SurfacePatchModel::_statsTick()
385{
386 const SurfaceModel::UpdateStats stats =
387 _surfaceModel ? _surfaceModel->takeUpdateStats() : SurfaceModel::UpdateStats{};
388 const double avgMs = stats.updates ? (stats.totalUs / 1000.0) / stats.updates : 0.0;
389 const double maxMs = stats.maxUs / 1000.0;
390 _statsText = QStringLiteral("upd/s %1 avg %2 ms max %3 ms | patch +%4 -%5 /s comp/s %6")
391 .arg(stats.updates)
392 .arg(avgMs, 0, 'f', 2)
393 .arg(maxMs, 0, 'f', 2)
394 .arg(_statAdds)
395 .arg(_statRemoves)
396 .arg(_statComposites);
397 if (_capturing) {
398 _captureWorstMaxMs = std::max(_captureWorstMaxMs, maxMs);
399 _captureWorstAvgMs = std::max(_captureWorstAvgMs, avgMs);
400 GeoMapCamera* const camera = _camera();
401 _captureRows.append(QStringLiteral("%1,%2,%3,%4,%5,%6,%7,%8,%9,%10,%11,%12")
402 .arg(_captureClock.elapsed() / 1000.0, 0, 'f', 1)
403 .arg(stats.updates)
404 .arg(avgMs, 0, 'f', 3)
405 .arg(maxMs, 0, 'f', 3)
406 .arg(_statAdds)
407 .arg(_statRemoves)
408 .arg(_statComposites)
409 .arg(patchCount())
410 .arg(pendingCount())
411 .arg(maxZoomLevel())
412 .arg(camera ? camera->distance() : 0.0, 0, 'f', 0)
413 .arg(camera ? camera->tilt() : 0.0, 0, 'f', 1));
414 }
415 _statAdds = 0;
416 _statRemoves = 0;
417 _statComposites = 0;
418 emit statsTextChanged();
419}
420
422{
423 if (_capturing) {
424 return;
425 }
426 _capturing = true;
427 emit capturingChanged();
428
429 _captureRows.clear();
430 _captureWorstMaxMs = 0.0;
431 _captureWorstAvgMs = 0.0;
432 _captureRows.append(
433 QStringLiteral("time_s,updates,avg_ms,max_ms,patch_adds,patch_removes,composites,patches,pending,max_zoom,cam_"
434 "dist_m,cam_tilt_deg"));
435 _captureClock.start();
436 // Run the sampler directly rather than via statsEnabled: that property is
437 // QML-bound to the overlay toggle, and writing it here would fight the
438 // binding (and leave the sampler running after the capture ends)
439 _startStatsSampling();
440}
441
443{
444 if (!_capturing) {
445 return QString();
446 }
447 _statsTick(); // flush the partial interval: the final settle pass must reach the CSV and worst-case values
448 _capturing = false;
449 emit capturingChanged();
450 if (!_statsEnabled && _statsTimer) {
451 _statsTimer->stop();
452 }
453
454 const QString path = QDir::temp().filePath(
455 QStringLiteral("qgc-geomap-capture-%1.csv").arg(QDateTime::currentDateTime().toString("yyyyMMdd-hhmmss")));
456 QFile file(path);
457 if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
458 qWarning() << "GeoMap capture: cannot write" << path;
459 _statsText = QStringLiteral("capture failed: %1").arg(path);
460 emit statsTextChanged();
461 return QString();
462 }
463 const QByteArray data = _captureRows.join(QLatin1Char('\n')).toUtf8() + '\n';
464 const bool written = (file.write(data) == data.size()) && file.flush();
465 file.close();
466 _captureRows.clear();
467 if (!written) {
468 (void) file.remove(); // never hand back a truncated CSV
469 qWarning() << "GeoMap capture: write failed" << path;
470 _statsText = QStringLiteral("capture failed: %1").arg(path);
471 emit statsTextChanged();
472 return QString();
473 }
474
475 // No log output on success: the overlay shows the path, and UI tests run
476 // with strict log checking. The worst-case summary makes the overlay a
477 // one-glance verdict without reading the CSV.
478 _statsText = QStringLiteral("capture: worst max %1 ms worst avg %2 ms -> %3")
479 .arg(_captureWorstMaxMs, 0, 'f', 2)
480 .arg(_captureWorstAvgMs, 0, 'f', 2)
481 .arg(path);
482 emit statsTextChanged();
483 return path;
484}
485
487{
488 GeoMapCamera* const camera = _camera();
489 if (!_surfaceModel || !camera) {
490 qDebug() << "Surface analysis: no surface model active";
491 return;
492 }
493
495
496 // Unproject a dense screen grid to ground points: coverage is checked
497 // where the screen actually looks, independent of the model's own
498 // visible-region estimate
499 const QSizeF viewport = camera->viewportSize();
500 const double maxRange = camera->distance() * SurfaceModel::kMaxRangeMultiplier;
501 constexpr int kScreenGrid = 24;
502 if (!viewport.isEmpty()) {
503 for (int row = 0; row < kScreenGrid; row++) {
504 for (int col = 0; col < kScreenGrid; col++) {
505 const QPointF screenPos(viewport.width() * (col + 0.5) / kScreenGrid,
506 viewport.height() * (row + 0.5) / kScreenGrid);
507 const std::optional<QPointF> ground = camera->groundPointCapped(screenPos, maxRange);
508 if (ground) {
509 view.groundSamples.append(*ground);
510 }
511 }
512 }
513 }
514
515 // Camera-below-surface check only applies in 3D (in 2D the mesh renders
516 // flattened, so comparing against real heights would be wrong)
517 if (_scene && (camera->tilt() > 0.0)) {
518 view.cameraGround = camera->cameraGroundPosition();
519 view.cameraHeight =
520 camera->centerElevation() + (camera->distance() * std::cos(qDegreesToRadians(camera->tilt())));
521 view.heightScale = _scene->verticalScale();
522 }
523
524 const SurfaceAnalysis::Report report =
526 qDebug().noquote() << report.text(); // user-triggered diagnostic: always emitted
527}
528
529int SurfacePatchModel::rowCount(const QModelIndex& parent) const
530{
531 return parent.isValid() ? 0 : _keys.count();
532}
533
534QVariant SurfacePatchModel::data(const QModelIndex& index, int role) const
535{
536 if (!index.isValid() || (index.row() < 0) || (index.row() >= _keys.count()) || !_surfaceModel) {
537 return {};
538 }
539
540 const TileMath::TileKey& key = _keys.at(index.row());
541 const double span = TileMath::tileSpanAtZoom(key.zoom);
542 const QPointF origin = _scene ? _scene->sceneOrigin() : QPointF();
543
544 switch (role) {
545 case CenterXRole:
546 return (TileMath::tileMinCorner(key).x() + (span / 2.0)) - origin.x();
547 case CenterYRole:
548 return (TileMath::tileMinCorner(key).y() + (span / 2.0)) - origin.y();
549 case SpanRole:
550 return span;
551 case ZoomRole:
552 return key.zoom;
553 case TileXRole:
554 return key.x;
555 case TileYRole:
556 return key.y;
557 case HeightsRole: {
558 const auto patch = _surfaceModel->patch(key);
559 return patch ? QVariant::fromValue(patch->heights) : QVariant::fromValue(QList<float>());
560 }
561 case ReadyRole: {
562 const auto patch = _surfaceModel->patch(key);
563 return patch ? patch->ready : false;
564 }
565 case CoveredRole: {
566 const auto patch = _surfaceModel->patch(key);
567 return patch ? patch->covered : false;
568 }
570 return QVariant::fromValue(_surfaceModel->edgeLodDeltas(key));
571 case TileImageRole: {
572 const QImage own = _tileImages.value(key);
573 if (!own.isNull()) {
574 return own;
575 }
576 const auto cached = _fallbackCache.constFind(key);
577 if (cached != _fallbackCache.constEnd()) {
578 return *cached;
579 }
580 const QImage fallback = _fallbackImage(key);
581 _fallbackCache.insert(key, fallback);
582 return fallback;
583 }
584 case HasTileImageRole:
585 return _tileImages.contains(key) || _fallbackAvailable(key);
586 default:
587 return {};
588 }
589}
590
591bool SurfacePatchModel::_fallbackAvailable(const TileMath::TileKey& key) const
592{
593 for (int childY = 0; childY < 2; childY++) {
594 for (int childX = 0; childX < 2; childX++) {
595 if (_tileImages.contains(TileMath::TileKey{(key.x * 2) + childX, (key.y * 2) + childY, key.zoom + 1})) {
596 return true;
597 }
598 }
599 }
600 for (int up = 1; (up <= kMaxAncestorFallbackLevels) && ((key.zoom - up) >= 0); up++) {
601 if (_tileImages.contains(TileMath::TileKey{key.x >> up, key.y >> up, key.zoom - up})) {
602 return true;
603 }
604 }
605 return false;
606}
607
608QImage SurfacePatchModel::_fallbackImage(const TileMath::TileKey& key) const
609{
610 // Sharper first: composite direct children (LOD coarsening keeps their detail)
611 bool anyChild = false;
612 for (int childY = 0; (childY < 2) && !anyChild; childY++) {
613 for (int childX = 0; (childX < 2) && !anyChild; childX++) {
614 anyChild =
615 _tileImages.contains(TileMath::TileKey{(key.x * 2) + childX, (key.y * 2) + childY, key.zoom + 1});
616 }
617 }
618 if (anyChild) {
619 constexpr int kCanvas = 256;
620 _statComposites++;
621 QImage canvas(kCanvas, kCanvas, QImage::Format_RGBA8888);
622 canvas.fill(QColor(0x3a, 0x40, 0x48)); // missing quadrants stay neutral
623 QPainter painter(&canvas);
624 for (int childY = 0; childY < 2; childY++) {
625 for (int childX = 0; childX < 2; childX++) {
626 const QImage child =
627 _tileImages.value(TileMath::TileKey{(key.x * 2) + childX, (key.y * 2) + childY, key.zoom + 1});
628 if (!child.isNull()) {
629 // Tile y grows south and image row 0 is north, so child y maps to top half directly
630 painter.drawImage(QRect((childX * kCanvas) / 2, (childY * kCanvas) / 2, kCanvas / 2, kCanvas / 2),
631 child);
632 }
633 }
634 }
635 return canvas;
636 }
637
638 // Blurrier: crop the covering region out of the nearest available ancestor
639 for (int up = 1; (up <= kMaxAncestorFallbackLevels) && ((key.zoom - up) >= 0); up++) {
640 const TileMath::TileKey ancestor{key.x >> up, key.y >> up, key.zoom - up};
641 const QImage image = _tileImages.value(ancestor);
642 if (image.isNull()) {
643 continue;
644 }
645 const int split = 1 << up;
646 const double cellWidth = static_cast<double>(image.width()) / split;
647 const double cellHeight = static_cast<double>(image.height()) / split;
648 const int cellX = key.x - (ancestor.x << up);
649 const int cellY = key.y - (ancestor.y << up);
650 _statComposites++;
651 return image.copy(QRect(qRound(cellX * cellWidth), qRound(cellY * cellHeight), qMax(1, qRound(cellWidth)),
652 qMax(1, qRound(cellHeight))));
653 }
654 return {};
655}
656
657QHash<int, QByteArray> SurfacePatchModel::roleNames() const
658{
659 return {
660 {CenterXRole, "centerX"},
661 {SpanRole, "span"},
662 {CenterYRole, "centerY"},
663 {ZoomRole, "zoomLevel"},
664 {HeightsRole, "heights"},
665 {ReadyRole, "ready"},
666 {CoveredRole, "covered"},
667 {TileImageRole, "tileImage"},
668 {HasTileImageRole, "hasTileImage"},
669 {EdgeLodDeltasRole, "edgeLodDeltas"},
670 {TileXRole, "tileX"},
671 {TileYRole, "tileY"},
672 };
673}
674
675void SurfacePatchModel::_patchAdded(const TileMath::TileKey& key)
676{
677 beginInsertRows(QModelIndex(), _keys.count(), _keys.count());
678 _keys.append(key);
679 endInsertRows();
680 _statAdds++;
681 if (_tileSource) {
682 if (_tileImages.contains(key)) {
683 _retiredOrder.removeOne(key); // back to live: exempt from eviction again
684 } else {
685 _requestTileImage(key);
686 }
687 }
688 _scheduleStatsChanged();
689}
690
691void SurfacePatchModel::_patchReady(const TileMath::TileKey& key)
692{
693 const int row = _keys.indexOf(key);
694 if (row < 0) {
695 return;
696 }
697 const QModelIndex idx = index(row);
698 emit dataChanged(idx, idx, {HeightsRole, ReadyRole, CoveredRole});
699 // A new ready patch can newly cover other pending rows
700 _notifyCoveredMayHaveChanged(key);
701 _scheduleStatsChanged();
702}
703
704void SurfacePatchModel::_patchEdgeDeltasChanged(const TileMath::TileKey& key)
705{
706 const int row = _keys.indexOf(key);
707 if (row < 0) {
708 return;
709 }
710 const QModelIndex idx = index(row);
711 emit dataChanged(idx, idx, {EdgeLodDeltasRole});
712}
713
714void SurfacePatchModel::_patchRemoved(const TileMath::TileKey& key)
715{
716 const int row = _keys.indexOf(key);
717 if (row < 0) {
718 return;
719 }
720 beginRemoveRows(QModelIndex(), row, row);
721 _keys.removeAt(row);
722 endRemoveRows();
723 _statRemoves++;
724
725 // Retire the delivered image instead of dropping it: it seeds the
726 // fallback for patches that replace this one across LOD changes
727 if (_tileImages.contains(key)) {
728 _retiredOrder.append(key);
729 while (_retiredOrder.count() > kMaxRetiredImages) {
730 const TileMath::TileKey evicted = _retiredOrder.takeFirst();
731 _tileImages.remove(evicted);
732 _invalidateFallbacks(evicted);
733 }
734 }
735 _fallbackCache.remove(key); // row gone: keep the cache bounded by live rows
736 _failedImageKeys.remove(key);
737 const auto requestIt = _imageRequestByKey.constFind(key);
738 if (requestIt != _imageRequestByKey.constEnd()) {
739 _tileSource->cancelRequest(requestIt.value());
740 _imageRequestKey.remove(requestIt.value());
741 _imageRequestByKey.erase(requestIt);
742 }
743 // A removed ready patch may have been the cover of pending rows
744 _notifyCoveredMayHaveChanged(key);
745 _scheduleStatsChanged();
746}
747
748void SurfacePatchModel::_notifyCoveredMayHaveChanged(const TileMath::TileKey& changedKey)
749{
750 // Ready rows are never covered, and only rows overlapping the changed
751 // patch can gain/lose their cover. The rect prefilter keeps this O(rows)
752 // per event; unscoped notification was O(rows^2) via the CoveredRole
753 // re-evaluations and dominated passes at high resident counts.
754 const double span = TileMath::tileSpanAtZoom(changedKey.zoom);
755 const QRectF changedRect(TileMath::tileMinCorner(changedKey), QSizeF(span, span));
756 for (int row = 0; row < _keys.count(); row++) {
757 const TileMath::TileKey& rowKey = _keys.at(row);
758 const double rowSpan = TileMath::tileSpanAtZoom(rowKey.zoom);
759 if (!changedRect.intersects(QRectF(TileMath::tileMinCorner(rowKey), QSizeF(rowSpan, rowSpan)))) {
760 continue;
761 }
762 const auto patch = _surfaceModel->patch(rowKey);
763 if (patch && !patch->ready) {
764 const QModelIndex idx = index(row);
765 emit dataChanged(idx, idx, {CoveredRole});
766 }
767 }
768}
769
770void SurfacePatchModel::_sceneOriginChanged()
771{
772 // All scene positions shift with the origin
773 if (!_keys.isEmpty()) {
774 emit dataChanged(index(0), index(_keys.count() - 1), {CenterXRole, CenterYRole});
775 }
776}
#define QGC_LOGGING_CATEGORY(name, categoryStr)
z=0 everywhere (Milestone 1 default)
QSizeF viewportSize() const
qreal distance() const
QPointF cameraGroundPosition() const
qreal centerElevation() const
std::optional< QPointF > groundPointCapped(const QPointF &screenPos, double maxRange) const
qreal tilt() const
qreal verticalScale() const
Definition GeoScene.cc:48
void sceneOriginChanged()
void cameraChanged()
GeoMapCamera * camera() const
Definition GeoScene.h:49
QPointF sceneOrigin() const
Definition GeoScene.h:56
double heightAt(const QPointF &world) const
void regionChanged(const QRectF &worldRect)
void setHeightField(HeightField *field)
Field that receives whole tiles requested via requestTile (not owned)
static constexpr double kMaxRangeMultiplier
visible-range cap in camera distances
std::optional< Patch > patch(const TileMath::TileKey &key) const
Single-patch lookup; std::nullopt when the key is not resident.
static constexpr int kGridSize
QList< Patch > patches() const
void patchEdgeDeltasChanged(const TileMath::TileKey &key)
QList< int > edgeLodDeltas(const TileMath::TileKey &key) const
void patchRemoved(const TileMath::TileKey &key)
int pendingCount() const
void patchAdded(const TileMath::TileKey &key)
void patchMeshChanged(const TileMath::TileKey &key)
The patch's mesh content changed (heights re-sampled): consumers must re-pull heights.
UpdateStats takeUpdateStats()
Returns the counters accumulated since the previous call and resets them.
void debugHillsChanged()
void statsEnabledChanged()
double maxRangeMultiplier() const
void setDebugHills(bool debugHills)
int rowCount(const QModelIndex &parent=QModelIndex()) const override
QString mapType() const
QVariant data(const QModelIndex &index, int role) const override
void terrainHeightsChanged()
terrainHeightAt answers changed somewhere: consumers re-query
bool debugHills() const
Q_INVOKABLE void startCapture()
Starts recording the per-second perf samples (plus camera context)
void setScene(GeoScene *scene)
@ SpanRole
patch edge length (m)
@ 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
@ ZoomRole
quadtree zoom level
@ TileYRole
slippy tile y of the patch key
@ CenterYRole
patch center y in scene meters
@ CoveredRole
pending but overlapped by a ready patch: delegate suppresses rendering
@ HeightsRole
vertex height grid (empty = flat)
@ CenterXRole
patch center x in scene meters
@ TileXRole
slippy tile x of the patch key
Q_INVOKABLE QString stopCapture()
~SurfacePatchModel() override
GeoScene * scene() const
QHash< int, QByteArray > roleNames() const override
void setStatsEnabled(bool enabled)
void setMapType(const QString &mapType)
Q_INVOKABLE double terrainHeightAt(const QGeoCoordinate &coordinate) const
void setTerrain(bool terrain)
Q_INVOKABLE void analyzeSurface() const
void tileImageFailed(int requestId)
void cancelRequest(int requestId)
Cancel a pending request, aborting any in-flight network fetch.
int requestTileImage(const TileMath::TileKey &key)
Request the image for a tile. Returns a request id (> 0).
void tileImageReady(int requestId, const QImage &image)
Report analyze(const QList< SurfaceModel::Patch > &patches, int gridSize, const ViewState &view, double minStep)
QPointF geoToWorld(const QGeoCoordinate &coord)
Geo -> world meters. Latitude is clamped to +/-kMaxLatitude.
Definition TileMath.cc:23
QPointF tileMinCorner(const TileKey &key)
South-west (minimum x/y) corner of a tile in world meters.
Definition TileMath.cc:58
constexpr int kMinZoom
Definition TileMath.h:30
double tileSpanAtZoom(int zoom)
Edge length of one tile at zoom, in world meters.
Definition TileMath.cc:53
QString text() const
human-readable report
double heightScale
vertical scale applied to patch heights; 0 skips the camera check
QPointF cameraGround
camera ground position (world meters)
double cameraHeight
camera height above the ground plane (scene units); 0 skips the camera check
QList< QPointF > groundSamples
world points visible on screen; must have rendered coverage
update() call/duration counters for the perf overlay