QGroundControl
Ground Control Station for MAVLink Drones
Loading...
Searching...
No Matches
SurfaceAnalysis.cc
Go to the documentation of this file.
1#include "SurfaceAnalysis.h"
2
3#include <QtCore/QHash>
4
5#include <algorithm>
6#include <cmath>
7#include <optional>
8
9namespace SurfaceAnalysis {
10
11namespace {
12
13enum Edge
14{
15 North,
16 South,
17 East,
18 West
19};
20
21constexpr QChar kEdgeChars[] = {u'N', u'S', u'E', u'W'};
22
23QRectF patchRect(const TileMath::TileKey& key)
24{
25 const double span = TileMath::tileSpanAtZoom(key.zoom);
26 const QPointF minCorner = TileMath::tileMinCorner(key);
27 return QRectF(minCorner.x(), minCorner.y(), span, span);
28}
29
30bool isPending(const SurfaceModel::Patch& patch)
31{
32 return !patch.ready;
33}
34
35bool isDegraded(const SurfaceModel::Patch& patch)
36{
37 return patch.ready && patch.heights.isEmpty();
38}
39
41float heightAt(const SurfaceModel::Patch& patch, int verticesPerEdge, int row, int col)
42{
43 if (patch.heights.isEmpty()) {
44 return 0.0f;
45 }
46 return patch.heights.at((row * verticesPerEdge) + col);
47}
48
51double edgeHeight(const SurfaceModel::Patch& patch, int gridSize, Edge edge, double t)
52{
53 t = std::clamp(t, 0.0, static_cast<double>(gridSize));
54 const int i0 = std::min(static_cast<int>(std::floor(t)), gridSize - 1);
55 const double frac = t - i0;
56 const int vpe = gridSize + 1;
57
58 double h0 = 0.0;
59 double h1 = 0.0;
60 switch (edge) {
61 case North:
62 h0 = heightAt(patch, vpe, 0, i0);
63 h1 = heightAt(patch, vpe, 0, i0 + 1);
64 break;
65 case South:
66 h0 = heightAt(patch, vpe, gridSize, i0);
67 h1 = heightAt(patch, vpe, gridSize, i0 + 1);
68 break;
69 case East:
70 h0 = heightAt(patch, vpe, i0, gridSize);
71 h1 = heightAt(patch, vpe, i0 + 1, gridSize);
72 break;
73 case West:
74 h0 = heightAt(patch, vpe, i0, 0);
75 h1 = heightAt(patch, vpe, i0 + 1, 0);
76 break;
77 }
78 return (h0 * (1.0 - frac)) + (h1 * frac);
79}
80
81SeamCause classify(const SurfaceModel::Patch& patch, const SurfaceModel::Patch& neighbor)
82{
83 if (isPending(patch) || isPending(neighbor)) {
85 }
86 if (isDegraded(patch) || isDegraded(neighbor)) {
88 }
89 if (patch.key.zoom != neighbor.key.zoom) {
91 }
93}
94
98std::optional<double> surfaceHeightAt(const QList<SurfaceModel::Patch>& patches, int gridSize, const QPointF& point)
99{
100 const SurfaceModel::Patch* best = nullptr;
101 for (const SurfaceModel::Patch& patch : patches) {
102 if (patch.covered || patch.heights.isEmpty() || !patchRect(patch.key).contains(point)) {
103 continue;
104 }
105 if (!best || (patch.key.zoom > best->key.zoom)) {
106 best = &patch;
107 }
108 }
109 if (!best) {
110 return std::nullopt;
111 }
112
113 const QRectF rect = patchRect(best->key);
114 const double step = rect.width() / gridSize;
115 // Row 0 is the north (max y) edge
116 const double colF = std::clamp((point.x() - rect.left()) / step, 0.0, static_cast<double>(gridSize));
117 const double rowF =
118 std::clamp(((rect.top() + rect.height()) - point.y()) / step, 0.0, static_cast<double>(gridSize));
119 const int col0 = std::min(static_cast<int>(std::floor(colF)), gridSize - 1);
120 const int row0 = std::min(static_cast<int>(std::floor(rowF)), gridSize - 1);
121 const double fCol = colF - col0;
122 const double fRow = rowF - row0;
123 const int vpe = gridSize + 1;
124 const double north =
125 (heightAt(*best, vpe, row0, col0) * (1.0 - fCol)) + (heightAt(*best, vpe, row0, col0 + 1) * fCol);
126 const double south =
127 (heightAt(*best, vpe, row0 + 1, col0) * (1.0 - fCol)) + (heightAt(*best, vpe, row0 + 1, col0 + 1) * fCol);
128 return (north * (1.0 - fRow)) + (south * fRow);
129}
130
135std::optional<QPointF> sightlineHole(const QList<SurfaceModel::Patch>& patches, int gridSize, const ViewState& view,
136 const QPointF& hit)
137{
138 constexpr int kRaySteps = 64;
139
140 // Terrain height is unknown over unrendered ground: carry the nearest
141 // rendered height along the ray as the continuity estimate
142 std::optional<double> nearbySurface;
143 for (int step = 1; step <= kRaySteps; step++) {
144 const double t = static_cast<double>(step) / kRaySteps;
145 const std::optional<double> height =
146 surfaceHeightAt(patches, gridSize, view.cameraGround + ((hit - view.cameraGround) * t));
147 if (height) {
148 nearbySurface = *height * view.heightScale;
149 break;
150 }
151 }
152 if (!nearbySurface) {
153 return std::nullopt; // nothing rendered along the ray: legacy check reports it
154 }
155
156 for (int step = 1; step <= kRaySteps; step++) {
157 const double t = static_cast<double>(step) / kRaySteps;
158 const QPointF point = view.cameraGround + ((hit - view.cameraGround) * t);
159 const double rayZ = view.cameraHeight * (1.0 - t);
160 const std::optional<double> height = surfaceHeightAt(patches, gridSize, point);
161 if (height) {
162 const double surface = *height * view.heightScale;
163 if (rayZ <= surface) {
164 return std::nullopt; // sightline meets rendered terrain: pixel is drawn
165 }
166 nearbySurface = surface;
167 } else if (rayZ <= *nearbySurface) {
168 return point;
169 }
170 }
171 return std::nullopt;
172}
173
174void findHoles(const QList<SurfaceModel::Patch>& patches, int gridSize, const ViewState& view, Report& report)
175{
176 const bool sightlines = (view.heightScale > 0.0) && (view.cameraHeight > 0.0);
177
178 for (const QPointF& point : view.groundSamples) {
179 report.totalSamples++;
180
181 if (sightlines) {
182 const std::optional<QPointF> blocked = sightlineHole(patches, gridSize, view, point);
183 if (blocked) {
184 Hole hole;
185 hole.at = TileMath::worldToGeo(*blocked);
187 report.holes.append(hole);
188 continue;
189 }
190 }
191
192 // Flat-ground hit: if no rendered patch spans it, the screen shows a
193 // hole there
194 bool renderedHere = false;
195 const SurfaceModel::Patch* suppressed = nullptr;
196 for (const SurfaceModel::Patch& patch : patches) {
197 if (!patchRect(patch.key).contains(point)) {
198 continue;
199 }
200 if (patch.covered) {
201 suppressed = &patch;
202 } else {
203 renderedHere = true;
204 break;
205 }
206 }
207 if (renderedHere) {
208 continue;
209 }
210
211 Hole hole;
212 hole.at = TileMath::worldToGeo(point);
213 if (suppressed) {
215 hole.patch = suppressed->key;
216 }
217 report.holes.append(hole);
218 }
219}
220
221void findBadHeights(const QList<SurfaceModel::Patch>& patches, Report& report)
222{
223 for (const SurfaceModel::Patch& patch : patches) {
224 int badCount = 0;
225 for (const float height : patch.heights) {
226 if (!std::isfinite(height)) {
227 badCount++;
228 }
229 }
230 if (badCount > 0) {
231 report.badHeights.append(BadHeights{patch.key, badCount});
232 }
233 }
234}
235
236} // namespace
237
239{
240 switch (cause) {
242 return QStringLiteral("patch renders flat while its terrain heights are still loading");
244 return QStringLiteral("terrain height fetch failed; patch degraded to a flat fallback");
246 return QStringLiteral(
247 "LOD boundary T-junction: fine edge vertices vs the coarse neighbor's interpolated edge");
249 return QStringLiteral("same-zoom edge mismatch (unexpected sampling inconsistency)");
250 }
251 return QString();
252}
253
255{
256 switch (cause) {
258 return QStringLiteral("no resident patch covers this visible area (culling/refinement bug)");
260 return QStringLiteral(
261 "pending patch suppressed as covered, but the covering geometry does not span this area");
263 return QStringLiteral(
264 "sightline passes below nearby terrain height over unrendered ground "
265 "(ground-plane visibility culling misses tall terrain near the camera)");
266 }
267 return QString();
268}
269
270QString Report::text() const
271{
272 QString out;
273 out += QStringLiteral("GeoMap surface analysis\n");
274 out += QStringLiteral("Rendered patches: %1 (heights loading: %2, terrain fetch failed: %3)\n")
275 .arg(rendered)
276 .arg(pending)
277 .arg(degraded);
278
279 if (totalSamples > 0) {
280 if (holes.isEmpty()) {
281 out += QStringLiteral("Coverage holes: none (%1 sample points)\n").arg(totalSamples);
282 } else {
283 out += QStringLiteral("Coverage holes: %1 of %2 sample points uncovered\n")
284 .arg(holes.count())
285 .arg(totalSamples);
286 QHash<HoleCause, int> counts;
287 QHash<HoleCause, const Hole*> examples;
288 for (const Hole& hole : holes) {
289 counts[hole.cause]++;
290 if (!examples.contains(hole.cause)) {
291 examples.insert(hole.cause, &hole);
292 }
293 }
294 for (auto it = counts.cbegin(); it != counts.cend(); ++it) {
295 const Hole* example = examples.value(it.key());
296 out += QStringLiteral(" %1x %2\n").arg(it.value()).arg(holeCauseDescription(it.key()));
297 out += QStringLiteral(" e.g. at %1,%2")
298 .arg(example->at.latitude(), 0, 'f', 5)
299 .arg(example->at.longitude(), 0, 'f', 5);
300 if (it.key() == HoleCause::SuppressedCovered) {
301 out += QStringLiteral(" - zoom %1 tile (%2,%3)")
302 .arg(example->patch.zoom)
303 .arg(example->patch.x)
304 .arg(example->patch.y);
305 }
306 out += QLatin1Char('\n');
307 }
308 }
309 }
310
311 if (cameraChecked) {
312 if (surfaceUnderCamera) {
313 out += QStringLiteral("Camera: %1 m above ground plane, surface under camera %2 m, max near camera %3 m")
314 .arg(cameraHeight, 0, 'f', 1)
315 .arg(surfaceAtCamera, 0, 'f', 1)
316 .arg(maxSurfaceNearCamera, 0, 'f', 1);
317 } else {
318 out += QStringLiteral("Camera: %1 m above ground plane, no rendered terrain under the camera")
319 .arg(cameraHeight, 0, 'f', 1);
320 }
321 if (cameraBelowSurface) {
322 out += QStringLiteral(" - CAMERA CLIPS INTO TERRAIN (eye below rendered surface; hole at screen bottom)");
323 }
324 out += QLatin1Char('\n');
325 }
326
327 if (badHeights.isEmpty()) {
328 out += QStringLiteral("Non-finite heights: none\n");
329 } else {
330 for (const BadHeights& bad : badHeights) {
331 out += QStringLiteral("Non-finite heights: %1 vertices in zoom %2 tile (%3,%4)\n")
332 .arg(bad.count)
333 .arg(bad.key.zoom)
334 .arg(bad.key.x)
335 .arg(bad.key.y);
336 }
337 }
338
339 out += QStringLiteral("Seams >= %1 m: %2\n").arg(minStep, 0, 'f', 1).arg(seams.count());
340 constexpr qsizetype kMaxListed = 20;
341 for (qsizetype i = 0; i < std::min(seams.count(), kMaxListed); i++) {
342 const Seam& seam = seams.at(i);
343 out += QStringLiteral("%1. %2 m step at %3,%4 - zoom %5 tile (%6,%7) %8 edge vs zoom %9 tile (%10,%11)\n")
344 .arg(i + 1)
345 .arg(seam.maxStep, 0, 'f', 1)
346 .arg(seam.worstAt.latitude(), 0, 'f', 5)
347 .arg(seam.worstAt.longitude(), 0, 'f', 5)
348 .arg(seam.patch.zoom)
349 .arg(seam.patch.x)
350 .arg(seam.patch.y)
351 .arg(seam.edge)
352 .arg(seam.neighbor.zoom)
353 .arg(seam.neighbor.x)
354 .arg(seam.neighbor.y);
355 out += QStringLiteral(" cause: %1\n").arg(seamCauseDescription(seam.cause));
356 }
357 if (seams.count() > kMaxListed) {
358 out += QStringLiteral("... %1 more not listed\n").arg(seams.count() - kMaxListed);
359 }
360 if (!seams.isEmpty()) {
361 QHash<SeamCause, int> counts;
362 for (const Seam& seam : seams) {
363 counts[seam.cause]++;
364 }
365 out += QStringLiteral("Seam cause summary:\n");
366 for (auto it = counts.cbegin(); it != counts.cend(); ++it) {
367 out += QStringLiteral(" %1x %2\n").arg(it.value()).arg(seamCauseDescription(it.key()));
368 }
369 }
370 return out;
371}
372
373Report analyze(const QList<SurfaceModel::Patch>& patches, int gridSize, const ViewState& view, double minStep)
374{
375 Report report;
376 report.minStep = minStep;
377
378 // Covered patches do not render; everything else does (flat when no heights)
379 QHash<TileMath::TileKey, const SurfaceModel::Patch*> rendered;
380 for (const SurfaceModel::Patch& patch : patches) {
381 if (patch.covered) {
382 continue;
383 }
384 rendered.insert(patch.key, &patch);
385 report.rendered++;
386 if (isPending(patch)) {
387 report.pending++;
388 } else if (isDegraded(patch)) {
389 report.degraded++;
390 }
391 }
392
393 findHoles(patches, gridSize, view, report);
394 findBadHeights(patches, report);
395
396 if ((view.heightScale > 0.0) && (view.cameraHeight > 0.0)) {
397 report.cameraChecked = true;
398 report.cameraHeight = view.cameraHeight;
399
400 const std::optional<double> underCamera = surfaceHeightAt(patches, gridSize, view.cameraGround);
401 if (underCamera) {
402 report.surfaceUnderCamera = true;
403 report.surfaceAtCamera = *underCamera * view.heightScale;
405 }
406
407 // Terrain taller than the eye within roughly eye-height of the camera
408 // intrudes into the bottom of the frustum even when the point directly
409 // under the camera is low
410 for (const QPointF& point : view.groundSamples) {
411 const QPointF offset = point - view.cameraGround;
412 if (std::hypot(offset.x(), offset.y()) > view.cameraHeight) {
413 continue;
414 }
415 const std::optional<double> height = surfaceHeightAt(patches, gridSize, point);
416 if (height) {
417 report.maxSurfaceNearCamera = std::max(report.maxSurfaceNearCamera, *height * view.heightScale);
418 }
419 }
420
421 report.cameraBelowSurface = (report.surfaceUnderCamera && (view.cameraHeight < report.surfaceAtCamera)) ||
422 (view.cameraHeight < report.maxSurfaceNearCamera);
423 }
424
425 for (auto it = rendered.cbegin(); it != rendered.cend(); ++it) {
426 const SurfaceModel::Patch& patch = *it.value();
427 const TileMath::TileKey key = patch.key;
428 const int tilesAtZoom = 1 << key.zoom;
429 const QPointF minCorner = TileMath::tileMinCorner(key);
430 const double span = TileMath::tileSpanAtZoom(key.zoom);
431 const double step = span / gridSize;
432 const double maxY = minCorner.y() + span;
433
434 for (const Edge edge : {North, South, East, West}) {
435 TileMath::TileKey neighborKey = key;
436 switch (edge) {
437 case North:
438 neighborKey.y--; // tile y grows south
439 break;
440 case South:
441 neighborKey.y++;
442 break;
443 case East:
444 neighborKey.x++;
445 break;
446 case West:
447 neighborKey.x--;
448 break;
449 }
450 if ((neighborKey.x < 0) || (neighborKey.x >= tilesAtZoom) || (neighborKey.y < 0) ||
451 (neighborKey.y >= tilesAtZoom)) {
452 continue;
453 }
454
455 // Find the rendered patch across this edge: the same-zoom neighbor,
456 // or the closest resident ancestor of it. Finer neighbors are
457 // handled from their own (fine) side.
458 const SurfaceModel::Patch* neighbor = nullptr;
459 if (rendered.contains(neighborKey)) {
460 if ((edge == West) || (edge == North)) {
461 continue; // same-zoom seams reported once, from the E/S side
462 }
463 neighbor = rendered.value(neighborKey);
464 } else {
465 for (int d = 1; (key.zoom - d) >= TileMath::kMinZoom; d++) {
466 const TileMath::TileKey candidate{neighborKey.x >> d, neighborKey.y >> d, key.zoom - d};
467 const TileMath::TileKey patchAncestor{key.x >> d, key.y >> d, key.zoom - d};
468 if (candidate == patchAncestor) {
469 break; // candidate contains this patch: overlap cover, not a boundary
470 }
471 if (rendered.contains(candidate)) {
472 neighbor = rendered.value(candidate);
473 break;
474 }
475 }
476 }
477 if (!neighbor) {
478 continue;
479 }
480
481 const QPointF nbMin = TileMath::tileMinCorner(neighbor->key);
482 const double nbSpan = TileMath::tileSpanAtZoom(neighbor->key.zoom);
483 const double nbStep = nbSpan / gridSize;
484 const double nbMaxY = nbMin.y() + nbSpan;
485
486 // Compare each of this patch's edge vertices against the neighbor's
487 // opposing rendered edge at the same world position
488 double maxStep = 0.0;
489 QPointF worstWorld;
490 const int vpe = gridSize + 1;
491 for (int i = 0; i <= gridSize; i++) {
492 double x = 0.0;
493 double y = 0.0;
494 double patchHeight = 0.0;
495 double neighborHeight = 0.0;
496 switch (edge) {
497 case North:
498 x = minCorner.x() + (i * step);
499 y = maxY;
500 patchHeight = heightAt(patch, vpe, 0, i);
501 neighborHeight = edgeHeight(*neighbor, gridSize, South, (x - nbMin.x()) / nbStep);
502 break;
503 case South:
504 x = minCorner.x() + (i * step);
505 y = minCorner.y();
506 patchHeight = heightAt(patch, vpe, gridSize, i);
507 neighborHeight = edgeHeight(*neighbor, gridSize, North, (x - nbMin.x()) / nbStep);
508 break;
509 case East:
510 x = minCorner.x() + span;
511 y = maxY - (i * step);
512 patchHeight = heightAt(patch, vpe, i, gridSize);
513 neighborHeight = edgeHeight(*neighbor, gridSize, West, (nbMaxY - y) / nbStep);
514 break;
515 case West:
516 x = minCorner.x();
517 y = maxY - (i * step);
518 patchHeight = heightAt(patch, vpe, i, 0);
519 neighborHeight = edgeHeight(*neighbor, gridSize, East, (nbMaxY - y) / nbStep);
520 break;
521 }
522 const double delta = std::abs(patchHeight - neighborHeight);
523 if (delta > maxStep) {
524 maxStep = delta;
525 worstWorld = QPointF(x, y);
526 }
527 }
528
529 if (maxStep >= minStep) {
530 report.seams.append(Seam{key, neighbor->key, kEdgeChars[edge], maxStep,
531 TileMath::worldToGeo(worstWorld), classify(patch, *neighbor)});
532 }
533 }
534 }
535
536 std::sort(report.seams.begin(), report.seams.end(),
537 [](const Seam& a, const Seam& b) { return a.maxStep > b.maxStep; });
538 return report;
539}
540
541} // namespace SurfaceAnalysis
@ NoPatch
no resident patch covers the visible point at all
@ SuppressedCovered
only a covered (render-suppressed) patch spans the point
@ ElevatedTerrainCulled
sightline passes below nearby terrain height over unrendered ground
@ LodTJunction
fine edge vertices vs the coarse neighbor's interpolated edge
@ PendingFlat
a patch renders flat because its heights are still loading
@ SameZoomMismatch
same zoom, same scale: unexpected sampling inconsistency
@ DegradedFlat
a patch renders flat because terrain fetches failed (retries exhausted)
QString seamCauseDescription(SeamCause cause)
Report analyze(const QList< SurfaceModel::Patch > &patches, int gridSize, const ViewState &view, double minStep)
QString holeCauseDescription(HoleCause cause)
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
TileMath::TileKey patch
the suppressed patch (SuppressedCovered only)
QGeoCoordinate at
uncovered sample point
double surfaceAtCamera
rendered surface height at the camera ground position (scene units)
int degraded
rendered flat: terrain fetches failed
int pending
rendered flat: heights still loading
int rendered
patches participating (ready, or rendering flat uncovered)
QString text() const
human-readable report
QList< Seam > seams
sorted by maxStep, largest first
QList< Hole > holes
one entry per uncovered sample point
bool cameraBelowSurface
eye is below rendered terrain at/near the camera: hole at screen bottom
double maxSurfaceNearCamera
highest rendered surface at ground samples within cameraHeight of the camera
bool surfaceUnderCamera
a rendered patch with heights spans the camera ground point
double cameraHeight
scene units above the ground plane
int totalSamples
coverage sample points tested (0 = hole check skipped)
QList< BadHeights > badHeights
TileMath::TileKey patch
TileMath::TileKey neighbor
double maxStep
largest vertex height difference along the shared edge (m)
QChar edge
'N','S','E','W': the patch's edge facing the neighbor
QGeoCoordinate worstAt
geo position of the largest step
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
TileMath::TileKey key
bool covered
always false (see ready)
QList< float > heights