1/****************************************************************************
3 * (c) 2009-2024 QGROUNDCONTROL PROJECT <http://www.qgroundcontrol.org>
5 * QGroundControl is licensed according to the terms in the file
6 * COPYING.md in the root of the source code directory.
8 ****************************************************************************/
12import QtQuick3D.Helpers
16import QGroundControl.Controls
17import QGroundControl.GeoMap
19/// Experimental 2D/3D map control (preview feature): the GeoMap-engine
20/// counterpart of FlightMap. Renders the LOD surface patch quadtree with full
21/// Google Earth-style camera gestures (pan, orbit, zoom, twist, first-person
22/// look) and no view chrome.
26 // Keep map item overlays (GeoMapItem) inside the viewport, matching
27 // QtLocation Map behavior
30 property alias camera: geoCamera
32 // Same contract as FlightMap: block pinch-to-zoom while virtual joystick
33 // thumbs are down (issue #13450)
34 property bool pinchZoomDisabledByVirtualJoysticks: false
36 // Same contract as FlightMap: plain left-click/tap (no drag, no modifier)
37 // in viewport coordinates; consumers convert via surfaceCoordinateAt
38 signal mapClicked(var position)
40 // Geographic coordinate of the rendered terrain surface under a screen
41 // point (viewport pixels): the pick lands on the visible front surface,
42 // so ridges occlude the ground behind them. Works at any tilt, unlike a
43 // ground-plane pick which can land kilometers past elevated terrain.
44 // Invalid coordinate on a sky pick.
45 function surfaceCoordinateAt(screenPos) {
46 return patchModel.surfaceCoordinateAtScreenPoint(geoCamera, screenPos,
47 geoScene.verticalScale * geoScene.terrainScale)
50 // Auto-centering (parity with FlightMap): one-shot GCS/vehicle centering
51 // plus vehicle following, all decided by the shared MapPositionTracker
52 property alias allowGCSLocationCenter: _positionTracker.allowGCSLocationCenter
53 property alias allowVehicleLocationCenter: _positionTracker.allowVehicleLocationCenter
54 property alias keepVehicleCentered: _positionTracker.keepVehicleCentered
55 property alias positionTracker: _positionTracker
56 property rect followViewportRect: Qt.rect(0, 0, width, height) ///< Map area the followed vehicle is kept within
57 property var followOccluders: [] ///< Rects within the viewport covered by host UI
59 // Live SurfaceModel stats for debug overlays
60 property alias patchCount: patchModel.patchCount
61 property alias pendingCount: patchModel.pendingCount
62 property alias maxZoomLevel: patchModel.maxZoomLevel
63 property alias modelStats: patchModel.statsText
64 property alias perfCapturing: patchModel.capturing
66 function startPerfCapture() {
67 patchModel.startCapture()
70 // Returns the CSV file path (empty on failure)
71 function stopPerfCapture() {
72 return patchModel.stopCapture()
75 readonly property int cameraAnimationMs: 500
77 readonly property var _activeVehicle: QGroundControl.multiVehicleManager.activeVehicle
78 readonly property var _activeVehicleCoordinate: _activeVehicle ? _activeVehicle.coordinate : QtPositioning.coordinate()
79 // Vehicle items render at coordinate.altitude + home terrain bias (see
80 // GeoMapVehicleItem): occluder-follow must track that same rendered point or
81 // the recenter target is vertically off by the bias under a tilted camera
82 readonly property var _trackedVehicleCoordinate: QtPositioning.coordinate(_activeVehicleCoordinate.latitude,
83 _activeVehicleCoordinate.longitude,
84 _activeVehicleCoordinate.altitude + _homeTerrainBias)
86 // DEM height minus vehicle-reported AMSL, sampled at home (same math as
87 // GeoMapVehicleItem). terrainHeightAt is not a binding dependency, so
88 // re-sampled explicitly below as terrain tiles arrive and when home moves.
89 property real _homeTerrainBias: 0
91 function _updateHomeTerrainBias() {
92 if (_activeVehicle && _activeVehicle.homePosition.isValid && !isNaN(_activeVehicle.homePosition.altitude)) {
93 _homeTerrainBias = patchModel.terrainDataHeightAt(_activeVehicle.homePosition) - _activeVehicle.homePosition.altitude
99 on_ActiveVehicleChanged: _updateHomeTerrainBias()
101 // Render-statistics overlay (FPS, frame timing, draw calls, texture/mesh
102 // memory) for perf work; owned here because it needs the internal View3D
103 property bool renderStats: false
105 // Terrain displacement factor (see GeoScene.terrainScale)
106 property alias terrainScale: geoScene.terrainScale
108 // Scene wiring for map items (GeoMapItem consumers)
109 readonly property GeoScene scene: geoScene
110 readonly property SurfacePatchModel surfaceModel: patchModel
112 function analyzeSurface() {
113 patchModel.analyzeSurface()
116 // Compass reset: animate heading back to north
117 function animateHeadingToNorth() {
118 headingAnimation.stop()
119 // Wrap through 360 for the short way home; the setter normalizes the
120 // final value back to 0
121 headingAnimation.to = (geoCamera.heading > 180) ? 360 : 0
122 headingAnimation.start()
125 // Show the whole QGeoRectangle at the current heading/tilt
126 function fitToRegion(region) {
127 completeCameraAnimations()
128 geoCamera.fitToRegion(region)
131 // LOD checker colors for debug mode (imagery off)
132 function _lodColor(centerX, centerY, span, zoomLevel) {
133 const parity = (Math.round(centerX / span) + Math.round(centerY / span)) & 1
134 return Qt.hsla((zoomLevel * 0.13) % 1.0, 0.5, parity ? 0.45 : 0.6, 1.0)
137 // Gestures jump a running mode transition to its end state rather
138 // than stranding the pose mid-transition (2D mode locks tilt, so a
139 // half-finished 3D->2D tilt could never recover by gesture)
140 function completeCameraAnimations() {
141 tiltAnimation.complete()
142 headingAnimation.complete()
143 terrainAnimation.complete()
144 // A recenter jumped to its end would fight the starting gesture; drop it
145 recenterAnimation.stop()
148 // Keep the camera's look-at point riding the terrain data, not the drawn
149 // mesh: the pivot feeds LOD selection, so following patch churn would loop.
150 // Without this the orbit center stays at z=0 and a close-zoom 2D->3D switch
151 // over high terrain puts the camera under the mesh (blank view)
152 function _updateCenterElevation() {
153 geoCamera.centerElevation = patchModel.terrainDataHeightAt(geoCamera.center)
154 * geoScene.verticalScale * geoScene.terrainScale
157 // Scene-space z of the rendered surface under a screen point, so gestures
158 // anchor to the terrain the user actually clicked instead of the z=0
159 // plane far beneath it. Sample at the ground-plane hit, then refine once
160 // at that elevation (same two-step solve as onRecenterVehicleTo).
161 function _surfaceZAt(screenPos) {
162 const coord = geoCamera.coordinateAtScreenPoint(screenPos)
163 if (!coord.isValid) {
166 const zScale = geoScene.verticalScale * geoScene.terrainScale
167 const z = patchModel.terrainHeightAt(coord) * zScale
168 const refined = geoCamera.coordinateAtScreenPoint(screenPos, z)
169 return refined.isValid ? (patchModel.terrainHeightAt(refined) * zScale) : z
174 objectName: "geoMapCamera"
175 viewportSize: Qt.size(root.width, root.height)
176 sceneOrigin: geoScene.sceneOrigin
177 // Startup pose: overhead view of a land area with terrain relief so
178 // imagery and elevation are visually verifiable (home-position
179 // integration comes later). Until this center applies, the camera is
180 // unpositioned and SurfaceModel builds nothing (see
181 // GeoMapCamera::isPositioned), so the null-island default pose never
182 // triggers doomed terrain fetches at (0,0).
183 center: QtPositioning.coordinate(47.6329078, -122.0876875)
188 objectName: "geoMapScene"
190 content3D: mapContent3D
195 objectName: "geoMapPatchModel"
198 statsEnabled: root.renderStats
199 // Drape the map imagery the rest of QGC uses (empty disables imagery)
200 mapType: QGroundControl.settingsManager.flightMapSettings.mapProvider.rawValue
201 + " " + QGroundControl.settingsManager.flightMapSettings.mapType.rawValue
207 gcsPosition: QGroundControl.positionManager.gcsPosition
208 vehicleCoordinate: root._activeVehicleCoordinate
209 centerGCSWhenVehicleValid: QGroundControl.settingsManager.flyViewSettings.keepMapCenteredOnVehicle.rawValue
210 userInteracting: panHandler.active || orbitHandler.active || shiftOrbitHandler.active
211 || lookHandler.active || metaLookHandler.active || pinchHandler.active
212 animating: recenterAnimation.running
214 onCenterMap: (coordinate, firstPosition) => {
215 recenterAnimation.stop()
216 geoCamera.center = coordinate
218 geoCamera.distance = geoCamera.distanceForZoomLevel(QGroundControl.flightMapInitialZoom)
222 onRecenterVehicleTo: (screenPoint) => {
223 recenterAnimation.stop()
224 recenterAnimation.from = geoCamera.center
225 // The solve assumes the camera pivot elevation stays put, but the
226 // pivot rides the terrain (_updateCenterElevation): re-solve at
227 // the elevation the pivot will settle to at the destination
228 let target = geoScene.centerForCoordinateAtScreenPoint(root._trackedVehicleCoordinate, screenPoint)
229 target = geoScene.centerForCoordinateAtScreenPoint(root._trackedVehicleCoordinate, screenPoint,
230 patchModel.terrainDataHeightAt(target))
231 recenterAnimation.to = target
232 recenterAnimation.start()
238 running: root.visible
241 const screenPos = geoScene.screenPositionFor(root._trackedVehicleCoordinate)
242 // Unprojectable (behind camera) counts as off-screen: recenter
243 const vehiclePoint = (screenPos === undefined) ? Qt.point(-1, -1) : screenPos
244 _positionTracker.evaluateOccluderFollow(vehiclePoint, root.followViewportRect, root.followOccluders)
250 objectName: "geoMapViewport"
253 environment: SceneEnvironment {
254 clearColor: "#1a2028"
255 backgroundMode: SceneEnvironment.Color
256 // Distance haze toward the background color hides the far-field
257 // LOD rings at the patch range edge
262 depthNear: 10 * geoCamera.distance
263 depthFar: (patchModel.maxRangeMultiplier + 1) * geoCamera.distance
269 ambientColor: Qt.rgba(0.4, 0.4, 0.4, 1.0)
272 // Scene camera fully driven by the GeoMapCamera pose. Clip planes
273 // scale with distance so the surface stays visible from close-up
274 // through whole-earth views.
277 objectName: "geoMapSceneCamera"
278 position: geoCamera.scenePosition
279 rotation: geoCamera.sceneRotation
280 fieldOfView: geoCamera.verticalFieldOfView
281 clipNear: Math.max(1, geoCamera.distance / 1000)
282 // Ray length to the farthest retained ground point is at most
283 // maxRange + camera height <= (maxRangeMultiplier + 1) * distance
284 clipFar: Math.max(100000, geoCamera.distance * (patchModel.maxRangeMultiplier + 1))
287 // One surface patch per active SurfaceModel entry: draped tile
288 // imagery once delivered, LOD-colored fallback while loading.
289 // The shared z-scale converts true-meter heights into mercator scene
290 // units (verticalScale) and animates terrain flat<->full during the
291 // 2D/3D mode transition (terrainScale) without geometry rebuilds:
292 // one binding here instead of one per delegate (delegate creation
293 // cost dominates patch churn, and transitions re-evaluated it N
294 // times per frame). Never exactly 0: a singular scale breaks the
297 scale: Qt.vector3d(1, 1, Math.max(0.0001, geoScene.terrainScale * geoScene.verticalScale))
303 objectName: "geoMapPatchDelegate"
304 required property real centerX
305 required property real centerY
306 required property real span
307 required property int zoomLevel
308 required property var heights
309 required property bool covered
310 required property var edgeLodDeltas
311 required property var tileImage
312 required property bool hasTileImage
314 // A covered pending patch stays hidden: its empty flat grid
315 // would z-fight the retained retiring cover
317 position: Qt.vector3d(centerX, centerY, 0)
318 geometry: PatchGeometry {
319 gridSize: patchModel.gridSize
320 span: patchDelegate.span
321 heights: patchDelegate.heights
322 edgeLodDeltas: patchDelegate.edgeLodDeltas
327 // Clamp: default Repeat wrap bleeds opposite-edge texels
328 // at UV 0/1, drawing 1-px seams on every patch border
329 tilingModeHorizontal: Texture.ClampToEdge
330 tilingModeVertical: Texture.ClampToEdge
331 textureData: PatchTextureData {
332 image: patchDelegate.tileImage
338 cullMode: Material.NoCulling
339 // Unlit: tiles carry their own shading, and lighting
340 // would show the per-patch normal seams
341 lighting: DefaultMaterial.NoLighting
342 diffuseMap: patchDelegate.hasTileImage ? patchTexture : null
343 // Loading fallback: quiet neutral when imagery is on
344 // (LOD flash on zoom); LOD checker colors in debug mode
345 diffuseColor: patchDelegate.hasTileImage
347 : (patchModel.mapType !== ""
349 : root._lodColor(patchDelegate.centerX, patchDelegate.centerY,
350 patchDelegate.span, patchDelegate.zoomLevel))
357 // Map item 3D content (GeoMapItem delegate3D instances). Sibling of
358 // the terrain node: items pre-scale their own z, and the terrain
359 // z-scale would squash models during the 2D/3D transition.
364 // Gestures (Google Earth semantics): the ground point under the cursor
365 // at gesture start stays under the cursor throughout. Any gesture
366 // completes a running mode/compass animation to its end state so
367 // the two never fight over the same pose properties.
371 acceptedButtons: Qt.LeftButton
372 // Plain drag only: Shift/Ctrl+left-drag are the orbit/look gestures below
373 acceptedModifiers: Qt.NoModifier
376 root.completeCameraAnimations()
377 // Anchor at the current position, not pressPosition: after a
378 // two-finger pinch drops to one finger this handler re-activates
379 // with a stale pressPosition, and anchoring there jumps the map.
380 geoCamera.beginPan(centroid.position, root._surfaceZAt(centroid.position))
385 geoCamera.panTo(centroid.position)
390 // Right/middle-drag orbits about the pressed ground point: full width =
391 // 360 deg heading, full height = 180 deg tilt.
392 // Mouse/touchpad only: acceptedButtons doesn't filter touch points, so
393 // without acceptedDevices this handler steals single-finger drags from
394 // panHandler on touchscreens (touch orbits via PinchHandler twist instead).
395 // TouchPad keeps trackpad secondary-click drag working.
399 acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
400 acceptedButtons: Qt.RightButton | Qt.MiddleButton
401 // Unmodified only: macOS synthesizes Ctrl+left-click as a right-click
402 // (carrying MetaModifier), which must fall through to metaLookHandler
403 acceptedModifiers: Qt.NoModifier
406 root.completeCameraAnimations()
407 geoCamera.beginOrbit(centroid.pressPosition, root._surfaceZAt(centroid.pressPosition))
408 // Apply motion accumulated before activation: a short drag
409 // can activate and release with no further centroid change
410 geoCamera.orbitTo(centroid.position)
415 geoCamera.orbitTo(centroid.position)
422 // Default acceptedDevices=Mouse drops trackpad scroll (and mouse
423 // wheel on Wayland/xcb, which misreport as TouchPad) — see the
424 // FlightMap WheelHandler comment for the full platform rundown
425 acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
426 onWheel: (event) => {
427 root.completeCameraAnimations()
428 geoCamera.zoom(event.angleDelta.y, point.position)
432 // Shift+left-drag orbits about the pressed ground point, same as
433 // right/middle-drag (keyboard-modifier alternative for one-button mice)
435 id: shiftOrbitHandler
437 acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
438 acceptedButtons: Qt.LeftButton
439 acceptedModifiers: Qt.ShiftModifier
442 root.completeCameraAnimations()
443 geoCamera.beginOrbit(centroid.pressPosition, root._surfaceZAt(centroid.pressPosition))
444 // Apply motion accumulated before activation (see orbitHandler)
445 geoCamera.orbitTo(centroid.position)
450 geoCamera.orbitTo(centroid.position)
455 // Ctrl+left-drag is first-person look: the camera stays put and the
456 // view rotates, like turning your head (Google Earth Ctrl+drag).
457 // Drag toward where you want to look.
461 acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
462 acceptedButtons: Qt.LeftButton
463 acceptedModifiers: Qt.ControlModifier
466 root.completeCameraAnimations()
467 geoCamera.beginLook(centroid.pressPosition)
468 // Apply motion accumulated before activation (see orbitHandler)
469 geoCamera.lookTo(centroid.position)
474 geoCamera.lookTo(centroid.position)
479 // macOS delivers the physical Ctrl key as Qt.MetaModifier (Qt swaps
480 // Ctrl/Cmd), so accept it too: Ctrl+drag looks on every platform
481 // (and Cmd+drag still works via lookHandler, matching Google Earth).
482 // RightButton included because macOS synthesizes Ctrl+left-click as a
483 // right-click, so that's the button this drag actually arrives on.
487 enabled: Qt.platform.os === "osx"
488 acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
489 acceptedButtons: Qt.LeftButton | Qt.RightButton
490 acceptedModifiers: Qt.MetaModifier
493 root.completeCameraAnimations()
494 geoCamera.beginLook(centroid.pressPosition)
495 // Apply motion accumulated before activation (see orbitHandler)
496 geoCamera.lookTo(centroid.position)
501 geoCamera.lookTo(centroid.position)
506 // Pinch zooms about the centroid; two-finger twist rotates around it
507 // (handler rotation is positive clockwise on screen; heading follows
508 // so the world tracks the fingers)
512 enabled: !root.pinchZoomDisabledByVirtualJoysticks
515 root.completeCameraAnimations()
518 onScaleChanged: (delta) => geoCamera.zoomBy(1 / delta, centroid.position)
519 onRotationChanged: (delta) => geoCamera.rotateBy(delta, centroid.position)
522 // Plain click/tap: the default DragThreshold gesture policy means any
523 // pan drag cancels the tap, so this never fires during camera gestures
525 acceptedButtons: Qt.LeftButton
526 acceptedModifiers: Qt.NoModifier
527 onTapped: (eventPoint, button) => root.mapClicked(eventPoint.position)
531 // Pivot ring (Google Earth-style): marks the ground point an orbit drag
532 // rotates about, visible only while the drag is active. The pivot stays
533 // pinned to its press position on screen, so the ring never moves.
535 id: orbitPivotIndicator
536 objectName: "geoMapOrbitPivotIndicator"
538 readonly property point _pivot: orbitHandler.active ? orbitHandler.centroid.pressPosition
539 : shiftOrbitHandler.centroid.pressPosition
541 visible: orbitHandler.active || shiftOrbitHandler.active
542 x: _pivot.x - (width / 2)
543 y: _pivot.y - (height / 2)
544 width: ScreenTools.defaultFontPixelHeight * 1.5
548 // Dark halo keeps the white ring visible over light imagery
549 border.color: Qt.rgba(0, 0, 0, 0.4)
550 border.width: (ScreenTools.defaultFontPixelHeight / 8) + 2
557 border.color: "white"
558 border.width: ScreenTools.defaultFontPixelHeight / 8
562 anchors.centerIn: parent
563 width: ScreenTools.defaultFontPixelHeight / 4
570 /// Ground station location (parity with the FlightMap marker)
575 surfaceModel: patchModel
576 coordinate: gcsIndicator._gcsPosition
577 anchorPoint: Qt.point(gcsImage.width / 2, gcsImage.height / 2)
578 width: gcsImage.width
579 height: gcsImage.height
580 visible: gcsIndicator._gcsPosition.isValid
582 readonly property var _gcsPosition: QGroundControl.positionManager.gcsPosition
583 readonly property real _gcsHeading: QGroundControl.positionManager.gcsHeading
587 source: isNaN(gcsIndicator._gcsHeading) ? "/res/QGCLogoFull.svg" : "/res/QGCLogoArrow.svg"
590 fillMode: Image.PreserveAspectFit
591 height: ScreenTools.defaultFontPixelHeight * (isNaN(gcsIndicator._gcsHeading) ? 1.75 : 2.5)
592 sourceSize.height: height
593 transform: Rotation {
594 origin.x: gcsImage.width / 2
595 origin.y: gcsImage.height / 2
596 // Camera heading rotates map north on screen; the arrow follows
597 angle: isNaN(gcsIndicator._gcsHeading) ? 0 : gcsIndicator._gcsHeading + geoCamera.heading
603 objectName: "geoMapRenderStats"
604 anchors.left: parent.left
605 anchors.top: parent.top
607 visible: root.renderStats
608 resourceDetailsVisible: true
615 duration: root.cameraAnimationMs
616 easing.type: Easing.InOutQuad
623 duration: root.cameraAnimationMs
624 easing.type: Easing.InOutQuad
630 property: "terrainScale"
631 duration: root.cameraAnimationMs
632 easing.type: Easing.InOutQuad
635 CoordinateAnimation {
636 id: recenterAnimation
640 easing.type: Easing.InOutQuad
643 // Single transition path for every mode writer (button, goTo2D/3D,
644 // C++): mode changes always animate tilt and terrain to the mode's
645 // targets, so camera state and terrain displacement cannot desync
648 function onModeChanged() {
649 const to3D = geoCamera.mode === GeoMapCamera.Mode3D
651 tiltAnimation.to = to3D ? geoCamera.default3DTilt : 0
652 tiltAnimation.start()
653 terrainAnimation.stop()
654 terrainAnimation.to = to3D ? 1 : 0
655 terrainAnimation.start()
657 function onCenterChanged() {
658 root._updateCenterElevation()
664 function onTerrainDataChanged() {
665 root._updateCenterElevation()
666 root._updateHomeTerrainBias()
672 function onTerrainScaleChanged() {
673 root._updateCenterElevation()
675 // verticalScale depends on the scene origin
676 function onSceneOriginChanged() {
677 root._updateCenterElevation()
682 target: root._activeVehicle
683 function onHomePositionChanged() {
684 root._updateHomeTerrainBias()