为 HOOPS+ACIS 三维平台实现一个 Split Tool Button 命令控件
最近在给一个基于 ACIS + HOOPS 的三维平台扩展 Orbit 命令,原本它就一个按钮点下去绕屏幕中心转。产品需求是改成下拉式的多模式 Orbit:主按钮照常可点击,下拉菜单里可选 Rotate Model Center / Rotate Current Axis / Rotate Screen Center / Rotate Cursor 四种旋转中心。选哪一种,主按钮的 icon 和 tooltip 就跟着变成那一种。再点一次退出命令。
听起来很标准的 split button,Qt 和 Qtitan Ribbon 应该都支持得很好。但真做下来踩了一堆坑,值得记录一下。
一、已有的命令框架
平台的命令框架是典型的”命令定义 + 输入控件 + 命令管理器”三层:
CommandDefinition:每个命令的元信息和工厂,负责创建具体的CommandBase实例InputControl:命令在 UI 上的”输入控件”,有多种类型(按钮、下拉列表、组合框等),通过InputControlType枚举区分CommandMgr:维护命令栈,管理激活/切换/退出
框架里已经有一个 ListTypeCommandDefinition + ListToolButtonInputControl,看起来能用:它就是一个带下拉的按钮。但仔细看实现就会发现它不适合我这个需求:
- 它的 toolbar 分发是把每个子项都摊成一个独立按钮并排摆到工具栏上,不是我要的 split button
- 它的点击分发槽有这么一句:
1 | |
也就是说点击当前已选中的项事件会被框架直接吞掉,根本进不到 handler。那”再点一次退出命令”就无从实现了。
所以要写一个新的控件 + 新的命令基类。
二、新控件的设计目标
理清楚需求:
- 一个可独立点击的主按钮,外加一个独立的下拉箭头
- 主按钮的 icon / tooltip 永远反映”当前选中的子项”
- 默认选中某个子项(Orbit 默认是 Screen Center)
- 每次点击都进 handler,没有”同项被吞”这种事
- 点击期间,菜单里对应子项要显示 checked 状态
- 命令退出时,菜单里的 checked 状态要被清掉
- 要能在我们的 QToolBar 和 Qtitan RibbonGroup 两个宿主里都正常显示
把 UI 语义和命令语义解耦:“当前选中”和”是否正在运行”是两个独立的状态,菜单项的勾选状态是 running && (key == currentItem) 这个公式算出来的。只要任何修改都走同一个 syncMenuChecks() helper,就能保证菜单显示永远和真实状态一致。
三、走过的弯路
这个事情最终花了多轮迭代才做对。把坑记录下来,下次别踩。
坑 1:setHoopsOperator 是 protected
我最开始想做一个 OrbitPivotOperator(继承 HOpCameraRelativeOrbit),在 CommandDef::onCreateCommand 里创建 OrbitCommand 之后外部调用:
1 | |
MSVC 报的是 “OrbitPivotOperator::OrbitPivotOperator 不可访问”,一开始以为是 operator 类本身的访问控制问题,折腾半天没头绪。真正的原因是:setHoopsOperator 是 HoopsInteractiveCommand 的 protected 成员,只能从派生类的成员函数里调用。从 CommandDef::onCreateCommand 外部 cmd->setHoopsOperator(...) 就是访问 protected 成员,不合法。
编译器把这个错误报在了 new OrbitPivotOperator(...) 这个表达式上(因为类型推导的隐式拷贝构造也在这里检查了可见性),所以容易误判成是 OrbitPivotOperator 的问题。
正确做法:把 operator 的创建放回 OrbitCommand 的构造函数里,mode 通过构造参数传进去:
1 | |
坑 2:匿名命名空间 + MSVC 的访问检查 bug
OrbitPivotOperator 一开始放在匿名命名空间里(internal linkage),结果 MSVC 报了个奇怪的 “构造函数不可访问” 错误,并且提示 “还有 1 个重载”。
根因是 MSVC 在处理 “匿名命名空间里的类被外部链接类型的成员函数实例化” 这种组合时,会对隐式生成的拷贝/移动构造做额外的可见性检查。而 HOOPS 的 HOpCameraRelativeOrbit 作为 operator 类大概率禁用或保护了拷贝构造,于是隐式生成的拷贝构造不可访问,整段代码就被报成 “还有 1 个重载 … 不可访问”,误导性很强。
解决:把 OrbitPivotOperator 从匿名命名空间搬出来,改成直接放在 mcad::gui::app 命名空间下。反正它只在 .cpp 里定义,头文件没暴露,不会有符号冲突。
1 | |
坑 3:Qt 的 QAction 点击路径在 Ribbon 里被吞掉
这是最难定位的一个坑,也是最后花了大力气才明白的。
一开始我的 SplitToolButtonInputControl 暴露一个 QAction*,dispatch 代码直接:
1 | |
运行后的现象:主按钮在 ribbon 里显示,点击它显示按下状态,但 handler 完全不触发。下拉箭头倒是正常工作。
折腾了半天,把整个链路梳理了一遍才弄明白:
- Qtitan 的
RibbonGroup::addAction2 参数版本会内部创建一个 “wrapper QAction” 和一个QToolButton - 如果原始 action 自带菜单(
action->setMenu(menu)已经调过),Qtitan 内部的按钮会被设置成DelayedPopup或类似模式 - 在这种模式下点击主按钮区域 → Qt 会延迟触发 popup → wrapper 吃掉 click → 原始 action 的
triggered信号根本发不出来
这就解释了为什么”显示按下了但命令不执行”——视觉上 setCheckable(true) 让按钮 toggle 了一下,功能上 triggered 信号被吞了。
中间我走了一段弯路:想把 action 换成直接暴露 QToolButton,通过 addWidgetToRibbonGroup 传进去。结果 Qtitan 的 addWidget 会把 widget 包成 RibbonWidgetControl,不参与 ribbon 的尺寸计算。QToolButton 没设 minimumSize,直接被挤成 0 宽度,整个 orbit 按钮在 ribbon 上消失了。
真正的解决方案:仔细看 Qtitan 的 RibbonGroup.h,它有这么一个 4 参数重载:
1 | |
这才是 Qtitan 原生 split button 的正确用法:传入 action + menu + MenuButtonPopup,Qtitan 内部会正确地 setDefaultAction(action) + setMenu(menu) + setPopupMode(MenuButtonPopup),主体和箭头就真正分离了。
对应的约束是:SplitToolButtonInputControl::getAction() 内部绝对不能调 m_action->setMenu(...)。Action 只管自己 clickable,菜单单独通过 getMenu() 暴露给 dispatch 代码,让 dispatch 代码去调用宿主的原生 split button API:
1 | |
QToolBar 也类似,用 2 参数 addAction 后抓 button 再配置:
1 | |
坑 4:切换模式时主按钮状态直接退出
SplitToolButtonInputControl::slotSubActionTriggered 一开始这样写:
1 | |
结果是:运行 ModelCenter 时,用户从下拉里切到 ScreenCenter,handler 里做这个判断:
1 | |
此时 current 已经被上一步改成 ScreenCenter 了,effectiveKey 也是 ScreenCenter,于是走了退出分支,没起新模式。
解决:控件的槽里不要提前更新 m_currentItem。真正的 “selection commit” 推迟到 SplitTypeCommandDefinition::runWithItem() 里去做——那里本来就会调 setCurrentItem(),而且只在命令真正 (re)start 成功时才调。
1 | |
坑 5:菜单项的勾选状态停留在退出前
退出命令后,下拉菜单里上次选中的那一项还保持勾选状态。这是因为 Qt 的 QActionGroup 在 exclusive 模式下只在”用户触发”时强制最多一个勾选,不会在命令结束时自动清除勾选。
解决:把”菜单项勾选状态”彻底绑死到一个公式:
1 | |
新增一个 syncMenuChecks() helper,对每个子项根据这个公式重新设置 setChecked。setRunning()、setCurrentItem()、rebuildMenuItems() 三处都调用它,保证无论谁改动状态,菜单显示都是一致的。
1 | |
四、最终的架构
整个实现分成三层,职责非常清晰。
输入控件:SplitToolButtonInputControl
职责:暴露 (QAction, QMenu) 一对,由 dispatch 代码决定怎么组合成 split button;维护”当前选中项”和”运行中”两个状态;把菜单勾选状态算成这两者的函数。
关键接口:
1 | |
命令定义基类:SplitTypeCommandDefinition
职责:绑定输入控件;把”同项 toggle 退出”、”异项切换”的策略封装掉;用 CommandMgr::isTopCommand 自动同步按钮的 running 状态。
子类只需要实现:
1 | |
核心的分发逻辑:
1 | |
onUpdateInputControl 里做状态同步:
1 | |
好处是:无论命令是怎么结束的(用户主动退出、ESC、被其它命令顶掉),按钮视觉状态都会跟着 CommandMgr 的真实栈顶走,不会残留”按下”样式。
具体命令:OrbitCommandDef + OrbitCommand
1 | |
五、旋转中心和 Gizmo 实现
命令本身用 OrbitPivotOperator 替换掉原来的 HOpCameraRelativeOrbit,override OnLButtonDown/OnLButtonUp:按下时根据模式算 pivot 点、调 SetCenter(pivot) 交给基类、绘制 gizmo;松开时移除 gizmo。
四种模式的 pivot 算法:
| 模式 | 算法 |
|---|---|
| ModelCenter | HC_Filter_Circumcuboid_By_Key(modelKey, "visibility = on", ...) 算可见模型的 bbox 中心,排除隐藏几何 |
| CurrentAxis | 当前活动坐标系原点(可用 HoopsGraphicUtil::applyActiveCSTransf 将 (0,0,0) 变换到世界坐标) |
| ScreenCenter | cam.target(焦平面上的屏幕中心,等价于原始 Orbit 行为) |
| Cursor | HoopsGraphicUtil::transform(vpPt, CSTYPE_VIEWPOINT, CSTYPE_WORLD) 投影光标到世界空间 |
Gizmo 是一个琥珀色中心球 + 6 个指向中心的圆锥(X 蓝、Y 绿、Z 红)。尺寸用 HoopsGraphicUtil::screenLengthToWorld(view, 30.0f) 根据当前相机缩放反算世界单位,这样无论模型是 0.001 mm 还是 100 m,gizmo 都稳定占约 60 px。
关键的 HOOPS 渲染设置:
1 | |
exclude bounding防止 gizmo 干扰 view auto-fitno hidden surfaces让 gizmo 始终画在模型上方no lighting保证三轴颜色不被光照冲淡
六、如何为新命令复用这套控件
后续如果有类似需求(”主按钮 + 下拉多模式 + 同项 toggle 退出 + 异项切换”),整个流程只需要三步。
步骤 1:派生 SplitTypeCommandDefinition
1 | |
步骤 2:注册命令
和普通命令没差别,工厂里加一行:
1 | |
步骤 3:资源和快捷键
把下拉项需要的图标放进 :/images/resource/ 并添加到资源文件里。快捷键在 info() 的第 4 个参数设置,比如 "Ctrl+F"。
就这样。所有”同项退出、异项切换、运行中视觉反馈、菜单勾选同步、dispatch 到 toolbar/ribbon”的逻辑都封在基类里了,新命令只要关心自己的业务。
七、一些可能的扩展方向
一些用得上的扩展点,留给以后:
- 想让主按钮永远绑定默认项,而不是跟着用户最后一次选的项走? 拆开
m_currentItem(当前实际要执行的模式)和m_pinnedKey(主按钮的”锚定项”),点击主按钮时用m_pinnedKey而不是m_currentItem - 想让某些子项在运行时动态禁用?
SplitToolButtonInputControl::setItemEnabled(key, false)已经有了,在onUpdateInputControl里调 - 想给子项加分组或分隔线? 在
rebuildMenuItems里插QAction::separator或扩展Infolist让每个Info可以带 group id - 想让下拉项本身也参与命令的可用性检查? 重写
SplitTypeCommandDefinition::onUpdateInputControl,为每个 key 单独调isApplicable并反映到setItemEnabled
总结
踩坑比实现本身花的时间多得多,根本原因是信任了”Qt 的 QAction 点击会自动转发到原始 action”这个天真假设。在 ribbon 这种第三方控件库里,中间插入 wrapper action + 自管 QToolButton 的 popup mode 让这个假设直接失效。
最后的解决方案反而很干净:action 和 menu 分开暴露,让 dispatch 代码用宿主原生的 split button API。这个做法的好处是——以后如果换了另一个 ribbon 库,只要它有对应的 split button API,修改点只在 dispatch 分支里,SplitToolButtonInputControl 和 SplitTypeCommandDefinition 都不用动。
另一个教训是状态同步要单向:菜单勾选状态不是”用户操作时设置一下”,而是从 (running, currentItem) 公式派生出来,只要保证任何修改都走同一个 syncMenuChecks helper,不一致就不可能发生。这个设计思路在其他需要多来源状态同步的地方也完全适用。