
PyQt+QML 实现飞控仪表盘:姿态、高度、速度,飞行数据等 实时显示(一)
- 一、说明
- 二、效果展示
- 三、源码分享
-
- 1、工程结构
- 2、库安装
- 3、main.py
- 4、main.qml
- 5、Adi.qml
- 6、Alt.qml
- 7、Asi.qml
- 8、图片资源下载
- 四、实现原理
-
- 1、整体架构与数据流
- 2、数据驱动:Timer 模拟飞行数据
- 3、属性绑定:QML 的响应式机制
- 4、分层渲染:仪表盘的绘制原理
-
- 4.1 姿态仪(Adi.qml)——滚转与俯仰
- 4.2 高度表(Alt.qml)——多指针联动
- 4.3 速度表(Asi.qml)——非线性刻度映射
- 5、自适应缩放
- 6、小结
一、说明
由于篇幅限制此篇只显示3个仪表,剩余见下一篇
二、效果展示

三、源码分享
1、工程结构

2、库安装

3、main.py
import sys
import os
from PyQt6.QtCore import QUrl
from PyQt6.QtGui import QGuiApplication
from PyQt6.QtQml import QQmlApplicationEngine
def main():
app = QGuiApplication(sys.argv)
engine = QQmlApplicationEngine()
qml_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "main.qml")
engine.load(QUrl.fromLocalFile(qml_path))
if not engine.rootObjects():
print("错误:无法加载 QML 文件", file=sys.stderr)
sys.exit(–1)
sys.exit(app.exec())
if __name__ == "__main__":
main()
4、main.qml
import QtQuick
import QtQuick.Window
import QtQuick.Controls
import QtQuick.Layouts
ApplicationWindow {
id: root
visible: true
width: 1200
height: 900
title: "Flight Instruments"
color: "#1a1a2e"
// Flight parameters (driven by test timer or manual control)
property real alpha: 0
property real beta: 0
property real roll: 0
property real pitch: 0
property real heading: 0
property real slipSkid: 0
property real turnRate: 0
property real devH: 0
property real devV: 0
property real airspeed: 0
property real altitude: 0
property real pressure: 30.0
property real climbRate: 0
property real machNo: 0
property real adf: 0
property real dme: 0
// Simulation state
property bool autoMode: true
property real realTime: 0
// 3×3 Grid layout
GridLayout {
anchors.fill: parent
columns: 3
rows: 3
rowSpacing: 4
columnSpacing: 4
// Row 1: ADI, ALT, ASI
Item {
Layout.fillWidth: true
Layout.fillHeight: true
Label {
anchors.top: parent.top
anchors.horizontalCenter: parent.horizontalCenter
text: "ADI"
color: "#aaaacc"
font.pixelSize: 12
z: 10
}
Adi {
anchors.fill: parent
roll: root.roll
pitch: root.pitch
}
}
Item {
Layout.fillWidth: true
Layout.fillHeight: true
Label {
anchors.top: parent.top
anchors.horizontalCenter: parent.horizontalCenter
text: "ALT"
color: "#aaaacc"
font.pixelSize: 12
z: 10
}
Alt {
anchors.fill: parent
altitude: root.altitude
pressure: root.pressure
}
}
Item {
Layout.fillWidth: true
Layout.fillHeight: true
Label {
anchors.top: parent.top
anchors.horizontalCenter: parent.horizontalCenter
text: "ASI"
color: "#aaaacc"
font.pixelSize: 12
z: 10
}
Asi {
anchors.fill: parent
airspeed: root.airspeed
}
}
// Row 2: HSI, TC, NAV
Item {
Layout.fillWidth: true
Layout.fillHeight: true
Label {
anchors.top: parent.top
anchors.horizontalCenter: parent.horizontalCenter
text: "HSI"
color: "#aaaacc"
font.pixelSize: 12
z: 10
}
Hsi {
anchors.fill: parent
heading: root.heading
}
}
Item {
Layout.fillWidth: true
Layout.fillHeight: true
Label {
anchors.top: parent.top
anchors.horizontalCenter: parent.horizontalCenter
text: "TC"
color: "#aaaacc"
font.pixelSize: 12
z: 10
}
Tc {
anchors.fill: parent
turnRate: root.turnRate
slipSkid: root.slipSkid * 15.0
}
}
Item {
Layout.fillWidth: true
Layout.fillHeight: true
Label {
anchors.top: parent.top
anchors.horizontalCenter: parent.horizontalCenter
text: "NAV"
color: "#aaaacc"
font.pixelSize: 12
z: 10
}
Nav {
anchors.fill: parent
heading: root.heading
headingBug: 0
course: 0
bearing: root.adf
bearingVisible: true
deviation: root.devH
deviationVisible: true
distance: root.dme
distanceVisible: true
}
}
// Row 3: VSI, Controls
Item {
Layout.fillWidth: true
Layout.fillHeight: true
Label {
anchors.top: parent.top
anchors.horizontalCenter: parent.horizontalCenter
text: "VSI"
color: "#aaaacc"
font.pixelSize: 12
z: 10
}
Vsi {
anchors.fill: parent
climbRate: root.climbRate
}
}
// Control Panel – spans 2 columns
Item {
Layout.columnSpan: 2
Layout.fillWidth: true
Layout.fillHeight: true
Rectangle {
anchors.fill: parent
anchors.margins: 8
color: "#16213e"
radius: 8
border.color: "#334466"
border.width: 1
Column {
anchors.centerIn: parent
spacing: 12
width: parent.width * 0.8
Label {
text: "Flight Instruments Test Panel"
color: "#ffffff"
font.pixelSize: 16
font.bold: true
anchors.horizontalCenter: parent.horizontalCenter
}
Button {
text: root.autoMode ? "⏸ Stop Auto" : "▶ Start Auto"
anchors.horizontalCenter: parent.horizontalCenter
width: 200
palette.button: root.autoMode ? "#e94560" : "#0f3460"
palette.buttonText: "#ffffff"
onClicked: root.autoMode = !root.autoMode
}
Grid {
columns: 3
spacing: 8
anchors.horizontalCenter: parent.horizontalCenter
Label { text: "Roll:"; color: "#aaaacc"; font.pixelSize: 11 }
Label { text: roll.toFixed(1) + "°"; color: "#ffffff"; font.pixelSize: 11 }
Label { text: ""; color: "#aaaacc"; font.pixelSize: 11 }
Label { text: "Pitch:"; color: "#aaaacc"; font.pixelSize: 11 }
Label { text: pitch.toFixed(1) + "°"; color: "#ffffff"; font.pixelSize: 11 }
Label { text: ""; color: "#aaaacc"; font.pixelSize: 11 }
Label { text: "Heading:"; color: "#aaaacc"; font.pixelSize: 11 }
Label { text: heading.toFixed(1) + "°"; color: "#ffffff"; font.pixelSize: 11 }
Label { text: ""; color: "#aaaacc"; font.pixelSize: 11 }
Label { text: "Speed:"; color: "#aaaacc"; font.pixelSize: 11 }
Label { text: airspeed.toFixed(0) + " kt"; color: "#ffffff"; font.pixelSize: 11 }
Label { text: "Alt:"; color: "#aaaacc"; font.pixelSize: 11 }
Label { text: altitude.toFixed(0) + " ft"; color: "#ffffff"; font.pixelSize: 11 }
Label { text: "V/S:"; color: "#aaaacc"; font.pixelSize: 11 }
Label { text: climbRate.toFixed(0) + " ft/m"; color: "#ffffff"; font.pixelSize: 11 }
}
}
}
}
}
// Test Timer – generates sinusoidal flight data (ported from MainWindow.cpp)
Timer {
id: testTimer
interval: 50
repeat: true
running: true
triggeredOnStart: false
onTriggered: {
if (!root.autoMode) return
const dt = interval / 1000.0
root.realTime += dt
const t = root.realTime
root.alpha = 20.0 * Math.sin(t / 10.0)
root.beta = 15.0 * Math.sin(t / 10.0)
root.roll = 180.0 * Math.sin(t / 10.0)
root.pitch = 90.0 * Math.sin(t / 20.0)
root.heading = 360.0 * Math.sin(t / 40.0)
root.slipSkid = 1.0 * Math.sin(t / 10.0)
root.turnRate = 7.0 * Math.sin(t / 10.0)
root.devH = 1.0 * Math.sin(t / 20.0)
root.devV = 1.0 * Math.sin(t / 20.0)
root.airspeed = 125.0 * Math.sin(t / 40.0) + 125.0
root.altitude = 9000.0 * Math.sin(t / 40.0) + 9000.0
root.pressure = 2.0 * Math.sin(t / 20.0) + 30.0
root.climbRate = 650.0 * Math.sin(t / 20.0)
root.machNo = root.airspeed / 650.0
root.adf = –360.0 * Math.sin(t / 50.0)
root.dme = 99.0 * Math.sin(t / 100.0)
}
}
}
5、Adi.qml
import QtQuick
Item {
id: root
// Attitude Director Indicator
// Original size: 240×240
property real roll: 0 // degrees, range: –180 to 180
property real pitch: 0 // degrees, range: –25 to 25
readonly property real origW: 240
readonly property real origH: 240
readonly property real pixPerDeg: 1.7
property real s: Math.min(width / origW, height / origH)
// Clamp values
property real clampedRoll: Math.max(–180, Math.min(180, roll))
property real clampedPitch: Math.max(–25, Math.min(25, pitch))
// Face translation for pitch (in 240×240 coordinate space)
property real pitchDelta: pixPerDeg * clampedPitch
property real rollRad: clampedRoll * Math.PI / 180.0
property real faceDeltaX: pitchDelta * Math.sin(rollRad)
property real faceDeltaY: pitchDelta * Math.cos(rollRad)
Item {
width: root.origW
height: root.origH
scale: root.s
anchors.centerIn: parent
// Back (z: –30) – sky/ground background, rotates by –roll
Image {
width: 240
height: 240
source: "image/adi/adi_back.svg"
fillMode: Image.PreserveAspectFit
transformOrigin: Item.Center
rotation: –root.clampedRoll
}
// Face (z: –20) – pitch ladder, rotates by –roll AND translates for pitch
// Wrapper Item handles translation, inner Image handles rotation
Item {
x: root.faceDeltaX
y: root.faceDeltaY
width: 240
height: 240
Image {
width: 240
height: 240
source: "image/adi/adi_face.svg"
fillMode: Image.PreserveAspectFit
transformOrigin: Item.Center
rotation: –root.clampedRoll
}
}
// Ring (z: –10) – roll indicator, rotates by –roll
Image {
width: 240
height: 240
source: "image/adi/adi_ring.svg"
fillMode: Image.PreserveAspectFit
transformOrigin: Item.Center
rotation: –root.clampedRoll
}
// Case (z: 10) – static bezel
Image {
width: 240
height: 240
source: "image/adi/adi_case.svg"
fillMode: Image.PreserveAspectFit
}
}
}
6、Alt.qml
import QtQuick
Item {
id: root
// Barometric Altimeter
// Original size: 240×240
property real altitude: 0 // feet
property real pressure: 28.0 // inHg, range: 28.0–31.5
readonly property real origW: 240
readonly property real origH: 240
property real s: Math.min(width / origW, height / origH)
Item {
width: root.origW
height: root.origH
scale: root.s
anchors.centerIn: parent
// Face 1 – pressure drum (z: –50)
// rotates by –(pressure – 28.0) * 100
Image {
width: 240
height: 240
source: "image/alt/alt_face_1.svg"
fillMode: Image.PreserveAspectFit
transformOrigin: Item.Center
rotation: –(Math.max(28.0, Math.min(31.5, root.pressure)) – 28.0) * 100
}
// Face 2 – static markings (z: –40)
Image {
width: 240
height: 240
source: "image/alt/alt_face_2.svg"
fillMode: Image.PreserveAspectFit
}
// Face 3 – 10000ft scale (z: –30)
// rotates by altitude * 0.0036
Image {
width: 240
height: 240
source: "image/alt/alt_face_3.svg"
fillMode: Image.PreserveAspectFit
transformOrigin: Item.Center
rotation: root.altitude * 0.0036
}
// Hand 1 – long hand for 1000s ft (z: –20)
// rotates by altitude * 0.036
Image {
width: 240
height: 240
source: "image/alt/alt_hand_1.svg"
fillMode: Image.PreserveAspectFit
transformOrigin: Item.Center
rotation: root.altitude * 0.036
}
// Hand 2 – short hand for 100s ft (z: –10)
// rotates by (altitude % 1000) * 0.36
Image {
width: 240
height: 240
source: "image/alt/alt_hand_2.svg"
fillMode: Image.PreserveAspectFit
transformOrigin: Item.Center
rotation: {
var altInt = Math.ceil(root.altitude + 0.5)
return (altInt % 1000) * 0.36
}
}
// Case (z: 10)
Image {
width: 240
height: 240
source: "image/alt/alt_case.svg"
fillMode: Image.PreserveAspectFit
}
}
}
7、Asi.qml
import QtQuick
Item {
id: root
// Airspeed Indicator
// Original size: 240×240
property real airspeed: 0 // knots, range: 0–235
readonly property real origW: 240
readonly property real origH: 240
property real s: Math.min(width / origW, height / origH)
// Non–linear angle calculation matching C++ implementation
function handAngle(speed) {
speed = Math.max(0, Math.min(235, speed))
if (speed < 40) {
return 0.9 * speed
} else if (speed < 70) {
return 36.0 + 1.8 * (speed – 40)
} else if (speed < 130) {
return 90.0 + 2.0 * (speed – 70)
} else if (speed < 160) {
return 210.0 + 1.8 * (speed – 130)
} else {
return 264.0 + 1.2 * (speed – 160)
}
}
Item {
width: root.origW
height: root.origH
scale: root.s
anchors.centerIn: parent
// Face (z: –20)
Image {
width: 240
height: 240
source: "image/asi/asi_face.svg"
fillMode: Image.PreserveAspectFit
}
// Hand (z: –10) – non–linear rotation
Image {
id: asiHand
width: 240
height: 240
source: "image/asi/asi_hand.svg"
fillMode: Image.PreserveAspectFit
transformOrigin: Item.Center
rotation: root.handAngle(root.airspeed)
}
// Case (z: 10)
Image {
width: 240
height: 240
source: "image/asi/asi_case.svg"
fillMode: Image.PreserveAspectFit
}
}
}
8、图片资源下载
https://download.csdn.net/download/qq_15181569/93089298
四、实现原理
本项目采用 PyQt6 + QML(Qt Quick) 的混合架构,将 Python 的业务逻辑与 QML 的声明式 UI 分离,实现飞行数据的实时可视化。整体原理可拆解为「数据驱动 → 属性绑定 → 分层渲染」三个层次。
1、整体架构与数据流
#mermaid-svg-hBxTSEJbYiyCN4zu{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-hBxTSEJbYiyCN4zu .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-hBxTSEJbYiyCN4zu .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-hBxTSEJbYiyCN4zu .error-icon{fill:#552222;}#mermaid-svg-hBxTSEJbYiyCN4zu .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-hBxTSEJbYiyCN4zu .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-hBxTSEJbYiyCN4zu .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-hBxTSEJbYiyCN4zu .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-hBxTSEJbYiyCN4zu .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-hBxTSEJbYiyCN4zu .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-hBxTSEJbYiyCN4zu .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-hBxTSEJbYiyCN4zu .marker{fill:#333333;stroke:#333333;}#mermaid-svg-hBxTSEJbYiyCN4zu .marker.cross{stroke:#333333;}#mermaid-svg-hBxTSEJbYiyCN4zu svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-hBxTSEJbYiyCN4zu p{margin:0;}#mermaid-svg-hBxTSEJbYiyCN4zu .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-hBxTSEJbYiyCN4zu .cluster-label text{fill:#333;}#mermaid-svg-hBxTSEJbYiyCN4zu .cluster-label span{color:#333;}#mermaid-svg-hBxTSEJbYiyCN4zu .cluster-label span p{background-color:transparent;}#mermaid-svg-hBxTSEJbYiyCN4zu .label text,#mermaid-svg-hBxTSEJbYiyCN4zu span{fill:#333;color:#333;}#mermaid-svg-hBxTSEJbYiyCN4zu .node rect,#mermaid-svg-hBxTSEJbYiyCN4zu .node circle,#mermaid-svg-hBxTSEJbYiyCN4zu .node ellipse,#mermaid-svg-hBxTSEJbYiyCN4zu .node polygon,#mermaid-svg-hBxTSEJbYiyCN4zu .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-hBxTSEJbYiyCN4zu .rough-node .label text,#mermaid-svg-hBxTSEJbYiyCN4zu .node .label text,#mermaid-svg-hBxTSEJbYiyCN4zu .image-shape .label,#mermaid-svg-hBxTSEJbYiyCN4zu .icon-shape .label{text-anchor:middle;}#mermaid-svg-hBxTSEJbYiyCN4zu .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-hBxTSEJbYiyCN4zu .rough-node .label,#mermaid-svg-hBxTSEJbYiyCN4zu .node .label,#mermaid-svg-hBxTSEJbYiyCN4zu .image-shape .label,#mermaid-svg-hBxTSEJbYiyCN4zu .icon-shape .label{text-align:center;}#mermaid-svg-hBxTSEJbYiyCN4zu .node.clickable{cursor:pointer;}#mermaid-svg-hBxTSEJbYiyCN4zu .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-hBxTSEJbYiyCN4zu .arrowheadPath{fill:#333333;}#mermaid-svg-hBxTSEJbYiyCN4zu .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-hBxTSEJbYiyCN4zu .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-hBxTSEJbYiyCN4zu .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-hBxTSEJbYiyCN4zu .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-hBxTSEJbYiyCN4zu .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-hBxTSEJbYiyCN4zu .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-hBxTSEJbYiyCN4zu .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-hBxTSEJbYiyCN4zu .cluster text{fill:#333;}#mermaid-svg-hBxTSEJbYiyCN4zu .cluster span{color:#333;}#mermaid-svg-hBxTSEJbYiyCN4zu div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-hBxTSEJbYiyCN4zu .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-hBxTSEJbYiyCN4zu rect.text{fill:none;stroke-width:0;}#mermaid-svg-hBxTSEJbYiyCN4zu .icon-shape,#mermaid-svg-hBxTSEJbYiyCN4zu .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-hBxTSEJbYiyCN4zu .icon-shape p,#mermaid-svg-hBxTSEJbYiyCN4zu .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-hBxTSEJbYiyCN4zu .icon-shape .label rect,#mermaid-svg-hBxTSEJbYiyCN4zu .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-hBxTSEJbYiyCN4zu .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-hBxTSEJbYiyCN4zu .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-hBxTSEJbYiyCN4zu :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
加载 QML 文件
属性绑定
属性绑定
属性绑定
每 50ms 更新属性
Python 主程序main.py
QML 引擎QQmlApplicationEngine
main.qmlApplicationWindow
Adi.qml姿态仪
Alt.qml高度表
Asi.qml速度表
Timer 定时器模拟飞行数据
数据流方向:main.py 只负责创建 QML 引擎并加载界面文件,真正的数据源是 main.qml 内部的 Timer 定时器。定时器每 50ms 触发一次,用正弦函数模拟飞行参数(滚转、俯仰、高度、速度等),并写入 root 对象的属性。各仪表组件通过 QML 的属性绑定机制,自动感知这些属性的变化并刷新画面,无需手动刷新 UI。
2、数据驱动:Timer 模拟飞行数据
main.qml 中的 Timer 是数据源的核心,它模拟了真实飞控系统周期性下发数据的行为:
Timer {
id: testTimer
interval: 50 // 每 50ms 触发一次,即 20Hz 刷新率
repeat: true
running: true
onTriggered: {
if (!root.autoMode) return
const dt = interval / 1000.0
root.realTime += dt
const t = root.realTime
// 用正弦函数模拟飞行参数的连续变化
root.roll = 180.0 * Math.sin(t / 10.0)
root.pitch = 90.0 * Math.sin(t / 20.0)
root.airspeed = 125.0 * Math.sin(t / 40.0) + 125.0
root.altitude = 9000.0 * Math.sin(t / 40.0) + 9000.0
// …
}
}
设计要点:
- 刷新率:50ms 间隔对应 20Hz,足以保证仪表指针转动平滑,同时 CPU 开销极低。
- 正弦函数:用不同周期的正弦波叠加,模拟真实飞行中姿态、速度、高度的连续波动,比固定值更能体现仪表盘的动态效果。
- 可扩展性:实际项目中,只需把 onTriggered 里的赋值语句替换为从串口、UDP 或共享内存读取真实飞控数据,即可无缝切换为真实数据源。
3、属性绑定:QML 的响应式机制
QML 最核心的机制是属性绑定(Property Binding)。当 main.qml 中 root.roll 的值变化时,所有引用该属性的表达式会自动重新求值,并触发界面更新。
以姿态仪为例,main.qml 中这样传递数据:
Adi {
anchors.fill: parent
roll: root.roll // 绑定父级属性
pitch: root.pitch
}
而 Adi.qml 内部又基于 roll 派生出多个中间属性:
property real clampedRoll: Math.max(-180, Math.min(180, roll))
property real rollRad: clampedRoll * Math.PI / 180.0
property real faceDeltaX: pitchDelta * Math.sin(rollRad)
property real faceDeltaY: pitchDelta * Math.cos(rollRad)
绑定链:root.roll → Adi.roll → clampedRoll → rollRad → faceDeltaX/Y → 图像的位置与旋转。任何一环的值变化,都会像多米诺骨牌一样自动传导到最终渲染结果,这正是 QML 声明式编程的精髓——开发者只需声明数据关系,无需编写刷新逻辑。
4、分层渲染:仪表盘的绘制原理
每个仪表组件都采用多层 SVG 图片叠加的方式绘制,通过 z 值控制图层顺序,通过 rotation 和 x/y 位移实现指针与刻度的运动。
4.1 姿态仪(Adi.qml)——滚转与俯仰
姿态仪由 4 层图片叠加而成:
| 背景(天空/地面) | -30 | adi_back.svg | 随滚转旋转 -roll |
| 俯仰刻度面 | -20 | adi_face.svg | 随滚转旋转 + 随俯仰平移 |
| 滚转刻度环 | -10 | adi_ring.svg | 随滚转旋转 -roll |
| 固定表壳 | 10 | adi_case.svg | 静止不动 |
滚转(Roll):背景、刻度面、刻度环都绕中心旋转 -roll 度,模拟飞机绕纵轴滚转时天地线的倾斜。
俯仰(Pitch):刻度面除了旋转,还要沿垂直方向平移。平移量由俯仰角与滚转角共同决定:
property real pitchDelta: pixPerDeg * clampedPitch // 俯仰对应的像素位移
property real faceDeltaX: pitchDelta * Math.sin(rollRad) // 滚转后 X 方向分量
property real faceDeltaY: pitchDelta * Math.cos(rollRad) // 滚转后 Y 方向分量
当飞机同时滚转和俯仰时,刻度面需要沿滚转后的坐标系方向平移,因此用三角函数把俯仰位移分解到 X、Y 两个方向。pixPerDeg = 1.7 表示每 1° 俯仰对应 1.7 像素的位移,这是根据 240×240 的原始设计尺寸标定出来的经验值。
4.2 高度表(Alt.qml)——多指针联动
高度表用 3 个刻度面 + 2 根指针 + 1 个表壳共 6 层图片,模拟真实高度表的读数方式:
| 气压鼓轮 | -50 | alt_face_1.svg | -(pressure – 28.0) * 100 |
| 静态刻度 | -40 | alt_face_2.svg | 静止 |
| 万英尺刻度 | -30 | alt_face_3.svg | altitude * 0.0036 |
| 千英尺长针 | -20 | alt_hand_1.svg | altitude * 0.036 |
| 百英尺短针 | -10 | alt_hand_2.svg | (altitude % 1000) * 0.36 |
| 固定表壳 | 10 | alt_case.svg | 静止 |
指针传动比:长针每 1000 英尺转一圈(0.036 × 1000 = 36° 不对,实际是 0.036 × 10000 = 360°,即长针每 10000 英尺转一圈);短针每 1000 英尺转一圈(0.36 × 1000 = 360°)。这种多指针不同传动比的设计,让飞行员能快速读出高度的大致量级和精确值。
气压修正:alt_face_1.svg 根据气压值旋转,模拟飞行员转动气压设定旋钮时,高度表内部鼓轮随之转动的机械结构。
4.3 速度表(Asi.qml)——非线性刻度映射
空速表的指针角度与速度值不是线性关系,因为真实空速表的刻度盘在低速区刻度稀疏、高速区刻度密集。handAngle() 函数用分段线性函数逼近这条非线性曲线:
function handAngle(speed) {
speed = Math.max(0, Math.min(235, speed))
if (speed < 40) {
return 0.9 * speed // 0-40kt:每节 0.9°
} else if (speed < 70) {
return 36.0 + 1.8 * (speed – 40) // 40-70kt:每节 1.8°
} else if (speed < 130) {
return 90.0 + 2.0 * (speed – 70) // 70-130kt:每节 2.0°
} else if (speed < 160) {
return 210.0 + 1.8 * (speed – 130) // 130-160kt:每节 1.8°
} else {
return 264.0 + 1.2 * (speed – 160) // 160-235kt:每节 1.2°
}
}
分段依据:不同速度段的刻度密度不同,低速段(0-40kt)每节只转 0.9°,高速段(70-130kt)每节转 2.0°,这样指针在常用巡航速度区间摆动更明显,读数更精细。这个分段函数是从真实空速表的刻度盘上采样标定得到的。
5、自适应缩放
所有仪表组件都内置了等比缩放逻辑,保证在不同窗口尺寸下不变形:
readonly property real origW: 240
readonly property real origH: 240
property real s: Math.min(width / origW, height / origH)
Item {
width: root.origW
height: root.origH
scale: root.s // 整体缩放
anchors.centerIn: parent
}
仪表内部所有图层都按 240×240 的原始设计尺寸布局,外层 Item 通过 scale 属性统一缩放,Math.min 保证缩放后不超出容器边界且保持宽高比。这样无论窗口是 800×600 还是 1920×1080,仪表都能自适应显示。
6、小结
整个项目的实现原理可以概括为一条响应式数据链:
Timer 模拟数据 → 属性绑定自动传导 → 各仪表分层渲染 → 实时刷新画面
PyQt6 负责提供 QML 运行环境,QML 负责声明 UI 结构与数据关系,SVG 图片负责精细的仪表外观,三者各司其职、层层解耦。这种架构让「换数据源」和「换皮肤」都变得非常容易——前者只需改 Timer 的赋值逻辑,后者只需替换 SVG 图片。







