从零造一个腱绳驱动灵巧手——仿真器篇4:Qt 界面实战——属性面板、树控件与姿势系统

〇、写在前面:UI 是最容易被低估的一层

前三篇我们干了一件挺有成就感的事:用 Eigen 把 59 个零件的位姿矩阵算出来,用 VTK 把它们画成屏幕上一只可以点、可以拖、可以插轴的手。

然后我打开自己写的界面,盯着看了三秒钟,陷入了沉思。

🙃 "所以我现在......要怎么把这根食指弯到 -63.5°?"

这就是仿真软件和"能跑的 demo"之间的那道坎。核心算法再漂亮,如果没法把参数喂进去、没法看见反馈、没法把结果存下来,它就只是一个命令行玩具。

我见过太多学长的毕设项目,代码写得很硬核,界面是一排 QPushButton 加一个 QTextEdit,答辩时老师问"这个轴方向怎么改",他当场开始改 main.cpp 里的常量重新编译。

我不太想成为那样的人。所以这一篇专门写 Qt 层:怎么把一个纯 C++ 的运动学内核,包成一个正常的桌面软件。

这一篇会比其他几篇长,因为界面层的坑有个特点:单个看都很蠢,但是堆在一起能让你调试一整天。 我按"先做取舍 → 再搭骨架 → 再逐个面板 → 最后算总账"的顺序讲,代码都是项目里真实在跑的。本篇使用文章助手的AI进行润色,可能AI味有点大,见谅哈哈哈哈

先放一句贯穿全文的架构纪律,后面会反复回来验证它:

bash 复制代码
ui → render → core
ui → model  → core

ui/ 可以知道所有人,但 core/ 谁都不认识。如果一个 Qt 类型出现在 core/ 里,那就是架构事故。


一、动手前的第一个取舍:.ui 表单,还是纯代码布局?

这件事我一开始压根没当回事,觉得"不就是几个 QVBoxLayout 嘛",顺手就在构造函数里码了六十行 addWidget。

直到我意识到:我写的不是给别人的软件,是给我自己写的软件。

区别在哪?我在 Qt Designer 里拖一下就能调的间距,用代码写得改一行、编译、重启、看效果、再改一行。而毕设这种东西,界面是要反复调整的------导师看一眼说"这个面板太挤了",你就得改。如果每次都要重编译,一个下午就交代在 ninja 上了。

更关键的是:这个软件以后是我自己维护,而我不一定记得住哪行 addWidget 对应屏幕上哪一块。

所以这里有个必须摆到台面上讲清楚的取舍:

| | 纯代码写布局 | .ui 表单(Qt Designer) | | --- | --- | --- | | 改间距/文案/顺序 | 改 C++ → 重编译 | 拖拽 → 保存 → 重编译(但不动 C++ 逻辑) | | 可视化调整 | 无 | 有,所见即所得 | | 动态生成的控件 | 天然支持 | 需要预留容器 | | 版本冲突 | 容易(全在一个 .cpp) | 较少(XML 结构化) | | 学习和调试成本 | 低 | 略高,多一层 uic |

这个取舍不能直接拍板,因为它直接决定使用者以后能不能自己改界面。我最后定的是「混合式」

静态布局骨架放 .ui 表单(Qt Designer 可拖拽),动态内容由代码填进 .ui 预留的容器。

具体到项目里是 4 个 .ui 文件:

bash 复制代码
ui/mainwindow.ui             主窗口壳:菜单栏、工具栏、三个 QDockWidget、状态栏
ui/property_panel.ui         属性面板:分组框、标签、数值输入框、限位滑块
ui/joint_control_panel.ui    关节角 / 姿势流程面板:当前姿势串、播放条、流程滚动区
ui/sim_control_panel.ui      仿真控制面板(后来整体从界面摘掉,见第十一节)

而动态面板 (同步模式的摄像头+Ollama 对话、模拟模式的视觉识别骨架叠加)是纯代码写的,因为它们的控件数量、可见性都随运行时状态变化,塞进 .ui 反而要写一堆 setVisible,不如直接 new。

cpp 复制代码
.ui 管布局,代码管数据填充。
改布局 / 文案 / 间距 → 不用改代码;
改 objectName        → 一定会断代码。

二、uic 的命名铁律:一个下划线就能让你编译半小时

Qt 的 uic 会把 .ui 文件编译成 ui_xxx.h,里面生成一个 Ui::Xxx 类,表单里每个控件的 name 属性,一字不差地变成这个类的成员变量。

cpp 复制代码
<widget class="QDoubleSpinBox" name="range_lo_">
  <property name="decimals"><number>1</number></property>
</widget>
// 代码里必须写成 ui_->range_lo_,多一个少一个下划线都编译不过
ui_->range_lo_->setValue(-270.0);

我踩过一次:表单里叫 limit_lo_slider,代码里顺手写成 ui_->limit_lo_slider_(多了个尾下划线),编译器甩出来一句 no member named 'limit_lo_slider_' in 'Ui::PropertyPanel'。因为 ui_ 是前置声明的不完整类型,报错信息又长又绕,我盯着看了十分钟才反应过来是名字对不上。

💡 经验 :.ui 里的 objectName 一旦定下来就当 API 看待。要改名,全局搜一遍代码;要调布局,随便拖。这两件事的成本差了一个数量级,也是"用 .ui 到底图什么"的答案所在。

所有面板的构造函数都长同一个样,毫无惊喜:

cpp 复制代码
PropertyPanel::PropertyPanel(QWidget* parent)
    : QWidget(parent)
    , ui_(new Ui::PropertyPanel)
{
    ui_->setupUi(this);                        // 把 .ui 的骨架铺开

    // 动态部分:AxisSelector 塞进 .ui 预留的容器
    axis_ = new AxisSelector(this);
    ui_->axis_container_lay->addWidget(axis_);

    connect(ui_->apply_btn_, &QPushButton::clicked,
            this, &PropertyPanel::applyToNode);
    // ...一堆 connect
    applyLimitRange();
}

ui_->axis_container_lay 就是那个"预留容器"------表单里放一个空的 QVBoxLayout,代码往里塞一个自写的轴选择控件。骨架归表单,内容归代码,两边不越界。

顺带说一句 private slots::Qt 里 slots 是个宏(展开成 Q_SLOTS),所以别把任何变量、函数、枚举成员命名成 slots。我干过一次,报错报在完全不相干的一行,堪称 Qt 版"最遥远的报错"。


三、主窗口骨架:一个中央视口 + 三个 Dock

QMainWindow 的布局哲学很省事:中间一个 centralWidget,四周随便挂 QDockWidget,Qt 自己管拖拽、停靠、尺寸。

mainwindow.ui 里只声明壳:

cpp 复制代码
<widget class="QDockWidget" name="dockTree">
  <property name="windowTitle"><string>模型树</string></property>
  <attribute name="dockWidgetArea"><number>1</number></attribute>  
  <widget class="QWidget" name="tree_placeholder">
    <property name="minimumSize"><size><width>180</width><height>120</height></size></property>
  </widget>
</widget>
<widget class="QDockWidget" name="dockProperty"> ... </widget>   
<widget class="QDockWidget" name="dockJoint">    ... </widget>   

代码里把占位 widget 换成真面板:

cpp 复制代码
void MainWindow::buildCentral() {
    viewport_ = new VtkViewport(this);
    setCentralWidget(viewport_);   // 替换掉 .ui 里的 central_placeholder

    connect(viewport_, &VtkViewport::nodePicked,   this, &MainWindow::onNodePicked);
    connect(viewport_, &VtkViewport::nodeDragged,  this, &MainWindow::onNodeDragged);
    connect(viewport_, &VtkViewport::jointQDragged,this, &MainWindow::onJointQDragged);
    connect(viewport_, &VtkViewport::dragStarted,  this, &MainWindow::pushUndo);
    // ... 插轴相关的一串 connect
}

void MainWindow::buildDocks() {
    tree_model_  = new LinkTreeModel(&tree_, this);
    tree_widget_ = new ModelTreeWidget(this);
    tree_widget_->setTreeModel(tree_model_);
    ui_->dockTree->setWidget(tree_widget_);

    property_ = new PropertyPanel(this);
    property_->setTree(&tree_);
    ui_->dockProperty->setWidget(property_);

    joint_ctrl_ = new JointControlPanel(this);
    joint_ctrl_->setTree(&tree_);
    ui_->dockJoint->setWidget(joint_ctrl_);
}

注意 PropertyPanel 和 JointControlPanel 拿到的都是 &tree_ ------ 同一个 hand_core::Tree 实例的裸指针。没有副本、没有代理、没有同步逻辑。这是整个 UI 层最重要的一条设计,下一节展开。

布局塌缩:

软件启动后偶尔会出现面板全挤成一条、3D 视图只剩指甲盖大小的状态。查了半天发现两件事:

  1. QDockWidget 默认允许漂浮 (AllowFloating),一拖,dock 变成独立小窗口,主窗口剩下的空间被 Qt 重新分配,布局就崩了;

  2. 窗口被拉小时,各 dock 的 minimumSize 之和大于窗口,Qt 只能压缩中央视口,压到 0。

解法粗暴但有效:

cpp 复制代码
// 防布局塌缩:约束窗口最小尺寸 + 禁止 dock 漂浮
setDockOptions(AnimatedDocks | AllowNestedDocks);
setMinimumSize(1024, 600);
QMainWindow 的默认 dockOptions 是 `AnimatedDocks
AllowTabbedDocks。显式设成 AnimatedDocks
AllowNestedDocks 之后,AllowFloating` 不在里面了------dock 不能飘出去,只能在主窗口内部嵌套停靠;再配一个窗口最小尺寸,中央视口永远有地方待。

顺便,默认窗口尺寸也不是在构造函数里直接 resize 的:

cpp 复制代码
// main.cpp:等事件循环转起来、dock 布局稳定后再定尺寸,否则会被 Qt 的布局计算覆盖
QTimer::singleShot(0, &w, [&w] { w.resize(1280, 720); });

1280×720 是 720P,答辩投屏刚好一屏不裁。singleShot(0, ...) 这个"排到下一轮事件循环再干"的小技巧,在 Qt 里专治各种"构造期设置被布局系统吃掉"的玄学。


四、模式切换:切的是 UI 状态,不是数据模型

项目有三个模式:设计模式 (装配、插轴、调静态参数)、模拟模式 (姿势串播放 / 视觉识别驱动)、同步模式(摄像头 + Ollama 对话)。

最容易写出这种代码:

cpp 复制代码
// 反面教材
void onModeChanged(Mode m) {
    if (m == Design) { designTree_ = buildFromJson(...); }
    else             { simTree_    = deepCopy(designTree_); }  // 两套数据
}

然后灾难就开始了:设计模式下改了轴方向,切到模拟模式发现没变;模拟模式里拖了关节角,切回去发现静态参数被覆盖;保存时到底存哪棵树?两套数据结构就是两份真相,而软件只能有一份。

我的做法:

LinkTreeModel 全生命周期只有一个实例,持有 hand_core::Tree 的引用。模式切换只改各面板的可编辑性、可见性,和 dock 的摆放方式。数据一动不动。

cpp 复制代码
void MainWindow::setMode(UiMode m) {
    mode_ = m;
    const bool design = (m == UiMode::Design);
    const bool sync   = (m == UiMode::Sync);
    const bool sim    = (m == UiMode::Simulation);
    if (sim) wrist_calibrated_ = false;   // 重新标定手腕零位

    // ① 可编辑性:设计模式才让改静态参数
    tree_widget_->setEditMode(design);
    property_->setEditable(design);
    ui_->dockTree->setVisible(design);
    joint_ctrl_->setEditable(true);
    joint_ctrl_->setPlaybackEnabled(!design && !sync);

    // ② 面板内容:同一个 dockProperty,按模式换 widget
    if (sync) {
        ui_->dockProperty->setWidget(sync_panel_);
        ui_->dockProperty->setWindowTitle(QStringLiteral("同步控制"));
    } else if (sim) {
        // 模拟模式:右侧上下堆叠 ------ 视觉识别(上)+ 关节角编辑(下,3:2)
        ui_->dockProperty->setWidget(vision_panel_);
        ui_->dockProperty->setWindowTitle(QStringLiteral("视觉识别"));
        ui_->dockJoint->setWindowTitle(QStringLiteral("关节角编辑"));
        splitDockWidget(ui_->dockProperty, ui_->dockJoint, Qt::Vertical);
        const int h = std::max(600, height());
        resizeDocks({ui_->dockProperty, ui_->dockJoint},
                    {h * 3 / 5, h * 2 / 5}, Qt::Vertical);
    } else {
        sync_panel_->stopCamera();
        if (vision_panel_->isRunning()) vision_panel_->stopDetection();
        ui_->dockProperty->setWidget(property_);
        ui_->dockProperty->setWindowTitle(QStringLiteral("属性面板"));
        addDockWidget(Qt::RightDockWidgetArea, ui_->dockJoint);
        ui_->dockJoint->setWindowTitle(QStringLiteral("关节角"));
    }
    ui_->dockJoint->setVisible(!sync);   // 同步模式不显示关节角面板

    // ③ 摆位交互只在设计模式开放
    ui_->act_manip_translate->setEnabled(design);
    ui_->act_manip_rotate->setEnabled(design);
    ui_->act_insert_axis->setEnabled(design);
    ui_->act_gizmo_global->setEnabled(design);
    ui_->act_gizmo_local->setEnabled(design);
}

这段代码的三个层次拆开看:

① 可编辑性,不是另一套 UI。 property_->setEditable(false) 只是把一堆 QWidget::setEnabled(false) 集中起来:

cpp 复制代码
void PropertyPanel::setEditable(bool on) {
    editable_ = on;
    ui_->name_edit_->setEnabled(on);
    ui_->parent_combo_->setEnabled(on);
    ui_->q_spin_->setEnabled(on);
    ui_->tx_->setEnabled(on); ui_->ty_->setEnabled(on); ui_->tz_->setEnabled(on);
    ui_->rx_->setEnabled(on); ui_->ry_->setEnabled(on); ui_->rz_->setEnabled(on);
    ui_->scale_spin_->setEnabled(on);
    axis_->setEditable(on);
    ui_->limit_on_->setEnabled(on);
    // ...
    // 子节点下拉是导航用途(点选跳转),设计/仿真模式都可点
    ui_->children_combo_->setEnabled(true);
}

最后那行是个细节:导航类控件不该跟着编辑权限一起灰掉。 模拟模式下你照样想点开下拉看这个节点有哪些子件,只是不能改。把"能不能看"和"能不能改"混在一起禁用,是很常见的体验灾难。

② 同一个 Dock,换 Widget 而不是开新窗口。 QDockWidget::setWidget() 会把旧 widget 摘下来(但不删除,指针还在 property_ / vision_panel_ / sync_panel_ 里),换上新的。所以三个面板对象全程只构造一次,切换是 O(1) 的指针搬运。

这里有个特别阴的坑:

cpp 复制代码
sync_panel_ = new SyncModePanel(this);
sync_panel_->hide();   // 不在布局里,必须手动隐藏

sync_panel_ 是 new SyncModePanel(this) ------ 父对象是主窗口,但它没被放进任何布局 。Qt 的规则是:父 widget show() 的时候,所有未显式隐藏的子 widget 会一起显示,位置默认 (0, 0)。结果就是启动瞬间,同步模式的文字浮在 3D 视图左上角,和视图的模式提示叠成一团糊字。

我一开始以为是谁调了 show(),翻了三遍代码。其实没人调,是父窗口帮忙调的。 加一行 hide() 就好了。同理 vision_panel_ 创建后也 hide()。

③ 布局随模式变,但布局不是数据。 模拟模式要求右侧上下堆叠:上面视觉识别(摄像头小窗 + 骨架),下面关节角编辑,大致 3:2。用 splitDockWidget + resizeDocks 一次成型,不用自己拖:

cpp 复制代码
splitDockWidget(ui_->dockProperty, ui_->dockJoint, Qt::Vertical);
resizeDocks({ui_->dockProperty, ui_->dockJoint},
            {h * 3 / 5, h * 2 / 5}, Qt::Vertical);

切回设计模式时 addDockWidget(Qt::RightDockWidgetArea, ui_->dockJoint) 把它放回右侧------注意这里我没去"撤销 split",而是重新 addDockWidget,让 Qt 自己决定怎么摆。实践下来比手动 removeDockWidget + addDockWidget 组合稳定,不会留下空的分隔条。

其实还有一条策略:仿真运行中禁止改静态参数。 现在 setMode 里用 design 布尔量控制,语义等价于"只要不在设计模式,静态参数一律只读"。理由很实在------积分器正在按当前树结构递推,你中途把某个 axis 改了,下一帧的雅可比就对不上了,出来的数谁也解释不清。


五、LinkTreeModel:把 core::Tree 翻译成 Qt 能懂的树

QTreeView 不直接吃 C++ 容器,它要一个 QAbstractItemModel。这一层是整个 UI 最容易写错的地方,因为它要求你实现一套和数据结构完全不同的索引体系。

先回忆一下 core::Tree 长什么样(第二篇讲过):一个 std::vector<TreeNode>,节点靠 id(= 数组下标)和 parent(= 父节点 id)连成树,删除用 parent = -2 打墓碑。

Qt 那边的模型用的是 QModelIndex:(row, column, internalId/pointer) 三元组。两套体系怎么对上?

我的选择是:把节点 id 直接塞进 QModelIndex::internalId()。

cpp 复制代码
QModelIndex LinkTreeModel::index(int row, int column, const QModelIndex& parent) const {
    if (!tree_ || column != 0 || row < 0) return {};
    if (!parent.isValid()) {
        auto roots = rootIds();
        if (row >= static_cast<int>(roots.size())) return {};
        return createIndex(row, column, static_cast<quintptr>(roots[row]));
    }
    int pid = static_cast<int>(parent.internalId());
    auto kids = tree_->children(pid);
    if (row >= static_cast<int>(kids.size())) return {};
    return createIndex(row, column, static_cast<quintptr>(kids[row]));
}

createIndex(row, column, quintptr) 第三个参数可以放一个整数------正好放节点 id。于是:

  • index():给我 (父, 第几行) → 我查出那个子节点的 id,塞进 internalId

  • parent():从 internalId 拿出 id → 查 tree_->node(id)->parent → 再算出父节点在它的兄弟里排第几

  • rowCount():根节点数量 / 某节点的子节点数量

cpp 复制代码
QModelIndex LinkTreeModel::parent(const QModelIndex& child) const {
    if (!tree_ || !child.isValid()) return {};
    int id = static_cast<int>(child.internalId());
    const auto* n = tree_->node(id);
    if (!n || n->parent < 0) return {};

    int pid = n->parent;
    const auto* pn = tree_->node(pid);
    if (!pn) return {};

    std::vector<int> sibs = (pn->parent < 0) ? rootIds() : tree_->children(pn->parent);
    auto it = std::find(sibs.begin(), sibs.end(), pid);
    if (it == sibs.end()) return {};
    return createIndex(static_cast<int>(it - sibs.begin()), 0, static_cast<quintptr>(pid));
}

parent() 里那句 sibs 计算是新手最容易漏的:QModelIndex 里的 row 是"在父节点的子列表里的排行",不是全局序号。 所以必须把父节点的所有兄弟捞出来,std::find 一下拿到下标。漏了这步,树能显示,但展开/折叠/选中会错位,而且错得很随机。

data() 决定树上每行显示什么:

cpp 复制代码
QVariant LinkTreeModel::data(const QModelIndex& index, int role) const {
    // ...
    if (role == Qt::EditRole) {
        return QString::fromStdString(n->joint.name);
    }
    if (role == Qt::DisplayRole) {
        const QString type = (n->joint.type == hand_core::JointType::Fixed)
                                 ? QStringLiteral("fixed") : QStringLiteral("revolute");
        const QString nm = !n->joint.name.empty()
                         ? QString::fromStdString(n->joint.name)
                         : QString::fromStdString(n->link.name);
        return QStringLiteral("%1  [%2]").arg(nm, type);
    }
    if (role == Qt::UserRole) return id;
    return {};
}

这里有个改动:最早我在树里显示 节点 29,在下拉框里显示 29。看了一眼说"我靠,我怎么知道 id:29 是哪个零件"。

对啊,id 是给程序看的,名字才是给人看的。 后来全线改成显示 joint.name,并统一了一个 displayName() 工具函数,属性面板、父节点下拉、子节点下拉、mimic 下拉全部走它。方括号里保留 [fixed] / [revolute] 是因为这个信息在插轴时很关键------一眼能看出哪些件还没变成活动关节。

💡 顺带一个真实教训 :重命名要同时写 joint.name 和 link.name(历史数据里两个字段可能不一致),displayName() 里做兜底 joint.name 优先,空则 link.name。不要指望数据永远干净,UI 层多做一层容错成本极低。


六、ModelTreeWidget:右键菜单与选中回环

ModelTreeWidget 是 QTreeView 的一层薄封装,主要干两件事:把选中事件转发成节点 id 列表,以及提供右键菜单。

cpp 复制代码
ModelTreeWidget::ModelTreeWidget(QWidget* parent) : QTreeView(parent) {
    setUniformRowHeights(true);
    setSelectionMode(QAbstractItemView::ExtendedSelection);   // Ctrl 多选
    setContextMenuPolicy(Qt::CustomContextMenu);
    connect(this, &QWidget::customContextMenuRequested,
            this, &ModelTreeWidget::onContextMenu);
}

右键菜单是"树控件为什么值得单独封装"的理由:

cpp 复制代码
QMenu menu(this);
QAction* renameAct = menu.addAction(QStringLiteral("重命名(F2)"));
QAction* addAct    = menu.addAction(QStringLiteral("新增 Revolute 子节点"));
QAction* bindAct   = menu.addAction(QStringLiteral("设为父对象(选中件挂到此节点下)"));
menu.addSeparator();
QAction* copyAct   = menu.addAction(QStringLiteral("复制(含子树)"));
QAction* pasteAct  = menu.addAction(QStringLiteral("粘贴到此节点下"));
menu.addSeparator();
QAction* importAct = menu.addAction(QStringLiteral("导入 STL 模型..."));
QAction* rmModelAct= menu.addAction(QStringLiteral("删除模型(保留节点)"));
menu.addSeparator();
QAction* delAct    = menu.addAction(QStringLiteral("删除该节点"));

九个动作,每个都对应一次装配操作。这里有两个设计点:

① 权限集中在 edit_mode_ 上。 所有动作统一 setEnabled(edit_mode_ && nodeId >= 0)。模拟模式下树本身是隐藏的,但就算通过快捷键触发也不会误改数据------权限判断不依赖"控件看不见就不可能点到"这种侥幸。

② "删除模型(保留节点)"和"删除该节点"是两个动作。 前者只清掉 STL 几何,节点、父子关系、轴、限位全留着------做装配时经常要换零件模型,但不想重调轴;后者才是真删节点。这个区分是被现实逼出来的:我第一次只做了"删除节点",结果换一个指尖 STL,整条关节链得重插一遍。

菜单动作不直接改树,一律 emit xxxRequested(nodeId) 交给 MainWindow 处理。树控件不持有 Tree* 的写权限,它只负责把意图翻译出来。这条边界让撤销、多选、模式门控都集中在主窗口一处。

选中回环:一个经典的信号死循环

3D 视口点选零件 → 树要同步高亮;树里点一行 → 3D 视口要同步高亮。两边互相 connect,很容易变成:

cpp 复制代码
树 selectionChanged → emit selectionEdited → 主窗口 setSelectionIds(树)
   → selectionChanged → emit selectionEdited → ...  ∞

我的解法是加一个重入标志:

cpp 复制代码
void ModelTreeWidget::setSelectionIds(const QList<int>& ids) {
    if (!tree_model_ || !selectionModel()) return;
    syncing_ = true;
    QItemSelection sel;
    for (int id : ids) {
        const QModelIndex idx = tree_model_->indexForNode(id);
        if (idx.isValid()) sel.select(idx, idx);
    }
    selectionModel()->select(sel, QItemSelectionModel::ClearAndSelect);
    // ...
    syncing_ = false;
}

void ModelTreeWidget::onSelectionChanged() {
    if (!tree_model_ || syncing_) return;   // 程序设置的选择不再往外广播
    // ...
    emit selectionEdited(ids);
}

syncing_ 只屏蔽"程序主动设置"引发的那一轮广播,手动点选照常发出。同样的手法在下一节的属性面板里还会出现一次,只是换成了 Qt 内置的 QSignalBlocker。

💡 命名去重也在这层 :最早新增节点用 tree_.size()+1 起名,因为删除是打墓碑、size() 不减,编号就从 53 跳到 54、55......这就是为什么编号不连续。改成 tree_.aliveNodes().size() + 1 并做重名去重后才顺眼。看到的是名字,不是内部 id,不把内部实现细节漏到界面上。


七、PropertyPanel:静态参数编辑,以及双向绑定的战争

属性面板是全项目控件最密的地方:节点名、父节点、fixed transform 六个分量、缩放、轴向量、关节类型、限位开关、量程上下限、限位上下限(数值框 + 滑块各一对)、关节角 q、子节点下拉、mimic 跟随关节下拉。

布局上我把它压成 3 列紧凑网格 (XYZ 三分量一行放完),否则 529 行的 .ui 还得再长一倍。

7.1 关节角实时联动

改 q 要立刻看到手跟着动,不能等点"应用":

cpp 复制代码
connect(ui_->q_spin_, QOverload<double>::of(&QDoubleSpinBox::valueChanged),
        this, &PropertyPanel::onQLinkChanged);

QOverload<double>::of(...) 这一串是 Qt 5.7+ 的标准写法------QDoubleSpinBox::valueChanged 有 double 和 QString 两个重载,不说明取哪个编译器会报"无法推导"。这个报错我第一次见的时候,还以为是信号槽连接语法写错了。

而从 3D 拖拽反向刷新 q 时,必须堵住信号:

cpp 复制代码
void PropertyPanel::setQSpinValue(double qRad) {
    QSignalBlocker blk(ui_->q_spin_);        // 阻塞期间 setValue 不触发 valueChanged
    ui_->q_spin_->setValue(qRad * kRadToDeg);
}

否则就是:拖拽 → 面板 setValue → 触发 onQLinkChanged → 写回树 → 又触发刷新 → ...... 轻则数值抖动,重则拖拽手感像在揉一团橡皮。QSignalBlocker 是 RAII 的,出作用域自动恢复,比手写 blockSignals(true/false) 安全得多(后者一旦中间 return 就永久哑火)。

7.2 限位:四个控件绑同一个量

限位的 UI 是"数值框 + 滑块"双向同步,一共涉及 6 个控件:

| 控件 | 作用 | | --- | --- | | limit_on_ | 是否启用限位 | | range_lo_ / range_hi_ | 滑块的量程 (默认 −270° ~ +270°,0 为中位) | | limit_lo_ / limit_hi_ | 限位本身的上下限数值框 | | limit_lo_slider_ / limit_hi_slider_ | 对应的两个滑块 |

滑块只能吃整数,所以用 0.1° 一个刻度:

cpp 复制代码
int degToSlider(double deg) {
    return static_cast<int>(std::lround(deg * 10.0));
}

±270° → ±2700,int 完全装得下。量程做成可设的(而不是硬编码 ±180°),是因为真实 3D 打印件里确实有能转过 180° 的关节,写死会让没法表达。

这里有个 Qt 的沉默陷阱:

QSlider::setRange(min, max) 如果 min > max,Qt 不报错、不警告,而是静默地把两者交换。

如果把量程下限填成 270、上限填成 −270,界面会"看起来正常"但滑块方向整个反过来,且数值框和滑块从此对不上。所以我在重设刻度前先做纠正并回写:

cpp 复制代码
void PropertyPanel::applyLimitRange() {
    blockLimitSignals(true);
    double lo = ui_->range_lo_->value();
    double hi = ui_->range_hi_->value();
    if (lo > hi) {   // 量程输入反了:自动交换并回写显示
        const double t = lo; lo = hi; hi = t;
        ui_->range_lo_->setValue(lo);
        ui_->range_hi_->setValue(hi);
    } else if (hi - lo < 1.0) {
        hi = lo + 1.0;
        ui_->range_hi_->setValue(hi);
    }
    ui_->limit_lo_slider_->setRange(degToSlider(lo), degToSlider(hi));
    ui_->limit_hi_slider_->setRange(degToSlider(lo), degToSlider(hi));
    // 当前限位值钳进量程、保证下限 ≤ 上限,再回填滑块位置
    double vlo = std::min(std::max(ui_->limit_lo_->value(), lo), hi);
    double vhi = std::min(std::max(ui_->limit_hi_->value(), lo), hi);
    if (vlo > vhi) { const double t = vlo; vlo = vhi; vhi = t; }
    ui_->limit_lo_->setValue(vlo);
    // ...
    blockLimitSignals(false);
}

blockLimitSignals(true) 把 6 个控件的信号一次性全堵住,避免" setValue → valueChanged → applyLimitRange → setValue " 的递归。凡是 N 个控件互相同步,第一件事就是找一个"总闸"把它们全堵上,改完再统一放开。

单个滑块的越界还要做回弹:

cpp 复制代码
void PropertyPanel::onLimitLoSlider(int v) {
    double deg = v / 10.0;
    const double hi = ui_->limit_hi_->value();
    if (deg > hi) {                       // 下限越过上限:停在上限,滑块回弹
        deg = hi;
        QSignalBlocker b(ui_->limit_lo_slider_);
        ui_->limit_lo_slider_->setValue(degToSlider(deg));
    }
    QSignalBlocker blk(ui_->limit_lo_);
    ui_->limit_lo_->setValue(deg);
    // ...
}

注意这里用了两个不同的 QSignalBlocker:一个堵住滑块自己(防止回弹又触发一轮),一个堵住数值框(防止反向同步)。作用域精确到具体控件,别图省事堵整个对象。

7.3 下拉框:activated 而不是 currentIndexChanged

子节点下拉和 mimic 下拉都用了 activated:

cpp 复制代码
// 子节点下拉点选 -> 跳转(activated 无论是否同项都触发)
connect(ui_->children_combo_, QOverload<int>::of(&QComboBox::activated),
        this, &PropertyPanel::onChildrenActivated);

为什么?因为"点子节点跳过去"是个导航动作 。可能连续两次点同一个子项(比如切回来看看),currentIndexChanged 在值没变时根本不触发,界面就像卡住了。activated 只要点了就发,符合"我点了我就要有反应"的直觉。

而 mimic 下拉是"点选即生效",也用 activated,语义一致。

7.4 名字统一:displayName()

前面提过,面板里所有出现节点名的地方都走同一个函数:

cpp 复制代码
QString displayName(const hand_core::TreeNode& n) {
    const std::string& nm = !n.joint.name.empty() ? n.joint.name : n.link.name;
    return nm.empty() ? QStringLiteral("节点 %1").arg(n.id)
                      : QString::fromStdString(nm);
}

一个只有 4 行的函数,但省掉了"为什么树里叫 T_MCP、下拉里叫 29"这类问题。UI 层的一致性不是靠纪律维持的,是靠把不一致的入口收窄到一个函数。


八、回到界面:限位只在 UI 层当钳子,绝不进 FK

第二篇立过一条规矩,这里必须再讲一遍,因为界面层是最容易违反它的地方。

限位(limit)是数据,不是钳子。FK 不截断角度。

linkPose() 只忠实计算 T_parent · origin · Rot(axis, q),不管 q 有没有超限。理由当时说过:如果 FK 偷偷 clamp,那么积分器、轨迹插值、逆运动学求解器全都会被坑------它们以为自己设进了 -200°,实际渲染的是 -180°,误差在数据层就消失了,追都追不回来。

那输超了怎么办?在 UI 输入层拦:

cpp 复制代码
void PropertyPanel::applyQLimitRange() {
    // 关节角输入框量程跟随限位:启用限位时 q 不能输到限位外
    QSignalBlocker blk(ui_->q_spin_);
    if (ui_->limit_on_->isChecked()) {
        const double lo = std::min(ui_->limit_lo_->value(), ui_->limit_hi_->value());
        const double hi = std::max(ui_->limit_lo_->value(), ui_->limit_hi_->value());
        ui_->q_spin_->setRange(lo, hi);
        ui_->q_spin_->setValue(std::max(lo, std::min(hi, ui_->q_spin_->value())));
    } else {
        ui_->q_spin_->setRange(-360.0, 360.0);
    }
}

QDoubleSpinBox::setRange() 天然不让输超,再 setValue 把已有的越界值拉回来。钳子握在 UI 手里,core 始终干净。

这条边界带来的实际好处:视觉识别驱动手的时候,Python 那边算出来的角度可能略超限位,我在 MainWindow::onVisionAngles() 里单独做一次 clampJointQ() 再写进树------而不是指望 FK 帮忙兜底。每一层都明确知道自己在干嘛,出了问题一眼能定位到该改哪一层。

用一张表收一下这一节:

| 层 | 对限位的态度 | | --- | --- | | core/kinematics.cpp | 完全不知道限位存在,照算 | | model/json_io.cpp | 把限位当普通字段存/读 | | ui/property_panel.cpp | 提供编辑控件、双向同步、输入钳制 | | ui/main_window.cpp | 外部输入(视觉/姿势串)写树前钳一次 |


九、关节角面板与姿势串的"只读窗口"

JointControlPanel 现在的核心其实很朴素------最上面一行只读文本框,实时显示当前姿势串:

cpp 复制代码
void JointControlPanel::updateCurrentPose() {
    if (!tree_) return;
    ui_->cur_pose_->setText(QString::fromStdString(buildPoseString(*tree_)));
}

每次 fkRefresh() 后调一次 joint_ctrl_->refresh(),这行文本就跟着刷新。在 3D 里拖一根手指,右上角立刻吐出一串可以直接复制的文本:

cpp 复制代码
{T_MCP0,T_PIP-49.9,T_TIP-30.7;I_KNUCKLE0,I_MCP0,I_PIP0,I_DIP0;M_MCP0,...}

这个"可读可复制"的设计比想象中有用得多。 做手势数据时,我不再需要一个个读滑条数值,摆好手 → 复制 → 粘进流程卡片,一步到位。而且它天然是一份"当前状态快照",调试时能直接贴到问题报告里。

协议本身在 ui/pose_string.hpp,三个函数撑起全部:

cpp 复制代码
// 18 个手指关节槽位:代号(=树节点名) -> 树节点名前缀(顺序即输出顺序)
inline const std::vector<std::pair<const char*, const char*>>& poseSlots() {
    static const std::vector<std::pair<const char*, const char*>> slotTab = {
        {"T_MCP", "T_MCP"}, {"T_PIP", "T_PIP"},   {"T_TIP", "T_TIP"},
        {"I_KNUCKLE", "I_KNUCKLE"}, {"I_MCP", "I_MCP"}, ...
    };
    return slotTab;
}
  • buildPoseString(tree):按槽位顺序,从树里读 q(弧度→度),拼成 {...}

  • parsePoseString(tree, text):逐字符扫描,识别"字母/下划线组成的代号 + 数字",映射回节点 id 和弧度

  • extractPoseBlocks(text):把一段文本里所有 {...} 抠出来,每个块 = 一个姿势

有个细节我挺喜欢:节点名匹配用前缀 + 下划线 规则,容忍复制粘贴产生的 _copy 后缀:

cpp 复制代码
inline int findPoseNode(const hand_core::Tree& tree, const char* prefix) {
    const std::string pre(prefix);
    for (const auto& n : tree.aliveNodes()) {
        const std::string& nm = n.joint.name;
        if (nm == pre) return n.id;
        if (nm.size() > pre.size() &&
            nm.compare(0, pre.size(), pre) == 0 && nm[pre.size()] == '_') {
            return n.id;   // T_MCP_copy 也算 T_MCP
        }
    }
    return -1;
}

为什么需要这个?因为我的工程里,食指和无名指的整条子树是偷懒,从中指复制出来的 ,节点名变成 I_PIP_copy、R_MCP_copy。姿势串里写 I_PIP-60 得能找到 I_PIP_copy,否则复制完模型,所有手势全失效。

💡 血案一则 :早期版本槽位名我图省事写成 T_IP,而工程里节点叫 T_PIP。结果 findPoseNode("T_IP") 永远返回 -1,一直排查为什么 T_PIP 不参与旋转?最后发现槽位字典里压根没这个节点,它当然不转。改成全名 T_PIP 后立刻好了。

更逗的是,joint_control_panel.cpp 里卡片输入框的 placeholder 至今还写着 T_IP0(只是提示文本,不参与解析)。它不影响功能,但会把照着提示抄的人带进沟里------我留着它当反面教材,提醒自己"协议里的名字要和工程里的名字逐字对齐"。

姿势串的完整格式、18 个槽位的顺序、手势数据的推导、以及多姿势卡片的插值播放,内容够单独撑一篇,放到第 5 篇 细讲。这里只需要记住一件事:界面层给算法层的输入,最好是一种人能读、能复制、能粘进聊天框的文本,而不是一堆藏在控件状态里的数值。


十、Qt 坑合集:每个都很蠢,合起来能烧掉一整天

把这一路踩过的 Qt 坑集中列一下,省得以后重蹈覆辙。

1. slots 是宏,不能当标识符。 private slots: 是正常写法,但把变量命名成 slots 会被预处理器展开,报错位置离现场十万八千里。

2. QSlider::setRange(min > max) 静默交换。 不报错、不警告,界面从此方向反了。必须自己先纠正并回写(见 7.2)。

3. QDoubleSpinBox::valueChanged 有两个重载。 连接时写 QOverload<double>::of(...),否则编译期推导失败。

4. 双向绑定必须堵信号。 QSignalBlocker 是 RAII 的,比 blockSignals(true/false) 安全。N 个控件互锁时,先找总闸全堵,改完统一放开。

5. QVideoWidget 是原生子窗口,会盖在 VTK 上。 同步模式最早用 QVideoWidget 显示摄像头,结果它是一个独立的 HWND,永远浮在 OpenGL 画布之上,怎么调 layout 都压不住,还带一个黑框。改成自绘:

cpp 复制代码
// QVideoSink 收帧 + QPainter 画,彻底绕开原生窗口
sink_ = new QVideoSink(this);
connect(sink_, &QVideoSink::videoFrameChanged, this,
        [this](const QVideoFrame& frame) {
            if (frame.isValid()) { frame_ = frame.toImage(); update(); }
        });

void CameraPreview::paintEvent(QPaintEvent*) {
    QPainter p(this);
    if (!frame_.isNull()) {
        const QImage scaled = frame_.scaled(size(), Qt::KeepAspectRatio,
                                            Qt::SmoothTransformation);
        p.fillRect(rect(), Qt::black);
        p.drawImage((width() - scaled.width()) / 2,
                    (height() - scaled.height()) / 2, scaled);
    }
}

6. Qt 6 的 QCamera 没有 setVideoOutput()。 正确姿势是 QMediaCaptureSession:

cpp 复制代码
session_ = new QMediaCaptureSession(this);
session_->setCamera(camera_);
session_->setVideoOutput(camera_view_->sink());   // 送进 QVideoSink
camera_->start();

7. "检测不到摄像头"未必是权限问题。 我一度以为是系统隐私设置,查了半天发现是 Qt Multimedia 的后端插件 DLL 没部署 :ffmpegmediaplugin.dll 和 windowsmediaplugin.dll 不在 exe 同目录,Qt 找不到任何可用后端,QMediaDevices::videoInputs() 直接返回空列表。后来在 CMake 里加了 POST_BUILD 拷贝规则才彻底解决:

cpp 复制代码
# Qt Multimedia 后端插件(摄像头/音频),缺失则 QMediaDevices::videoInputs() 返回空
if(EXISTS "C:/Qt/6.9.1/mingw_64/plugins/multimedia")
    add_custom_command(TARGET hand_sim POST_BUILD
        COMMAND ${CMAKE_COMMAND} -E copy_directory_if_different
                "C:/Qt/6.9.1/mingw_64/plugins/multimedia"
                "$<TARGET_FILE_DIR:hand_sim>/multimedia"
        COMMENT "部署 Qt Multimedia 后端插件")
endif()

整目录拷贝比逐个 DLL 稳(Qt 升级时插件名会变),外面套 EXISTS 是为了在没有 Qt Multimedia 开发包的机器上也能配置通过。

这个坑的教训是:框架"静默返回空"比"报错"难查十倍。 遇到"列表是空的",先查插件/资源有没有部署,再怀疑权限。

8. 父窗口 show() 会把未隐藏的子 widget 一起显示。 前面说的 sync_panel_->hide() 叠字坑。

9. 改了信号槽要强制重编 moc。 Ninja 偶尔对头文件里的 Q_OBJECT 变更不敏感,编译过了但槽连不上。稳妥做法:

cpp 复制代码
cd hand_sim\build
$env:PATH = "C:\Qt\Tools\mingw1310_64\bin;C:\Qt\Tools\CMake_64\bin;C:\Qt\Tools\Ninja;$env:PATH"
Remove-Item ui\CMakeFiles\hand_ui.dir\main_window.cpp.obj -ErrorAction SilentlyContinue
Remove-Item ui\CMakeFiles\hand_ui.dir\moc_*.cpp.obj -ErrorAction SilentlyContinue
ninja hand_sim

10. Windows 中文路径要用 std::filesystem::u8path。 直接 std::ofstream(path) 走 ANSI 代码页,"xxxxx.hproj" 会变乱码。这是 model/ 层的坑,但症状在 UI 上(保存对话框里显示一堆问号),列在这里备查。

11. 链接时 hand_sim.exe: Permission denied。 不是权限,是程序还在运行 ,Windows 锁住了 exe。我的做法是链一个独立的 hand_sim_verify.exe 先测,等关掉程序再 ninja hand_sim 覆盖正式文件。


十一、界面层的减法:我删掉的四个模块

这一节我想单独讲,因为做界面最容易犯的错不是写不出,是舍不得删。

项目里曾经有过这些,现在都没了:

| 删掉的 | 为什么 | | --- | --- | | 质量参数模块(质量/质心/惯量输入) | L1 阶段只做几何与运动学,质量参数要到 L3 动力学才用得上。摆在那里只会让人以为已经实现了 | | 关节角滑条区(每个关节一根 QSlider) | 18 个关节 = 18 根滑条,屏幕塞不下;姿势串 + 3D 拖拽更直接 | | 回放进度条 | 有了流程卡片 + 播放状态标签,进度条是重复信息 | | 整个仿真控制面板 (启停/单步/复位/步长) | L1 没有积分器,"开始仿真"按下去什么也不会发生。一个点了没反应的按钮,比没有这个按钮更伤 |

由此我给自己定了个规矩,后来每次加控件前都问一遍:

"这个控件背后,现在有没有一个真实的行为?"

有 → 做。 没有但规划里有 → 先别放进界面,等后端就位。

纯粹"看起来专业" → 删。

顺带一提,.ui 表单在这里又体现了价值:删掉一个面板,主窗口这边只动了 buildDocks() 的几行;如果当初整个界面都是代码堆出来的,这次减法得连着改几十行 layout。

界面写完那天,我把软件从头到尾走了一遍:打开 阶段3.hproj → 树里点 T_MCP → 属性面板改限位 → 拖 q 看拇指弯 → 复制右上角姿势串 → 切模拟模式粘贴播放。全程没碰过一次命令行,也没重编译。

那一刻才觉得,前三篇那些矩阵,终于变成了别人也能用的东西。


下篇见。

相关推荐
用户1179104883323 分钟前
鸿蒙签名失败不是玄学:5 个证据点把 hapsigntool 一次排通 GitHub:swsgbl/hmharness
开源·ai编程·harmonyos
用户2991422196801 小时前
GitHub 热榜项目:日榜(2026-11-07)
人工智能·开源·github
Flynt1 小时前
内存省16倍的Lightpanda实测:数字是真的,但并发代码要重写
性能优化·开源·agent
分布式存储与RustFS3 小时前
Hudi 表想摆脱 HDFS 绑定?用 Spark + Hudi 直接写进 RustFS
运维·云原生·开源·对象存储·分布式存储·s3·性能基准
miofly4 小时前
GitHub 日榜趋势速报 | 2026-11-07
开源·github
冬奇Lab15 小时前
一天一个开源项目(第255篇):Mika —— 装在安卓手机里的 AI 代理,在后台虚拟屏上替你操作 App 和网页
人工智能·开源·资讯
孟健17 小时前
实测 agent-desktop:AI 控制桌面不截图、不占鼠标键盘,连点 55 次计算器 0 失误
人工智能·架构·开源
miofly19 小时前
Inkling 2 Small 发布,基准测试大幅超越前代但不敌同级对手
开源·github
ClouGence19 小时前
数据库变更审批困局,被这个开源工具解决了
数据库·开源