Qt Quick 之动态旋转刻度盘(无人机中指南针 Demo )

发布于:2025-08-06 ⋅ 阅读:(18) ⋅ 点赞:(0)


Gitee 源码:     QmlLearningPro选择子工程 RotatingDial.pro

QML 其它文章请点击这里:     QT QUICK QML 学习笔记


在无人机系统中,“航向”是一个核心的导航维度:你需要知道机头朝向哪里、当前航向与目标的偏差。

本文基于一个用 QtQuick Canvas + 2D 绘制 实现的旋转刻度圆 Demo,逐步拆解其设计原理、文字定向技巧。

一、概览

演示:

在这里插入图片描述

功能:

  1. 每 15° 一个刻度,其中 45° 的(即主方向 N, NE, E…)有加粗,并用字母标出方向(N, NE 等),其他标出角度数字。

  2. 所有文字都做了“下边朝向圆心”的旋转处理 —— 这样无论圆怎么转,外圈方向标注都是“朝外看”的自然视觉效果。

  3. 中心有一个固定指针,代表界面参考方向(通常是“机体前方”/北)。

  4. 模拟的 rotationAngle 每 50ms 递增一点,用来驱动圆整体的旋转(在真机中这会来自磁力计/融合后的航向)。

  5. 底部显示当前最接近的八个主方向(N, NE, …)。

二、关键实现技巧拆解

1. 文本“下边朝向圆心”的旋转

计算该标签位置向量 (lx, ly)(相对于中心)。

计算从标签指向圆心的向心向量 (-lx, -ly),得到它的角度 angleToCenter = atan2(-ly, -lx)。

文字默认“下边”是朝 +Y 方向,因此需要旋转 angleToCenter - 90°(即 angleToCenter - π/2)使文字底部对齐该向心方向。

通过 Canvas 的 ctx.rotate(…) 做局部变换然后绘制。

这个技巧避免了文字“倒着”或“横着”难以辨认的问题,即使盘在高速旋转,标签方向始终有一种“朝外”的、一致的视觉习惯。

2. 主方向字母与普通数字差异化

代码中用正则 ^[NSEW]{1,2}$
判断是方向字母,对它们赋予不同颜色(例:绿色),使关键方向一目了然。

三、指南针

在真实无人机中,航向(heading)不是靠模拟自增给出的,而是来自一套传感器融合系统。要把这个 UI Demo 和实际传感器融合。

四、UI 与交互增强建议(适用于无人机控制台)

北向锁定切换:用户可以选择“磁北/真北/机头”模式,UI 上文字颜色/标签备注随之变化。

过渡动画:航向突变时用缓动处理防止跳变,避免飞控短时波动造成 UI 抖动。

偏差提示:例如显示“偏离目标航向 X°”,配合目标箭头指示(用另一层小箭头叠加)。

可配置刻度与单位:让用户切换 360°、16 分度、甚至自定义方向标签(比如航线编号)。

五、代码

具体见     QmlLearningPro选择子工程 RotatingDial.pro

import QtQuick 2.15
import QtQuick.Window 2.15
import QtQuick.Controls 2.11
import QtQml 2.15


Window {
    id:     root
    width:  600
    height: 600
    visible: true
    color: "#202020"
    title: "旋转的刻度圆(文字下边朝向圆心)"

    property real rotationAngle: 0   // 当前旋转角度(模拟数据)
    property real radius: Math.min(width, height) * 0.3

    // 方向映射(假设 NW 是 315°)
    readonly property var directionMap: {
        "0": "N",
        "45": "NE",
        "90": "E",
        "135": "SE",
        "180": "S",
        "225": "SW",
        "270": "W",
        "315": "NW",
        "360": "N"
    }

    // 模拟数据:每 50ms 递增一点角度
    Timer {
        interval: 50
        repeat: true
        running: true
        onTriggered: {
            root.rotationAngle = (root.rotationAngle + 0.5) % 360
        }
    }

    // 中心容器
    Item {
        id: dial
        anchors.centerIn: parent
        width: parent.width
        height: parent.height
        transform: Rotation {
            origin.x: root.width/2
            origin.y: root.height/2
            angle: root.rotationAngle
        }

        // 圆盘背景 + 刻度
        Canvas {
            id: circleCanvas
            anchors.centerIn: parent
            width:  parent.width
            height: parent.height
            onPaint: {
                var ctx = getContext("2d")
                ctx.reset()
                ctx.translate(width/2, height/2)

                // 画外圈
                ctx.beginPath()
                ctx.lineWidth = 6
                ctx.strokeStyle = "#555555"
                ctx.arc(0,0, root.radius + 10, 0, Math.PI*2)
                ctx.stroke()

                // 每 15° 画刻度
                for (var deg=0; deg<=360; deg += 15) {
                    var rad = (deg - 90) * Math.PI / 180  // 0° 在顶部
                    var innerR = root.radius - 10
                    var outerR = root.radius + 5

                    ctx.beginPath()
                    ctx.lineWidth = (deg % 45 === 0) ? 3 : 1.5
                    ctx.strokeStyle = "#888888"
                    var x1 = Math.cos(rad) * innerR
                    var y1 = Math.sin(rad) * innerR
                    var x2 = Math.cos(rad) * outerR
                    var y2 = Math.sin(rad) * outerR
                    ctx.moveTo(x1, y1)
                    ctx.lineTo(x2, y2)
                    ctx.stroke()
                }

                // 每 15° 画标签,文字下边朝向圆心
                for (var deg=0; deg<=360; deg += 15) {
                    var rad = (deg - 90) * Math.PI / 180  // 0° 在顶部
                    var labelR = root.radius + 25
                    var lx = Math.cos(rad) * labelR
                    var ly = Math.sin(rad) * labelR

                    // 决定要显示什么:方向字母优先
                    var text = ""
                    if (directionMap.hasOwnProperty(deg.toString())) {
                        text = directionMap[deg.toString()]
                    } else if (deg === 360) {
                        text = directionMap["360"]
                    } else {
                        text = deg.toString()
                    }

                    // 文字旋转:使“下边”朝向圆心
                    // 向心向量是 (-lx, -ly),其角度:
                    var angleToCenter = Math.atan2(-ly, -lx)  // 弧度
                    // 需要把文字的“下边”(正 y 方向,角度 90°)对齐到这个向量:
                    var rotateRad = angleToCenter - Math.PI/2

                    ctx.save()
                    ctx.translate(lx, ly)
                    ctx.rotate(rotateRad)

                    // 文字样式
                    ctx.font = "bold 14px Sans"
                    ctx.textAlign = "center"
                    ctx.textBaseline = "middle"
                    if (/^[NSEW]{1,2}$/.test(text)) {
                        ctx.fillStyle = "#00ff00" // 方向:绿
                    } else {
                        ctx.fillStyle = "#ffffff" // 数字:白
                    }
                    ctx.fillText(text, 0, 0)
                    ctx.restore()
                }
            }
            onWidthChanged: requestPaint()
            onHeightChanged: requestPaint()
            Component.onCompleted: requestPaint()
        }

        // 中心指针(可选,标示当前 0 方向)
        Rectangle {
            width: 6
            height: root.radius * 0.6
            anchors.horizontalCenter: parent.horizontalCenter
            anchors.verticalCenter: parent.verticalCenter
            color: "#ff5555"
            radius: 3
            y: parent.height/2 - height
        }

        // 当前角度显示(不随着圆盘转动,固定在界面)
        Text {
            id: angleLabel
            text: "旋转角度: " + rotationAngle.toFixed(1) + "°"
            color: "#ffffff"
            font.pixelSize: 16
            anchors.horizontalCenter: parent.horizontalCenter
            anchors.top: parent.top
            anchors.topMargin: 10
        }
    }

    // 外圈当前方向提示
    Text {
        id: currentDir
        anchors.bottom: parent.bottom
        anchors.horizontalCenter: parent.horizontalCenter
        anchors.bottomMargin: 10
        font.pixelSize: 16
        color: "#ffffff"
        text: {
            var normalized = (rotationAngle % 360 + 360) % 360
            var closest = Math.round(normalized / 45) * 45
            if (closest === 360) closest = 0
            var dir = directionMap[closest.toString()] || closest + "°"
            return "当前最接近方向: " + dir
        }
    }
}

网站公告

今日签到

点亮在社区的每一天
去签到