QGroundControl
Ground Control Station for MAVLink Drones
Loading...
Searching...
No Matches
GeoMapCamera.cc
Go to the documentation of this file.
1#include "GeoMapCamera.h"
2
3#include <QtCore/QtMath>
4
5#include <algorithm>
6#include <cmath>
7
9#include "TileMath.h"
10
11QGC_LOGGING_CATEGORY(GeoMapCameraLog, "GeoMap.GeoMapCamera")
12
13namespace {
14
15struct Vec3
16{
17 double x = 0;
18 double y = 0;
19 double z = 0;
20};
21
22// Camera-to-world rotation for the pose convention R = Rz(heading) * Rx(tilt),
23// in double precision (QQuaternion/QVector3D floats lose meters at mercator world scale).
24Vec3 rotateCameraToWorld(const Vec3& v, qreal headingDeg, qreal tiltDeg)
25{
26 const double t = qDegreesToRadians(tiltDeg);
27 const double h = qDegreesToRadians(headingDeg);
28
29 // Rx(tilt)
30 const Vec3 a{v.x, (v.y * std::cos(t)) - (v.z * std::sin(t)), (v.y * std::sin(t)) + (v.z * std::cos(t))};
31 // Rz(heading)
32 return Vec3{(a.x * std::cos(h)) - (a.y * std::sin(h)), (a.x * std::sin(h)) + (a.y * std::cos(h)), a.z};
33}
34
35QPointF rotated(const QPointF& vec, qreal degrees)
36{
37 const double rad = qDegreesToRadians(degrees);
38 const double c = std::cos(rad);
39 const double s = std::sin(rad);
40 return QPointF((vec.x() * c) - (vec.y() * s), (vec.x() * s) + (vec.y() * c));
41}
42
43struct Ray
44{
45 Vec3 origin;
46 Vec3 dir;
47};
48
49// Camera offset from the look-at center for the pose convention: the camera
50// sits distance meters along the back-rotated view axis (top-down at tilt 0,
51// moving south and down-range as tilt grows, swung around by heading)
52Vec3 cameraOffset(qreal headingDeg, qreal tiltDeg, qreal distance)
53{
54 const double tiltRad = qDegreesToRadians(tiltDeg);
55 const double headingRad = qDegreesToRadians(headingDeg);
56 const double sinTilt = std::sin(tiltRad);
57 return Vec3{distance * sinTilt * std::sin(headingRad), -distance * sinTilt * std::cos(headingRad),
58 distance * std::cos(tiltRad)};
59}
60
61Ray pickRay(const QPointF& centerWorld, qreal heading, qreal tilt, qreal distance, qreal centerElevation,
62 const QSizeF& viewport, qreal fov, const QPointF& screenPos)
63{
64 const double aspect = viewport.width() / viewport.height();
65 const double tanHalfFov = std::tan(qDegreesToRadians(fov) / 2.0);
66 const double ndcX = ((2.0 * screenPos.x()) / viewport.width()) - 1.0;
67 const double ndcY = 1.0 - ((2.0 * screenPos.y()) / viewport.height());
68
69 const Vec3 dir = rotateCameraToWorld(Vec3{ndcX * tanHalfFov * aspect, ndcY * tanHalfFov, -1.0}, heading, tilt);
70
71 const Vec3 offset = cameraOffset(heading, tilt, distance);
72 return Ray{Vec3{centerWorld.x() + offset.x, centerWorld.y() + offset.y, centerElevation + offset.z}, dir};
73}
74
75} // namespace
76
77GeoMapCamera::GeoMapCamera(QObject* parent) : QObject(parent) {}
78
79QGeoCoordinate GeoMapCamera::center() const
80{
81 return TileMath::worldToGeo(_centerWorld);
82}
83
84void GeoMapCamera::setCenter(const QGeoCoordinate& center)
85{
86 _setCenterWorld(TileMath::geoToWorld(center));
87}
88
89void GeoMapCamera::_setCenterWorld(const QPointF& world)
90{
91 const double half = TileMath::worldSize() / 2.0;
92 const QPointF clamped(std::clamp(world.x(), -half, half), std::clamp(world.y(), -half, half));
93 const bool firstPosition = !_positioned;
94 _positioned = true; // any explicit center counts, even one equal to the default
95 if ((clamped == _centerWorld) && !firstPosition) {
96 return;
97 }
98 _centerWorld = clamped;
99 emit centerChanged();
100 emit scenePoseChanged();
101}
102
103void GeoMapCamera::setHeading(qreal heading)
104{
105 const qreal normalized = _normalizedHeading(heading);
106 if (qFuzzyCompare(normalized, _heading)) {
107 return;
108 }
109 _heading = normalized;
110 emit headingChanged();
111 emit scenePoseChanged();
112}
113
114void GeoMapCamera::setTilt(qreal tilt)
115{
116 const qreal clamped = std::clamp(tilt, kMinTilt, kMaxTilt);
117 if (qFuzzyCompare(clamped, _tilt)) {
118 return;
119 }
120 _tilt = clamped;
121 emit tiltChanged();
122 emit scenePoseChanged();
123}
124
125void GeoMapCamera::setDistance(qreal distance)
126{
127 const qreal clamped = std::clamp(distance, kMinDistance, kMaxDistance);
128 if (qFuzzyCompare(clamped, _distance)) {
129 return;
130 }
131 _distance = clamped;
132 emit distanceChanged();
133 emit scenePoseChanged();
134}
135
137{
138 // Exact compare: values repeat from the same terrain lookup, and
139 // qFuzzyCompare misbehaves near zero
140 if (elevation == _centerElevation) {
141 return;
142 }
143 _centerElevation = elevation;
145 emit scenePoseChanged();
146}
147
148void GeoMapCamera::setViewportSize(const QSizeF& size)
149{
150 if (size == _viewportSize) {
151 return;
152 }
153 _viewportSize = size;
154 emit viewportSizeChanged();
155}
156
158{
159 const qreal clamped = std::clamp(fov, 10.0, 120.0);
160 if (qFuzzyCompare(clamped, _fieldOfView)) {
161 return;
162 }
163 _fieldOfView = clamped;
164 emit fieldOfViewChanged();
165}
166
168{
169 if (mode == _mode) {
170 return;
171 }
172 qCDebug(GeoMapCameraLog) << "mode" << _mode << "->" << mode;
173 _mode = mode;
174 emit modeChanged();
175}
176
183
184void GeoMapCamera::lookAt(const QGeoCoordinate& center, qreal heading, qreal tilt, qreal distance)
185{
188 setTilt(tilt);
190}
191
193{
194 const Vec3 offset = cameraOffset(_heading, _tilt, _distance);
195 return QVector3D(static_cast<float>(_centerWorld.x() + offset.x), static_cast<float>(_centerWorld.y() + offset.y),
196 static_cast<float>(_centerElevation + offset.z));
197}
198
200{
201 const Vec3 offset = cameraOffset(_heading, _tilt, _distance);
202 return QPointF(_centerWorld.x() + offset.x, _centerWorld.y() + offset.y);
203}
204
205void GeoMapCamera::setSceneOrigin(const QPointF& origin)
206{
207 if (origin == _sceneOrigin) {
208 return;
209 }
210 _sceneOrigin = origin;
211 emit scenePoseChanged();
212}
213
215{
216 // World offset computed in doubles before the float cast
217 const Vec3 offset = cameraOffset(_heading, _tilt, _distance);
218 return QVector3D(static_cast<float>((_centerWorld.x() - _sceneOrigin.x()) + offset.x),
219 static_cast<float>((_centerWorld.y() - _sceneOrigin.y()) + offset.y),
220 static_cast<float>(_centerElevation + offset.z));
221}
222
224{
225 // R = Rz(heading) * Rx(tilt): identity is top-down (camera -z look direction
226 // points down the scene z axis) with north (+y) up on screen
227 return QQuaternion::fromAxisAndAngle(0, 0, 1, static_cast<float>(_heading)) *
228 QQuaternion::fromAxisAndAngle(1, 0, 0, static_cast<float>(_tilt));
229}
230
231std::optional<QPointF> GeoMapCamera::screenToGround(const QPointF& screenPos) const
232{
233 if (_viewportSize.isEmpty()) {
234 return std::nullopt;
235 }
236
237 const Ray ray =
238 pickRay(_centerWorld, _heading, _tilt, _distance, _centerElevation, _viewportSize, _fieldOfView, screenPos);
239 if (ray.dir.z >= 0.0) {
240 return std::nullopt; // at or above the horizon
241 }
242
243 const double s = -ray.origin.z / ray.dir.z;
244 return QPointF(ray.origin.x + (s * ray.dir.x), ray.origin.y + (s * ray.dir.y));
245}
246
247std::optional<QPointF> GeoMapCamera::groundPointCapped(const QPointF& screenPos, double maxRange) const
248{
249 if (_viewportSize.isEmpty()) {
250 return std::nullopt;
251 }
252
253 const Ray ray =
254 pickRay(_centerWorld, _heading, _tilt, _distance, _centerElevation, _viewportSize, _fieldOfView, screenPos);
255 const QPointF cameraGround(ray.origin.x, ray.origin.y);
256
257 if (ray.dir.z < 0.0) {
258 const double s = -ray.origin.z / ray.dir.z;
259 const QPointF hit(ray.origin.x + (s * ray.dir.x), ray.origin.y + (s * ray.dir.y));
260 const QPointF offset = hit - cameraGround;
261 const double range = std::hypot(offset.x(), offset.y());
262 if (range <= maxRange) {
263 return hit;
264 }
265 return cameraGround + (offset * (maxRange / range));
266 }
267
268 // Above the horizon: cap along the ray's horizontal direction
269 const double horizontal = std::hypot(ray.dir.x, ray.dir.y);
270 if (horizontal < 1e-12) {
271 return cameraGround; // straight up
272 }
273 return cameraGround + (QPointF(ray.dir.x, ray.dir.y) * (maxRange / horizontal));
274}
275
276std::optional<QPointF> GeoMapCamera::worldToScreen(const QPointF& worldGround, double worldZ) const
277{
278 if (_viewportSize.isEmpty()) {
279 return std::nullopt;
280 }
281
282 const Vec3 offset = cameraOffset(_heading, _tilt, _distance);
283 const Vec3 d{worldGround.x() - (_centerWorld.x() + offset.x), worldGround.y() - (_centerWorld.y() + offset.y),
284 worldZ - (_centerElevation + offset.z)};
285
286 // World-to-camera: inverse of the pose rotation, R^T = Rx(-tilt) * Rz(-heading)
287 const double h = qDegreesToRadians(_heading);
288 const double t = qDegreesToRadians(_tilt);
289 const Vec3 a{(d.x * std::cos(h)) + (d.y * std::sin(h)), (-d.x * std::sin(h)) + (d.y * std::cos(h)), d.z};
290 const Vec3 c{a.x, (a.y * std::cos(t)) + (a.z * std::sin(t)), (-a.y * std::sin(t)) + (a.z * std::cos(t))};
291
292 // Camera looks along -z; the epsilon guards the projection divide (not a near plane)
293 const double depth = -c.z;
294 if (depth <= 1e-9) {
295 return std::nullopt;
296 }
297
298 const double aspect = _viewportSize.width() / _viewportSize.height();
299 const double tanHalfFov = std::tan(qDegreesToRadians(_fieldOfView) / 2.0);
300 const double ndcX = c.x / (depth * tanHalfFov * aspect);
301 const double ndcY = c.y / (depth * tanHalfFov);
302 return QPointF(((ndcX + 1.0) / 2.0) * _viewportSize.width(), ((1.0 - ndcY) / 2.0) * _viewportSize.height());
303}
304
306{
307 if (_viewportSize.isEmpty()) {
308 return 0;
309 }
310 const QPointF screenCenter(_viewportSize.width() / 2.0, _viewportSize.height() / 2.0);
311 const auto p0 = screenToGround(screenCenter);
312 const auto p1 = screenToGround(screenCenter + QPointF(1, 0));
313 if (!p0 || !p1) {
314 return 0;
315 }
316 const QPointF d = *p1 - *p0;
317 return std::hypot(d.x(), d.y());
318}
319
320qreal GeoMapCamera::distanceForZoomLevel(qreal zoomLevel) const
321{
322 if (_viewportSize.isEmpty()) {
323 return kDefaultDistance;
324 }
325 const double metersPerPixel = TileMath::worldSize() / (TileMath::kTilePixels * std::exp2(zoomLevel));
326 const double tanHalfFov = std::tan(qDegreesToRadians(_fieldOfView) / 2.0);
327 return std::clamp((metersPerPixel * _viewportSize.height()) / (2.0 * tanHalfFov), kMinDistance, kMaxDistance);
328}
329
330QGeoCoordinate GeoMapCamera::centerForCoordinateAtScreenPoint(const QGeoCoordinate& coordinate,
331 const QPointF& screenPos, double worldZ,
332 double centerElevation) const
333{
334 if (_viewportSize.isEmpty()) {
335 return center();
336 }
337
338 // Intersect the pick ray with the horizontal plane at worldZ: solving on the
339 // ground plane instead would center the coordinate's ground footprint, leaving
340 // an elevated point (e.g. a flying vehicle) high on screen under a tilted camera.
341 const double pivotElevation = std::isfinite(centerElevation) ? centerElevation : _centerElevation;
342 const Ray ray =
343 pickRay(_centerWorld, _heading, _tilt, _distance, pivotElevation, _viewportSize, _fieldOfView, screenPos);
344 if (ray.dir.z >= 0.0) {
345 return center(); // at or above the horizon
346 }
347 const double s = (worldZ - ray.origin.z) / ray.dir.z;
348 if (s <= 0.0) {
349 return center(); // plane is behind the camera
350 }
351
352 const QPointF hit(ray.origin.x + (s * ray.dir.x), ray.origin.y + (s * ray.dir.y));
353 const QPointF delta = TileMath::geoToWorld(coordinate) - hit;
354 return TileMath::worldToGeo(_centerWorld + delta);
355}
356
357void GeoMapCamera::beginPan(const QPointF& screenPos)
358{
359 _panAnchorWorld = screenToGround(screenPos);
360}
361
362void GeoMapCamera::panTo(const QPointF& screenPos)
363{
364 if (!_panAnchorWorld) {
365 return;
366 }
367 _anchorToScreen(*_panAnchorWorld, screenPos);
368}
369
370void GeoMapCamera::beginOrbit(const QPointF& screenPos)
371{
372 _orbitAnchorWorld = screenToGround(screenPos);
373 _orbitAnchorScreen = screenPos;
374 _orbitStartScreen = screenPos;
375 _orbitStartHeading = _heading;
376 _orbitStartTilt = _tilt;
377}
378
379void GeoMapCamera::orbitTo(const QPointF& screenPos)
380{
381 if (!_orbitAnchorWorld || _viewportSize.isEmpty()) {
382 return;
383 }
384
385 const QPointF delta = screenPos - _orbitStartScreen;
386 const qreal headingDelta = (delta.x() / _viewportSize.width()) * 360.0;
387 const qreal tiltDelta = (-delta.y() / _viewportSize.height()) * 180.0;
388
389 // Heading change is a rigid rotation of the camera about the vertical axis
390 // through the anchor: rotate the center around the anchor by the same angle.
391 const qreal newHeading = _normalizedHeading(_orbitStartHeading + headingDelta);
392 const qreal appliedHeadingDelta = std::remainder(newHeading - _heading, 360.0);
393 _setCenterWorld(*_orbitAnchorWorld + rotated(_centerWorld - *_orbitAnchorWorld, appliedHeadingDelta));
394 setHeading(newHeading);
395 if (_mode == Mode::Mode3D) {
396 setTilt(_orbitStartTilt + tiltDelta);
397 }
398
399 // Keep the anchor pinned to its on-screen position from gesture start
400 _anchorToScreen(*_orbitAnchorWorld, _orbitAnchorScreen);
401}
402
403void GeoMapCamera::rotateBy(qreal degrees, const QPointF& screenPos)
404{
405 const auto anchor = screenToGround(screenPos);
406 if (!anchor) {
407 return;
408 }
409 _setCenterWorld(*anchor + rotated(_centerWorld - *anchor, degrees));
410 setHeading(_heading + degrees);
411}
412
413void GeoMapCamera::tiltBy(qreal degrees, const QPointF& screenPos)
414{
415 if (_mode != Mode::Mode3D) {
416 return; // 2D locks tilt against gestures
417 }
418 const auto anchor = screenToGround(screenPos);
419 setTilt(_tilt + degrees);
420 if (anchor) {
421 _anchorToScreen(*anchor, screenPos);
422 }
423}
424
425void GeoMapCamera::zoom(qreal amount, const QPointF& screenPos)
426{
427 // 120 angleDelta units (one wheel notch) scales distance by ~0.8
428 zoomBy(std::pow(0.8, amount / 120.0), screenPos);
429}
430
431void GeoMapCamera::zoomBy(qreal factor, const QPointF& screenPos)
432{
433 if (factor <= 0.0) {
434 return;
435 }
436 const auto anchor = screenToGround(screenPos);
437 setDistance(_distance * factor);
438 if (anchor) {
439 _anchorToScreen(*anchor, screenPos);
440 }
441}
442
443void GeoMapCamera::_anchorToScreen(const QPointF& anchorWorld, const QPointF& screenPos)
444{
445 const auto current = screenToGround(screenPos);
446 if (!current) {
447 return;
448 }
449 _setCenterWorld(_centerWorld + (anchorWorld - *current));
450}
451
452qreal GeoMapCamera::_normalizedHeading(qreal heading)
453{
454 const qreal wrapped = std::fmod(heading, 360.0);
455 return (wrapped < 0.0) ? (wrapped + 360.0) : wrapped;
456}
#define QGC_LOGGING_CATEGORY(name, categoryStr)
Q_INVOKABLE void panTo(const QPointF &screenPos)
Continue a pan gesture: the ground point picked at beginPan stays under the cursor.
void distanceChanged()
void centerElevationChanged()
void setCenterElevation(qreal elevation)
void setMode(Mode mode)
qreal distance() const
void setFieldOfView(qreal fov)
std::optional< QPointF > worldToScreen(const QPointF &worldGround, double worldZ=0.0) const
void tiltChanged()
Q_INVOKABLE void beginPan(const QPointF &screenPos)
Start a pan gesture at the given screen position (pixels)
static constexpr qreal kMinTilt
QPointF cameraGroundPosition() const
Q_INVOKABLE qreal sceneUnitsPerPixel() const
Q_INVOKABLE void zoom(qreal amount, const QPointF &screenPos)
std::optional< QPointF > screenToGround(const QPointF &screenPos) const
void setCenter(const QGeoCoordinate &center)
void headingChanged()
qreal centerElevation() const
static constexpr qreal kMinDistance
void setSceneOrigin(const QPointF &origin)
void scenePoseChanged()
void fieldOfViewChanged()
Q_INVOKABLE void zoomBy(qreal factor, const QPointF &screenPos)
Q_INVOKABLE void orbitTo(const QPointF &screenPos)
void setDistance(qreal distance)
GeoMapCamera(QObject *parent=nullptr)
Q_INVOKABLE void lookAt(const QGeoCoordinate &center, qreal heading, qreal tilt, qreal distance)
Set the full pose in one call. tilt/distance are clamped.
QVector3D cameraPosition() const
Q_INVOKABLE void beginOrbit(const QPointF &screenPos)
Start an orbit gesture at the given screen position (pixels)
void setHeading(qreal heading)
QVector3D scenePosition() const
void setViewportSize(const QSizeF &size)
void viewportSizeChanged()
static constexpr qreal kDefaultDistance
Q_INVOKABLE void reset()
Restore the default pose at the current center (heading 0, tilt 0, default distance)
static constexpr qreal kMaxTilt
qreal heading() const
Q_INVOKABLE void tiltBy(qreal degrees, const QPointF &screenPos)
Mode mode() const
std::optional< QPointF > groundPointCapped(const QPointF &screenPos, double maxRange) const
Q_INVOKABLE void rotateBy(qreal degrees, const QPointF &screenPos)
Q_INVOKABLE qreal distanceForZoomLevel(qreal zoomLevel) const
void centerChanged()
qreal tilt() const
Q_INVOKABLE QGeoCoordinate centerForCoordinateAtScreenPoint(const QGeoCoordinate &coordinate, const QPointF &screenPos, double worldZ=0.0, double centerElevation=qQNaN()) const
QGeoCoordinate center() const
void modeChanged()
void setTilt(qreal tilt)
static constexpr qreal kMaxDistance
QQuaternion sceneRotation() const
constexpr int kTilePixels
Nominal tile edge used for meters-per-pixel.
Definition TileMath.h:32
double worldSize()
Full mercator world extent (2*pi*R) in world meters.
Definition TileMath.cc:18
QPointF geoToWorld(const QGeoCoordinate &coord)
Geo -> world meters. Latitude is clamped to +/-kMaxLatitude.
Definition TileMath.cc:23
QGeoCoordinate worldToGeo(const QPointF &world)
World meters -> geo (altitude 0)
Definition TileMath.cc:31