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 <algorithm>
4#include <cmath>
5#include <limits>
6#include <optional>
7#include <utility>
8
9#include <QtCore/QByteArray>
10#include <QtCore/QDateTime>
11#include <QtCore/QDebug>
12#include <QtCore/QDir>
13#include <QtCore/QFile>
14#include <QtCore/QTimer>
15#include <QtCore/QtMath>
16#include <QtGui/QPainter>
17
18#ifdef Q_OS_ANDROID
19#include <android/log.h>
20#endif
21
22#include "GeoMapCamera.h"
23#include "GeoScene.h"
24#include "HeightField.h"
25#include "HeightSource.h"
26#include "QGCLoggingCategory.h"
27#include "SurfaceAnalysis.h"
28#include "SurfaceModel.h"
30#include "TileImageSource.h"
31
32QGC_LOGGING_CATEGORY(GeoMapSurfacePatchModelLog, "GeoMap.SurfacePatchModel")
33QGC_LOGGING_CATEGORY(GeoMapSurfacePatchModelVerboseLog, "GeoMap.SurfacePatchModel.Verbose")
34
35SurfacePatchModel::SurfacePatchModel(QObject* parent) : QAbstractListModel(parent) {}
36
38
40{
41 if (scene == _scene) {
42 return;
43 }
44 if (_scene) {
45 disconnect(_scene, nullptr, this, nullptr);
46 }
47 _scene = scene;
48 emit sceneChanged();
49
50 if (_scene) {
51 // cameraChanged also fires on camera destruction (scene clears its pointer)
52 connect(_scene, &GeoScene::cameraChanged, this, &SurfacePatchModel::_rebuildSurfaceModel);
53 connect(_scene, &GeoScene::sceneOriginChanged, this, &SurfacePatchModel::_sceneOriginChanged);
54 connect(_scene, &QObject::destroyed, this, [this] {
55 _scene = nullptr;
56 emit sceneChanged();
57 _rebuildSurfaceModel();
58 });
59 }
60 _rebuildSurfaceModel();
61}
62
63GeoMapCamera* SurfacePatchModel::_camera() const
64{
65 return _scene ? _scene->camera() : nullptr;
66}
67
68void SurfacePatchModel::_scheduleStatsChanged()
69{
70 // Patch events arrive in bursts (up to hundreds/s during churn); one
71 // emit per event-loop pass bounds the QML binding re-evaluations
72 if (_statsNotifyPending) {
73 return;
74 }
75 _statsNotifyPending = true;
76 QTimer::singleShot(0, this, [this] {
77 _statsNotifyPending = false;
78 emit statsChanged();
79 });
80}
81
82#ifdef QGC_UNITTEST_BUILD
83void SurfacePatchModel::drainUpdates()
84{
85 if (_surfaceModel) {
86 _surfaceModel->drainUpdates();
87 }
88}
89#endif
90
92{
93 if (terrain == _terrain) {
94 return;
95 }
96 qCDebug(GeoMapSurfacePatchModelLog) << "terrain" << _terrain << "->" << terrain;
97 _terrain = terrain;
98 emit terrainChanged();
99 _rebuildSurfaceModel();
100}
101
103{
104 if (debugHills == _debugHills) {
105 return;
106 }
107 qCDebug(GeoMapSurfacePatchModelLog) << "debugHills" << _debugHills << "->" << debugHills;
108 _debugHills = debugHills;
109 emit debugHillsChanged();
110 _rebuildSurfaceModel();
111}
112
113void SurfacePatchModel::setMapType(const QString& mapType)
114{
115 if (mapType == _mapType) {
116 return;
117 }
118 qCDebug(GeoMapSurfacePatchModelLog) << "mapType" << _mapType << "->" << mapType;
119 _mapType = mapType;
120 emit mapTypeChanged();
121
122 _resetImagery();
123 delete _tileSource;
124 _tileSource = nullptr;
125 if (!_mapType.isEmpty()) {
126 _tileSource = new TileImageSource(_mapType, this, _tileImageNetworkManager);
127 connect(_tileSource, &TileImageSource::tileImageReady, this, &SurfacePatchModel::_tileImageReady);
128 connect(_tileSource, &TileImageSource::tileImageFailed, this, &SurfacePatchModel::_tileImageFailed);
129 for (const TileMath::TileKey& key : std::as_const(_keys)) {
130 _requestTileImage(key);
131 }
132 }
133}
134
135void SurfacePatchModel::_resetImagery()
136{
137 if (_tileSource) {
138 // Cancelled requests never signal, so iterating the live map is safe
139 for (auto it = _imageRequestKey.cbegin(); it != _imageRequestKey.cend(); ++it) {
140 _tileSource->cancelRequest(it.key());
141 }
142 }
143 _imageRequestKey.clear();
144 _imageRequestByKey.clear();
145 _failedImageKeys.clear();
146 if (_imageRetryTimer) {
147 _imageRetryTimer->stop();
148 }
149 _retiredOrder.clear();
150 _fallbackCache.clear();
151 if (!_tileImages.isEmpty()) {
152 _tileImages.clear();
153 if (!_keys.isEmpty()) {
154 emit dataChanged(index(0), index(_keys.count() - 1), {TileImageRole, HasTileImageRole});
155 }
156 }
157}
158
159void SurfacePatchModel::_requestTileImage(const TileMath::TileKey& key)
160{
161 if (!_tileSource) {
162 return;
163 }
164 const int requestId = _tileSource->requestTileImage(key);
165 _imageRequestKey.insert(requestId, key);
166 _imageRequestByKey.insert(key, requestId);
167}
168
169void SurfacePatchModel::_tileImageReady(int requestId, const QImage& image)
170{
171 const auto it = _imageRequestKey.constFind(requestId);
172 if (it == _imageRequestKey.constEnd()) {
173 return;
174 }
175 const TileMath::TileKey key = it.value();
176 _imageRequestKey.erase(it);
177 _imageRequestByKey.remove(key);
178
179 const int row = _keys.indexOf(key);
180 if (row < 0) {
181 return;
182 }
183 _tileImages.insert(key, image);
184 _fallbackCache.remove(key); // own image supersedes any cached miss
185 qCDebug(GeoMapSurfacePatchModelVerboseLog) << "tile image ready for" << key;
186 _invalidateFallbacks(key);
187 _notifyTileImageChanged(key);
188}
189
190void SurfacePatchModel::_invalidateFallbacks(const TileMath::TileKey& tile)
191{
192 // A patch's fallback sources from its direct children and its ancestors
193 // (up to kMaxAncestorFallbackLevels), so a changed tile invalidates its
194 // parent's composite and every cached descendant's crop. Fallbacks read
195 // only delivered images, never other fallbacks, so invalidation never
196 // cascades further.
197 if (tile.zoom > TileMath::kMinZoom) {
198 _fallbackCache.remove(TileMath::TileKey{tile.x >> 1, tile.y >> 1, tile.zoom - 1});
199 }
200 for (auto it = _fallbackCache.begin(); it != _fallbackCache.end();) {
201 const int down = it.key().zoom - tile.zoom;
202 if ((down > 0) && (down <= kMaxAncestorFallbackLevels) && ((it.key().x >> down) == tile.x) &&
203 ((it.key().y >> down) == tile.y)) {
204 it = _fallbackCache.erase(it);
205 } else {
206 ++it;
207 }
208 }
209}
210
211void SurfacePatchModel::_notifyTileImageChanged(const TileMath::TileKey& key)
212{
213 // A delivered tile changes its own row plus the rows whose fallback
214 // sources from it: descendants (ancestor crop) and the direct parent
215 // (child composite). Rows with their own image are unaffected.
216 for (int row = 0; row < _keys.count(); row++) {
217 const TileMath::TileKey& rowKey = _keys.at(row);
218 bool affected = (rowKey == key);
219 if (!affected && !_tileImages.contains(rowKey)) {
220 if ((key.zoom > TileMath::kMinZoom) && (rowKey.zoom == (key.zoom - 1))) {
221 affected = (rowKey.x == (key.x >> 1)) && (rowKey.y == (key.y >> 1));
222 } else if (rowKey.zoom > key.zoom) {
223 const int up = rowKey.zoom - key.zoom;
224 affected =
225 (up <= kMaxAncestorFallbackLevels) && ((rowKey.x >> up) == key.x) && ((rowKey.y >> up) == key.y);
226 }
227 }
228 if (affected) {
229 const QModelIndex idx = index(row);
230 emit dataChanged(idx, idx, {TileImageRole, HasTileImageRole});
231 }
232 }
233}
234
235void SurfacePatchModel::_tileImageFailed(int requestId)
236{
237 const auto it = _imageRequestKey.constFind(requestId);
238 if (it == _imageRequestKey.constEnd()) {
239 return;
240 }
241 const TileMath::TileKey key = it.value();
242 _imageRequestByKey.remove(key);
243 _imageRequestKey.erase(it);
244
245 // The delegate keeps its fallback meanwhile; a resident patch retries on
246 // the paced timer (patch churn cannot be relied on to re-request - a
247 // patch that stays resident would keep the loading fallback forever)
248 if (!_keys.contains(key)) {
249 return;
250 }
251 qCDebug(GeoMapSurfacePatchModelLog) << "tile image failed for" << key << "(retry scheduled)";
252 _failedImageKeys.insert(key);
253 if (!_imageRetryTimer) {
254 _imageRetryTimer = new QTimer(this);
255 _imageRetryTimer->setSingleShot(true);
256 _imageRetryTimer->setInterval(kImageRetryMs);
257 connect(_imageRetryTimer, &QTimer::timeout, this, &SurfacePatchModel::_retryFailedImages);
258 }
259 if (!_imageRetryTimer->isActive()) {
260 _imageRetryTimer->start();
261 }
262}
263
264void SurfacePatchModel::_retryFailedImages()
265{
266 const QSet<TileMath::TileKey> failed = std::exchange(_failedImageKeys, {});
267 for (const TileMath::TileKey& key : failed) {
268 // Re-check residency and in-flight state: churn may have re-requested
269 // or removed the patch since the failure
270 if (!_keys.contains(key) || _tileImages.contains(key) || _imageRequestByKey.contains(key)) {
271 continue;
272 }
273 _requestTileImage(key);
274 }
275}
276
277void SurfacePatchModel::_rebuildSurfaceModel()
278{
279 beginResetModel();
280 _keys.clear();
281 _resetImagery();
282 delete _surfaceModel;
283 _surfaceModel = nullptr;
284 delete _heightSource;
285 _heightSource = nullptr;
286 delete _heightField;
287 _heightField = nullptr;
288
289 GeoMapCamera* const camera = _camera();
290 if (camera) {
291 if (_debugHills) {
292 _heightSource = new DebugHeightSource(this);
293 } else if (_terrain) {
294 _heightSource = new TerrariumTileFetcher(this);
295 } else {
296 _heightSource = new FlatHeightSource(this);
297 }
298 qCDebug(GeoMapSurfacePatchModelLog) << "rebuilding surface model, height source:"
299 << (_debugHills ? "debug hills" : (_terrain ? "terrain" : "flat"));
300 _heightField = new HeightField(this);
302 _heightSource->setHeightField(_heightField);
303 _surfaceModel = new SurfaceModel(camera, _heightSource, _heightField, this);
304 // Via the surface model, so patches are re-meshed before consumers re-query
306 connect(_surfaceModel, &SurfaceModel::patchAdded, this, &SurfacePatchModel::_patchAdded);
307 connect(_surfaceModel, &SurfaceModel::patchMeshChanged, this, &SurfacePatchModel::_patchReady);
308 connect(_surfaceModel, &SurfaceModel::patchEdgeDeltasChanged, this,
309 &SurfacePatchModel::_patchEdgeDeltasChanged);
310 connect(_surfaceModel, &SurfaceModel::patchRemoved, this, &SurfacePatchModel::_patchRemoved);
311 }
312 endResetModel();
313
314 if (_surfaceModel) {
315 _surfaceModel->update();
316 }
317 emit statsChanged();
318 // Field replacement invalidates every previous height answer
320 emit terrainDataChanged();
321}
322
323double SurfacePatchModel::terrainHeightAt(const QGeoCoordinate& coordinate) const
324{
325 // Invalid coordinates carry NaN lat/lon, which the height lookup cannot digest
326 if (!_heightField || !coordinate.isValid()) {
327 return 0.0;
328 }
329 return _surfaceHeightAt(TileMath::geoToWorld(coordinate));
330}
331
332double SurfacePatchModel::terrainDataHeightAt(const QGeoCoordinate& coordinate) const
333{
334 if (!_heightField || !coordinate.isValid()) {
335 return 0.0;
336 }
337 return _heightField->heightAt(TileMath::geoToWorld(coordinate));
338}
339
340bool SurfacePatchModel::segmentBelowTerrain(const QGeoCoordinate& from, const QGeoCoordinate& to,
341 double ignoreStartMeters, double ignoreEndMeters) const
342{
343 if (!_heightField || !from.isValid() || !to.isValid() || std::isnan(from.altitude()) || std::isnan(to.altitude())) {
344 return false;
345 }
346 const double distance = from.distanceTo(to);
347 const int steps =
348 std::clamp(static_cast<int>(std::ceil(distance / kSegmentSampleSpacingMeters)), 1, kMaxSegmentSamples);
349 // Linear in mercator, as the mission path draws the segment
350 const QPointF fromWorld = TileMath::geoToWorld(from);
351 const QPointF toWorld = TileMath::geoToWorld(to);
352 for (int i = 0; i <= steps; i++) {
353 const double fraction = static_cast<double>(i) / steps;
354 const double along = distance * fraction;
355 if ((along < ignoreStartMeters) || (along > (distance - ignoreEndMeters))) {
356 continue;
357 }
358 const double altitude = from.altitude() + ((to.altitude() - from.altitude()) * fraction);
359 if (_heightField->heightAt(fromWorld + ((toWorld - fromWorld) * fraction)) > altitude) {
360 return true;
361 }
362 }
363 return false;
364}
365
366double SurfacePatchModel::_surfaceHeightAt(const QPointF& world) const
367{
368 if (_surfaceModel) {
369 if (const std::optional<double> rendered = _surfaceModel->renderedHeightAt(world)) {
370 return *rendered;
371 }
372 }
373 return _heightField->heightAt(world);
374}
375
376QGeoCoordinate SurfacePatchModel::surfaceCoordinateAtScreenPoint(const GeoMapCamera* camera, const QPointF& screenPos,
377 double zScale) const
378{
379 // Negative zScale would invert the terrain band math below
380 if (!camera || !_heightField || (zScale < 0.0)) {
381 return QGeoCoordinate();
382 }
383 const auto rayOpt = camera->pickRayAt(screenPos);
384 if (!rayOpt) {
385 return QGeoCoordinate();
386 }
387 const GeoMapCamera::PickRay& ray = *rayOpt;
388
389 // Signed height of the ray above the rendered surface at parameter t
390 const auto surfaceOffset = [&](double t) {
391 const QPointF ground(ray.originX + (t * ray.dirX), ray.originY + (t * ray.dirY));
392 return (ray.originZ + (t * ray.dirZ)) - (_surfaceHeightAt(ground) * zScale);
393 };
394
395 // Earth terrain bounds (Terrarium tiles encode bathymetry, so the floor is
396 // the ocean trenches, not sea level). The ray can only cross the surface
397 // while its z is inside [minZ, maxZ]: march that band only.
398 constexpr double kMinTerrainMeters = -11000.0;
399 constexpr double kMaxTerrainMeters = 9000.0;
400 const double minZ = kMinTerrainMeters * zScale;
401 const double maxZ = kMaxTerrainMeters * zScale;
402
403 double tStart = 0.0;
404 double tEnd = 0.0;
405 if (ray.dirZ < 0.0) {
406 tStart = std::max(0.0, (maxZ - ray.originZ) / ray.dirZ);
407 tEnd = (minZ - ray.originZ) / ray.dirZ;
408 } else if (ray.originZ < maxZ) {
409 // Ascending ray below the terrain ceiling (screen edge above the camera
410 // horizon) can still hit terrain rising in front of the camera
411 tEnd = (ray.dirZ > 0.0) ? ((maxZ - ray.originZ) / ray.dirZ) : std::numeric_limits<double>::infinity();
412 } else {
413 return QGeoCoordinate();
414 }
415
416 // Nothing renders beyond the retained patch range, so a hit past it would
417 // be a hit on invisible terrain; this also bounds the coarse step length
418 const double horizontal = std::hypot(ray.dirX, ray.dirY);
419 if (horizontal > 1e-12) {
420 tEnd = std::min(tEnd, (SurfaceModel::kMaxRangeMultiplier * camera->distance()) / horizontal);
421 }
422 if (!std::isfinite(tEnd) || (tEnd < tStart)) {
423 return QGeoCoordinate();
424 }
425
426 const auto coordinateAt = [&](double t) {
427 return TileMath::worldToGeo(QPointF(ray.originX + (t * ray.dirX), ray.originY + (t * ray.dirY)));
428 };
429
430 if (surfaceOffset(tStart) <= 0.0) {
431 // Already at/below the surface at the band entry (includes the zScale=0
432 // degenerate case, where the band collapses to the z=0 plane hit)
433 return coordinateAt(tStart);
434 }
435
436 // Coarse march to the first above->below crossing (the visible surface),
437 // then bisect the bracket down to sub-meter precision. The step length
438 // scales with camera distance like the rendered LOD does, so a visible
439 // ridge cannot fall between samples; only features narrower than
440 // ~distance/64 can be stepped over. With the range capped at
441 // kMaxRangeMultiplier distances this bounds the march at a few thousand
442 // heightAt lookups, fine for a one-shot click.
443 constexpr double kStepsPerCameraDistance = 64.0;
444 constexpr int kBisectSteps = 24;
445 const double stepMeters = std::max(camera->distance() / kStepsPerCameraDistance, 1.0);
446 const double groundRange = (tEnd - tStart) * horizontal;
447 const int coarseSteps = std::max(1, static_cast<int>(std::ceil(groundRange / stepMeters)));
448 double prevT = tStart;
449 for (int i = 1; i <= coarseSteps; i++) {
450 const double t = tStart + (((tEnd - tStart) * i) / coarseSteps);
451 if (surfaceOffset(t) <= 0.0) {
452 double lo = prevT;
453 double hi = t;
454 for (int j = 0; j < kBisectSteps; j++) {
455 const double mid = (lo + hi) / 2.0;
456 if (surfaceOffset(mid) <= 0.0) {
457 hi = mid;
458 } else {
459 lo = mid;
460 }
461 }
462 return coordinateAt(hi);
463 }
464 prevT = t;
465 }
466 return QGeoCoordinate(); // sky: no crossing within the rendered range
467}
468
470{
472}
473
478
480{
481 return _surfaceModel ? _surfaceModel->pendingCount() : 0;
482}
483
485{
486 int maxZoom = 0;
487 for (const TileMath::TileKey& key : _keys) {
488 maxZoom = std::max(maxZoom, key.zoom);
489 }
490 return maxZoom;
491}
492
493// GCOVR_EXCL_START — perf-stats/capture/diagnostics instrumentation, not shipping behavior
494void SurfacePatchModel::_startStatsSampling()
495{
496 if (!_statsTimer) {
497 _statsTimer = new QTimer(this);
498 _statsTimer->setInterval(1000);
499 connect(_statsTimer, &QTimer::timeout, this, &SurfacePatchModel::_statsTick);
500 }
501 _statAdds = 0;
502 _statRemoves = 0;
503 _statComposites = 0;
504 if (_surfaceModel) {
505 _surfaceModel->takeUpdateStats(); // discard pre-window accumulation
506 }
507 _statsTimer->start();
508}
509
511{
512 if (enabled == _statsEnabled) {
513 return;
514 }
515 _statsEnabled = enabled;
516 emit statsEnabledChanged();
517
518 // The sampler is shared with capture recording, which owns it while
519 // capturing: the overlay toggling must not reset or stop a live capture
520 if (_statsEnabled) {
521 if (!_capturing) {
522 _startStatsSampling();
523 }
524 } else if (!_capturing && _statsTimer) {
525 _statsTimer->stop();
526 }
527 _statsText.clear();
528 emit statsTextChanged();
529}
530
531void SurfacePatchModel::_statsTick()
532{
533 const SurfaceModel::UpdateStats stats =
534 _surfaceModel ? _surfaceModel->takeUpdateStats() : SurfaceModel::UpdateStats{};
535 const double avgMs = stats.updates ? (stats.totalUs / 1000.0) / stats.updates : 0.0;
536 const double maxMs = stats.maxUs / 1000.0;
537 const double addAvgMs = stats.updates ? (stats.addTotalUs / 1000.0) / stats.updates : 0.0;
538 const double addMaxMs = stats.addMaxUs / 1000.0;
539 _statsText = QStringLiteral("upd/s %1 avg %2 ms max %3 ms | patch +%4 -%5 /s comp/s %6")
540 .arg(stats.updates)
541 .arg(avgMs, 0, 'f', 2)
542 .arg(maxMs, 0, 'f', 2)
543 .arg(_statAdds)
544 .arg(_statRemoves)
545 .arg(_statComposites);
546 if (_capturing) {
547 _captureWorstMaxMs = std::max(_captureWorstMaxMs, maxMs);
548 _captureWorstAvgMs = std::max(_captureWorstAvgMs, avgMs);
549 GeoMapCamera* const camera = _camera();
550 _captureRows.append(QStringLiteral("%1,%2,%3,%4,%5,%6,%7,%8,%9,%10,%11,%12,%13,%14")
551 .arg(_captureClock.elapsed() / 1000.0, 0, 'f', 1)
552 .arg(stats.updates)
553 .arg(avgMs, 0, 'f', 3)
554 .arg(maxMs, 0, 'f', 3)
555 .arg(addAvgMs, 0, 'f', 3)
556 .arg(addMaxMs, 0, 'f', 3)
557 .arg(_statAdds)
558 .arg(_statRemoves)
559 .arg(_statComposites)
560 .arg(patchCount())
561 .arg(pendingCount())
562 .arg(maxZoomLevel())
563 .arg(camera ? camera->distance() : 0.0, 0, 'f', 0)
564 .arg(camera ? camera->tilt() : 0.0, 0, 'f', 1));
565 }
566 _statAdds = 0;
567 _statRemoves = 0;
568 _statComposites = 0;
569 emit statsTextChanged();
570}
571
573{
574 if (_capturing) {
575 return;
576 }
577 _capturing = true;
578 emit capturingChanged();
579
580 _captureRows.clear();
581 _captureWorstMaxMs = 0.0;
582 _captureWorstAvgMs = 0.0;
583 _captureRows.append(
584 QStringLiteral("time_s,updates,avg_ms,max_ms,add_avg_ms,add_max_ms,patch_adds,patch_removes,composites,"
585 "patches,pending,max_zoom,cam_dist_m,cam_tilt_deg"));
586 _captureClock.start();
587 // Run the sampler directly rather than via statsEnabled: that property is
588 // QML-bound to the overlay toggle, and writing it here would fight the
589 // binding (and leave the sampler running after the capture ends)
590 _startStatsSampling();
591}
592
594{
595 if (!_capturing) {
596 return QString();
597 }
598 _statsTick(); // flush the partial interval: the final settle pass must reach the CSV and worst-case values
599 _capturing = false;
600 emit capturingChanged();
601 if (!_statsEnabled && _statsTimer) {
602 _statsTimer->stop();
603 }
604
605 const QString path = QDir::temp().filePath(
606 QStringLiteral("qgc-geomap-capture-%1.csv").arg(QDateTime::currentDateTime().toString("yyyyMMdd-hhmmss")));
607 QFile file(path);
608 if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
609 qWarning() << "GeoMap capture: cannot write" << path;
610 _statsText = QStringLiteral("capture failed: %1").arg(path);
611 emit statsTextChanged();
612 return QString();
613 }
614 const QByteArray data = _captureRows.join(QLatin1Char('\n')).toUtf8() + '\n';
615 const bool written = (file.write(data) == data.size()) && file.flush();
616 file.close();
617 _captureRows.clear();
618 if (!written) {
619 (void) file.remove(); // never hand back a truncated CSV
620 qWarning() << "GeoMap capture: write failed" << path;
621 _statsText = QStringLiteral("capture failed: %1").arg(path);
622 emit statsTextChanged();
623 return QString();
624 }
625
626#ifdef Q_OS_ANDROID
627 // Non-debuggable release builds hide the app cache from adb; dump the CSV
628 // to logcat so it can be pulled with: adb logcat -d -s GeoMapCapture
629 for (const QByteArray& line : data.split('\n')) {
630 if (!line.isEmpty()) {
631 __android_log_print(ANDROID_LOG_WARN, "GeoMapCapture", "%s", line.constData());
632 }
633 }
634#endif
635
636 // No log output on success: the overlay shows the path, and UI tests run
637 // with strict log checking. The worst-case summary makes the overlay a
638 // one-glance verdict without reading the CSV.
639 _statsText = QStringLiteral("capture: worst max %1 ms worst avg %2 ms -> %3")
640 .arg(_captureWorstMaxMs, 0, 'f', 2)
641 .arg(_captureWorstAvgMs, 0, 'f', 2)
642 .arg(path);
643 emit statsTextChanged();
644 return path;
645}
646
648{
649 GeoMapCamera* const camera = _camera();
650 if (!_surfaceModel || !camera) {
651 qDebug() << "Surface analysis: no surface model active";
652 return;
653 }
654
656
657 // Unproject a dense screen grid to ground points: coverage is checked
658 // where the screen actually looks, independent of the model's own
659 // visible-region estimate
660 const QSizeF viewport = camera->viewportSize();
661 const double maxRange = camera->distance() * SurfaceModel::kMaxRangeMultiplier;
662 constexpr int kScreenGrid = 24;
663 if (!viewport.isEmpty()) {
664 for (int row = 0; row < kScreenGrid; row++) {
665 for (int col = 0; col < kScreenGrid; col++) {
666 const QPointF screenPos(viewport.width() * (col + 0.5) / kScreenGrid,
667 viewport.height() * (row + 0.5) / kScreenGrid);
668 const std::optional<QPointF> ground = camera->groundPointCapped(screenPos, maxRange);
669 if (ground) {
670 view.groundSamples.append(*ground);
671 }
672 }
673 }
674 }
675
676 // Camera-below-surface check only applies in 3D (in 2D the mesh renders
677 // flattened, so comparing against real heights would be wrong)
678 if (_scene && (camera->tilt() > 0.0)) {
679 view.cameraGround = camera->cameraGroundPosition();
680 view.cameraHeight =
681 camera->centerElevation() + (camera->distance() * std::cos(qDegreesToRadians(camera->tilt())));
682 view.heightScale = _scene->verticalScale();
683 }
684
685 const SurfaceAnalysis::Report report =
687 qDebug().noquote() << report.text(); // user-triggered diagnostic: always emitted
688}
689
690// GCOVR_EXCL_STOP
691
692int SurfacePatchModel::rowCount(const QModelIndex& parent) const
693{
694 return parent.isValid() ? 0 : _keys.count();
695}
696
697QVariant SurfacePatchModel::data(const QModelIndex& index, int role) const
698{
699 if (!index.isValid() || (index.row() < 0) || (index.row() >= _keys.count()) || !_surfaceModel) {
700 return {};
701 }
702
703 const TileMath::TileKey& key = _keys.at(index.row());
704 const double span = TileMath::tileSpanAtZoom(key.zoom);
705 const QPointF origin = _scene ? _scene->sceneOrigin() : QPointF();
706
707 switch (role) {
708 case CenterXRole:
709 return (TileMath::tileMinCorner(key).x() + (span / 2.0)) - origin.x();
710 case CenterYRole:
711 return (TileMath::tileMinCorner(key).y() + (span / 2.0)) - origin.y();
712 case SpanRole:
713 return span;
714 case ZoomRole:
715 return key.zoom;
716 case TileXRole:
717 return key.x;
718 case TileYRole:
719 return key.y;
720 case HeightsRole: {
721 const auto patch = _surfaceModel->patch(key);
722 return patch ? QVariant::fromValue(patch->heights) : QVariant::fromValue(QList<float>());
723 }
724 case ReadyRole: {
725 const auto patch = _surfaceModel->patch(key);
726 return patch ? patch->ready : false;
727 }
728 case CoveredRole: {
729 const auto patch = _surfaceModel->patch(key);
730 return patch ? patch->covered : false;
731 }
733 return QVariant::fromValue(_surfaceModel->edgeLodDeltas(key));
734 case TileImageRole: {
735 const QImage own = _tileImages.value(key);
736 if (!own.isNull()) {
737 return own;
738 }
739 const auto cached = _fallbackCache.constFind(key);
740 if (cached != _fallbackCache.constEnd()) {
741 return *cached;
742 }
743 const QImage fallback = _fallbackImage(key);
744 _fallbackCache.insert(key, fallback);
745 return fallback;
746 }
747 case HasTileImageRole:
748 return _tileImages.contains(key) || _fallbackAvailable(key);
749 default:
750 return {};
751 }
752}
753
754bool SurfacePatchModel::_fallbackAvailable(const TileMath::TileKey& key) const
755{
756 for (int childY = 0; childY < 2; childY++) {
757 for (int childX = 0; childX < 2; childX++) {
758 if (_tileImages.contains(TileMath::TileKey{(key.x * 2) + childX, (key.y * 2) + childY, key.zoom + 1})) {
759 return true;
760 }
761 }
762 }
763 for (int up = 1; (up <= kMaxAncestorFallbackLevels) && ((key.zoom - up) >= 0); up++) {
764 if (_tileImages.contains(TileMath::TileKey{key.x >> up, key.y >> up, key.zoom - up})) {
765 return true;
766 }
767 }
768 return false;
769}
770
771QImage SurfacePatchModel::_fallbackImage(const TileMath::TileKey& key) const
772{
773 // Sharper first: composite direct children (LOD coarsening keeps their detail)
774 QImage image = _compositeFromChildren(key);
775 if (image.isNull()) {
776 // Blurrier: crop the covering region out of the nearest available ancestor
777 image = _cropFromAncestor(key);
778 }
779 return image;
780}
781
782QImage SurfacePatchModel::_compositeFromChildren(const TileMath::TileKey& key) const
783{
784 bool anyChild = false;
785 for (int childY = 0; (childY < 2) && !anyChild; childY++) {
786 for (int childX = 0; (childX < 2) && !anyChild; childX++) {
787 anyChild =
788 _tileImages.contains(TileMath::TileKey{(key.x * 2) + childX, (key.y * 2) + childY, key.zoom + 1});
789 }
790 }
791 if (!anyChild) {
792 return {};
793 }
794 constexpr int kCanvas = 256;
795 _statComposites++;
796 QImage canvas(kCanvas, kCanvas, QImage::Format_RGBA8888);
797 canvas.fill(QColor(0x3a, 0x40, 0x48)); // missing quadrants stay neutral
798 QPainter painter(&canvas);
799 for (int childY = 0; childY < 2; childY++) {
800 for (int childX = 0; childX < 2; childX++) {
801 const QImage child =
802 _tileImages.value(TileMath::TileKey{(key.x * 2) + childX, (key.y * 2) + childY, key.zoom + 1});
803 if (!child.isNull()) {
804 // Tile y grows south and image row 0 is north, so child y maps to top half directly
805 painter.drawImage(QRect((childX * kCanvas) / 2, (childY * kCanvas) / 2, kCanvas / 2, kCanvas / 2),
806 child);
807 }
808 }
809 }
810 return canvas;
811}
812
813QImage SurfacePatchModel::_cropFromAncestor(const TileMath::TileKey& key) const
814{
815 for (int up = 1; (up <= kMaxAncestorFallbackLevels) && ((key.zoom - up) >= 0); up++) {
816 const TileMath::TileKey ancestor{key.x >> up, key.y >> up, key.zoom - up};
817 const QImage image = _tileImages.value(ancestor);
818 if (image.isNull()) {
819 continue;
820 }
821 const int split = 1 << up;
822 const double cellWidth = static_cast<double>(image.width()) / split;
823 const double cellHeight = static_cast<double>(image.height()) / split;
824 const int cellX = key.x - (ancestor.x << up);
825 const int cellY = key.y - (ancestor.y << up);
826 _statComposites++;
827 return image.copy(QRect(qRound(cellX * cellWidth), qRound(cellY * cellHeight), qMax(1, qRound(cellWidth)),
828 qMax(1, qRound(cellHeight))));
829 }
830 return {};
831}
832
833QHash<int, QByteArray> SurfacePatchModel::roleNames() const
834{
835 return {
836 {CenterXRole, "centerX"},
837 {SpanRole, "span"},
838 {CenterYRole, "centerY"},
839 {ZoomRole, "zoomLevel"},
840 {HeightsRole, "heights"},
841 {ReadyRole, "ready"},
842 {CoveredRole, "covered"},
843 {TileImageRole, "tileImage"},
844 {HasTileImageRole, "hasTileImage"},
845 {EdgeLodDeltasRole, "edgeLodDeltas"},
846 {TileXRole, "tileX"},
847 {TileYRole, "tileY"},
848 };
849}
850
851void SurfacePatchModel::_patchAdded(const TileMath::TileKey& key)
852{
853 beginInsertRows(QModelIndex(), _keys.count(), _keys.count());
854 _keys.append(key);
855 endInsertRows();
856 _statAdds++;
857 if (_tileSource) {
858 if (_tileImages.contains(key)) {
859 _retiredOrder.removeOne(key); // back to live: exempt from eviction again
860 } else {
861 _requestTileImage(key);
862 }
863 }
864 _scheduleStatsChanged();
865}
866
867void SurfacePatchModel::_patchReady(const TileMath::TileKey& key)
868{
869 const int row = _keys.indexOf(key);
870 if (row < 0) {
871 return;
872 }
873 const QModelIndex idx = index(row);
874 emit dataChanged(idx, idx, {HeightsRole, ReadyRole, CoveredRole});
875 // A new ready patch can newly cover other pending rows
876 _notifyCoveredMayHaveChanged(key);
877 _scheduleStatsChanged();
878}
879
880void SurfacePatchModel::_patchEdgeDeltasChanged(const TileMath::TileKey& key)
881{
882 const int row = _keys.indexOf(key);
883 if (row < 0) {
884 return;
885 }
886 const QModelIndex idx = index(row);
887 emit dataChanged(idx, idx, {EdgeLodDeltasRole});
888}
889
890void SurfacePatchModel::_patchRemoved(const TileMath::TileKey& key)
891{
892 const int row = _keys.indexOf(key);
893 if (row < 0) {
894 return;
895 }
896 beginRemoveRows(QModelIndex(), row, row);
897 _keys.removeAt(row);
898 endRemoveRows();
899 _statRemoves++;
900
901 // Retire the delivered image instead of dropping it: it seeds the
902 // fallback for patches that replace this one across LOD changes
903 if (_tileImages.contains(key)) {
904 _retiredOrder.append(key);
905 while (_retiredOrder.count() > kMaxRetiredImages) {
906 const TileMath::TileKey evicted = _retiredOrder.takeFirst();
907 _tileImages.remove(evicted);
908 _invalidateFallbacks(evicted);
909 }
910 }
911 _fallbackCache.remove(key); // row gone: keep the cache bounded by live rows
912 _failedImageKeys.remove(key);
913 const auto requestIt = _imageRequestByKey.constFind(key);
914 if (requestIt != _imageRequestByKey.constEnd()) {
915 _tileSource->cancelRequest(requestIt.value());
916 _imageRequestKey.remove(requestIt.value());
917 _imageRequestByKey.erase(requestIt);
918 }
919 // A removed ready patch may have been the cover of pending rows
920 _notifyCoveredMayHaveChanged(key);
921 _scheduleStatsChanged();
922}
923
924void SurfacePatchModel::_notifyCoveredMayHaveChanged(const TileMath::TileKey& changedKey)
925{
926 // Ready rows are never covered, and only rows overlapping the changed
927 // patch can gain/lose their cover. The rect prefilter keeps this O(rows)
928 // per event; unscoped notification was O(rows^2) via the CoveredRole
929 // re-evaluations and dominated passes at high resident counts.
930 const double span = TileMath::tileSpanAtZoom(changedKey.zoom);
931 const QRectF changedRect(TileMath::tileMinCorner(changedKey), QSizeF(span, span));
932 for (int row = 0; row < _keys.count(); row++) {
933 const TileMath::TileKey& rowKey = _keys.at(row);
934 const double rowSpan = TileMath::tileSpanAtZoom(rowKey.zoom);
935 if (!changedRect.intersects(QRectF(TileMath::tileMinCorner(rowKey), QSizeF(rowSpan, rowSpan)))) {
936 continue;
937 }
938 const auto patch = _surfaceModel->patch(rowKey);
939 if (patch && !patch->ready) {
940 const QModelIndex idx = index(row);
941 emit dataChanged(idx, idx, {CoveredRole});
942 }
943 }
944}
945
946void SurfacePatchModel::_sceneOriginChanged()
947{
948 // All scene positions shift with the origin
949 if (!_keys.isEmpty()) {
950 emit dataChanged(index(0), index(_keys.count() - 1), {CenterXRole, CenterYRole});
951 }
952}
#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
std::optional< PickRay > pickRayAt(const QPointF &screenPos) 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)
void surfaceHeightsChanged()
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
std::optional< double > renderedHeightAt(const QPointF &world) 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 terrainDataChanged()
terrainDataHeightAt / segmentBelowTerrain answers changed somewhere (not on patch churn)
void debugHillsChanged()
void statsEnabledChanged()
double maxRangeMultiplier() const
void setDebugHills(bool debugHills)
int rowCount(const QModelIndex &parent=QModelIndex()) const override
QString mapType() const
Q_INVOKABLE double terrainDataHeightAt(const QGeoCoordinate &coordinate) const
QVariant data(const QModelIndex &index, int role) const override
void terrainHeightsChanged()
terrainHeightAt answers changed somewhere: consumers re-query
Q_INVOKABLE QGeoCoordinate surfaceCoordinateAtScreenPoint(const GeoMapCamera *camera, const QPointF &screenPos, double zScale) const
Q_INVOKABLE bool segmentBelowTerrain(const QGeoCoordinate &from, const QGeoCoordinate &to, double ignoreStartMeters, double ignoreEndMeters) const
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
QGeoCoordinate worldToGeo(const QPointF &world)
World meters -> geo (altitude 0)
Definition TileMath.cc:31
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
qint64 addTotalUs
time inside the patch-add loop (delegate creation share)