为 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,看起来能用:它就是一个带下拉的按钮。但仔细看实现就会发现它不适合我这个需求:

  1. 它的 toolbar 分发是把每个子项都摊成一个独立按钮并排摆到工具栏上,不是我要的 split button
  2. 它的点击分发槽有这么一句:
1
2
3
4
5
6
7
void ListToolButtonInputControl::slotCurrentActionPressed(QAction* action) {
QString itemKey = action->objectName();
if (itemKey.toStdString() == m_currentItem) {
return; // 重复点同一项被吞掉
}
// ...
}

也就是说点击当前已选中的项事件会被框架直接吞掉,根本进不到 handler。那”再点一次退出命令”就无从实现了。

所以要写一个新的控件 + 新的命令基类。

二、新控件的设计目标

理清楚需求:

  1. 一个可独立点击的主按钮,外加一个独立的下拉箭头
  2. 主按钮的 icon / tooltip 永远反映”当前选中的子项”
  3. 默认选中某个子项(Orbit 默认是 Screen Center)
  4. 每次点击都进 handler,没有”同项被吞”这种事
  5. 点击期间,菜单里对应子项要显示 checked 状态
  6. 命令退出时,菜单里的 checked 状态要被清掉
  7. 要能在我们的 QToolBar 和 Qtitan RibbonGroup 两个宿主里都正常显示

把 UI 语义和命令语义解耦:“当前选中”和”是否正在运行”是两个独立的状态,菜单项的勾选状态是 running && (key == currentItem) 这个公式算出来的。只要任何修改都走同一个 syncMenuChecks() helper,就能保证菜单显示永远和真实状态一致。

三、走过的弯路

这个事情最终花了多轮迭代才做对。把坑记录下来,下次别踩。

坑 1:setHoopsOperator 是 protected

我最开始想做一个 OrbitPivotOperator(继承 HOpCameraRelativeOrbit),在 CommandDef::onCreateCommand 里创建 OrbitCommand 之后外部调用:

1
2
3
4
auto* cmd = new OrbitCommand(this, context);
cmd->setPivotMode(mode);
cmd->setHoopsOperator(new OrbitPivotOperator(cmd->getHoopsView(), mode));
// ↑ 编译报错:OrbitPivotOperator 不可访问

MSVC 报的是 “OrbitPivotOperator::OrbitPivotOperator 不可访问”,一开始以为是 operator 类本身的访问控制问题,折腾半天没头绪。真正的原因是:setHoopsOperatorHoopsInteractiveCommand 的 protected 成员,只能从派生类的成员函数里调用。从 CommandDef::onCreateCommand 外部 cmd->setHoopsOperator(...) 就是访问 protected 成员,不合法。

编译器把这个错误报在了 new OrbitPivotOperator(...) 这个表达式上(因为类型推导的隐式拷贝构造也在这里检查了可见性),所以容易误判成是 OrbitPivotOperator 的问题。

正确做法:把 operator 的创建放回 OrbitCommand 的构造函数里,mode 通过构造参数传进去:

1
2
3
4
5
6
7
OrbitCommand::OrbitCommand(CommandDefinition* def, CommandContext* ctx,
OrbitPivotMode mode)
: HoopsInteractiveCommand(def, ctx), m_pivotMode(mode)
{
setHoopsOperator(new OrbitPivotOperator(getHoopsView(), m_pivotMode));
// ↑ 在成员函数里调用,OK
}

坑 2:匿名命名空间 + MSVC 的访问检查 bug

OrbitPivotOperator 一开始放在匿名命名空间里(internal linkage),结果 MSVC 报了个奇怪的 “构造函数不可访问” 错误,并且提示 “还有 1 个重载”。

根因是 MSVC 在处理 “匿名命名空间里的类被外部链接类型的成员函数实例化” 这种组合时,会对隐式生成的拷贝/移动构造做额外的可见性检查。而 HOOPS 的 HOpCameraRelativeOrbit 作为 operator 类大概率禁用或保护了拷贝构造,于是隐式生成的拷贝构造不可访问,整段代码就被报成 “还有 1 个重载 … 不可访问”,误导性很强。

解决:把 OrbitPivotOperator 从匿名命名空间搬出来,改成直接放在 mcad::gui::app 命名空间下。反正它只在 .cpp 里定义,头文件没暴露,不会有符号冲突。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
namespace mcad { namespace gui { namespace app {

namespace {
// 只放真正需要 internal linkage 的东西(常量、helper 函数)
constexpr const char* kRotateModelCenter = "RotateModelCenter";
// ...
}

// OrbitPivotOperator 直接放在 app 命名空间下,不进匿名命名空间
class OrbitPivotOperator : public HOpCameraRelativeOrbit {
// ...
};

}}}

坑 3:Qt 的 QAction 点击路径在 Ribbon 里被吞掉

这是最难定位的一个坑,也是最后花了大力气才明白的。

一开始我的 SplitToolButtonInputControl 暴露一个 QAction*,dispatch 代码直接:

1
2
3
4
// 错误做法
QAction* action = splitCtrl->getAction();
// 这个 action 内部还调了 m_action->setMenu(m_menu)
group->addAction(action, Qt::ToolButtonIconOnly);

运行后的现象:主按钮在 ribbon 里显示,点击它显示按下状态,但 handler 完全不触发。下拉箭头倒是正常工作。

折腾了半天,把整个链路梳理了一遍才弄明白:

  1. Qtitan 的 RibbonGroup::addAction 2 参数版本会内部创建一个 “wrapper QAction” 和一个 QToolButton
  2. 如果原始 action 自带菜单(action->setMenu(menu) 已经调过),Qtitan 内部的按钮会被设置成 DelayedPopup 或类似模式
  3. 在这种模式下点击主按钮区域 → 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
2
3
4
QAction* addAction(QAction* action,
Qt::ToolButtonStyle style,
QMenu* menu = Q_NULL, // ← 关键
QToolButton::ToolButtonPopupMode mode = MenuButtonPopup); // ← 关键

这才是 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
2
3
4
5
6
7
8
9
10
11
12
13
// 正确做法
} else if (type == InputControlType::eSplitToolButton) {
auto splitCtrl = static_cast<SplitToolButtonInputControl*>(inputControl);
QAction* newAction = group->addAction(
splitCtrl->getAction(),
Qt::ToolButtonIconOnly,
splitCtrl->getMenu(), // ← 菜单从这里传
QToolButton::MenuButtonPopup); // ← 强制 split 模式
auto control = group->controlByAction(newAction);
control->sizeDefinition(RibbonControlSizeDefinition::GroupLarge)
->setImageSize(RibbonControlSizeDefinition::ImageSmall);
// ...
}

QToolBar 也类似,用 2 参数 addAction 后抓 button 再配置:

1
2
3
4
5
6
7
8
9
} else if (type == InputControlType::eSplitToolButton) {
auto splitCtrl = static_cast<SplitToolButtonInputControl*>(inputControl);
toolbar->addAction(splitCtrl->getAction());
if (auto* btn = qobject_cast<QToolButton*>(
toolbar->widgetForAction(splitCtrl->getAction()))) {
btn->setMenu(splitCtrl->getMenu());
btn->setPopupMode(QToolButton::MenuButtonPopup);
}
}

坑 4:切换模式时主按钮状态直接退出

SplitToolButtonInputControl::slotSubActionTriggered 一开始这样写:

1
2
3
4
5
void slotSubActionTriggered(QAction* action) {
std::string key = action->objectName().toStdString();
setCurrentItem(key); // ← 先更新自己的 m_currentItem
handler->onItemTriggered(key);
}

结果是:运行 ModelCenter 时,用户从下拉里切到 ScreenCenter,handler 里做这个判断:

1
2
3
4
std::string current = m_splitControl->getCurrentItem();
if (effectiveKey == current) {
// 同项 → 退出
}

此时 current 已经被上一步改成 ScreenCenter 了,effectiveKey 也是 ScreenCenter,于是走了退出分支,没起新模式。

解决:控件的槽里不要提前更新 m_currentItem。真正的 “selection commit” 推迟到 SplitTypeCommandDefinition::runWithItem() 里去做——那里本来就会调 setCurrentItem(),而且只在命令真正 (re)start 成功时才调。

1
2
3
4
5
void slotSubActionTriggered(QAction* action) {
std::string key = action->objectName().toStdString();
// 不碰 m_currentItem, 让 handler 拿到的 "current" 仍然是上一个值
handler->onItemTriggered(key);
}

坑 5:菜单项的勾选状态停留在退出前

退出命令后,下拉菜单里上次选中的那一项还保持勾选状态。这是因为 Qt 的 QActionGroup 在 exclusive 模式下只在”用户触发”时强制最多一个勾选,不会在命令结束时自动清除勾选。

解决:把”菜单项勾选状态”彻底绑死到一个公式:

1
checked ⇔ (m_running && key == m_currentItem)

新增一个 syncMenuChecks() helper,对每个子项根据这个公式重新设置 setCheckedsetRunning()setCurrentItem()rebuildMenuItems() 三处都调用它,保证无论谁改动状态,菜单显示都是一致的。

1
2
3
4
5
6
7
8
9
10
11
void SplitToolButtonInputControl::syncMenuChecks() {
for (auto& kv : m_subActions) {
if (kv.second == nullptr) continue;
bool shouldCheck = m_running && (kv.first == m_currentItem);
if (kv.second->isChecked() == shouldCheck) continue;
// blockSignals 防止 QActionGroup 做反向同步
bool prev = kv.second->blockSignals(true);
kv.second->setChecked(shouldCheck);
kv.second->blockSignals(prev);
}
}

四、最终的架构

整个实现分成三层,职责非常清晰。

输入控件:SplitToolButtonInputControl

职责:暴露 (QAction, QMenu) 一对,由 dispatch 代码决定怎么组合成 split button;维护”当前选中项”和”运行中”两个状态;把菜单勾选状态算成这两者的函数。

关键接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
class SplitToolButtonInputControl : public QObject, public InputControl {
public:
void initItems(const command::InfoList& items);

std::string getCurrentItem() const;
void setCurrentItem(const std::string& itemKey);

void setRunning(bool running);
bool isRunning() const;

QAction* getAction(); // 主按钮 action, 无 menu
QMenu* getMenu(); // 下拉菜单, 和 action 分离
};

命令定义基类:SplitTypeCommandDefinition

职责:绑定输入控件;把”同项 toggle 退出”、”异项切换”的策略封装掉;用 CommandMgr::isTopCommand 自动同步按钮的 running 状态。

子类只需要实现:

1
2
3
4
5
6
class MyCommandDef : public SplitTypeCommandDefinition {
protected:
Infolist getSplitItems() override; // 下拉项
CommandBase* onCreateCommand(CommandContext*) override; // 工厂
std::string defaultItemKey() override { return "..."; } // 默认选中
};

核心的分发逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
void SplitTypeCommandDefinition::onItemTriggered(const std::string& itemKey) {
auto* mgr = CommandMgr::get();
bool running = mgr && mgr->isTopCommand(getCommandInfo()->getKey());

std::string effectiveKey = itemKey;
if (effectiveKey.empty() && m_splitControl) {
effectiveKey = m_splitControl->getCurrentItem();
}

if (running) {
mgr->cancelAndEndActiveCommand();

std::string current = m_splitControl
? m_splitControl->getCurrentItem() : "";
if (effectiveKey.empty() || effectiveKey == current) {
// 同项或空 → 纯退出
m_splitControl->setRunning(false);
return;
}
// 异项 → 继续往下走, 启动新模式
}

if (effectiveKey.empty()) return;
runWithItem(effectiveKey);
}

onUpdateInputControl 里做状态同步:

1
2
3
4
5
6
void SplitTypeCommandDefinition::onUpdateInputControl() {
CommandDefinition::onUpdateInputControl();
auto* mgr = CommandMgr::get();
bool running = mgr && mgr->isTopCommand(getCommandInfo()->getKey());
m_splitControl->setRunning(running);
}

好处是:无论命令是怎么结束的(用户主动退出、ESC、被其它命令顶掉),按钮视觉状态都会跟着 CommandMgr 的真实栈顶走,不会残留”按下”样式。

具体命令:OrbitCommandDef + OrbitCommand

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class OrbitCommandDef : public SplitTypeCommandDefinition {
protected:
CommandBase* onCreateCommand(CommandContext* context) override;
Infolist getSplitItems() override;
std::string defaultItemKey() override { return "RotateScreenCenter"; }
};

CommandBase* OrbitCommandDef::onCreateCommand(CommandContext* context) {
std::string itemKey;
try {
itemKey = boost::any_cast<std::string>(
context->getValue(SplitTypeCommandDefinition::selectedItemKeyName()));
} catch (...) {}
if (itemKey.empty()) itemKey = "RotateScreenCenter";

OrbitPivotMode mode = modeFromKey(itemKey);
return new OrbitCommand(this, context, mode);
}

五、旋转中心和 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
2
3
HC_Set_Heuristics("exclude bounding, no hidden surfaces");
HC_Set_Visibility("everything = off, faces = on");
HC_Set_Rendering_Options("no lighting");
  • exclude bounding 防止 gizmo 干扰 view auto-fit
  • no hidden surfaces 让 gizmo 始终画在模型上方
  • no lighting 保证三轴颜色不被光照冲淡

六、如何为新命令复用这套控件

后续如果有类似需求(”主按钮 + 下拉多模式 + 同项 toggle 退出 + 异项切换”),整个流程只需要三步。

步骤 1:派生 SplitTypeCommandDefinition

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
class MyFancyCommandDef : public command::SplitTypeCommandDefinition {
public:
MyFancyCommandDef() : SplitTypeCommandDefinition(info()) {}

static command::Info* info() {
static command::Info theInfo(
"MyFancyCommand", "Fancy", "do fancy stuff", "", "fancy.svg");
return &theInfo;
}

protected:
// 下拉项
command::Infolist getSplitItems() override {
command::Infolist items;
items.push_back(command::Info(
"ModeA", "Mode A", "do it in mode A", "", "fancy_a.svg"));
items.push_back(command::Info(
"ModeB", "Mode B", "do it in mode B", "", "fancy_b.svg"));
return items;
}

// 默认选中项
std::string defaultItemKey() override { return "ModeA"; }

// 命令工厂
command::CommandBase* onCreateCommand(command::CommandContext* ctx) override {
std::string itemKey;
try {
itemKey = boost::any_cast<std::string>(ctx->getValue(
command::SplitTypeCommandDefinition::selectedItemKeyName()));
} catch (...) {}
if (itemKey.empty()) itemKey = "ModeA";
return new MyFancyCommand(this, ctx, itemKey);
}

bool onApplicable(command::CommandContext* ctx) override {
// 自定义可用性检查
return CommandDefinition::onApplicable(ctx);
}
};

步骤 2:注册命令

和普通命令没差别,工厂里加一行:

1
2
3
addCommandDefCreationItem("MyFancyCommand", []() {
return new MyFancyCommandDef();
});

步骤 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 分支里,SplitToolButtonInputControlSplitTypeCommandDefinition 都不用动。

另一个教训是状态同步要单向:菜单勾选状态不是”用户操作时设置一下”,而是(running, currentItem) 公式派生出来,只要保证任何修改都走同一个 syncMenuChecks helper,不一致就不可能发生。这个设计思路在其他需要多来源状态同步的地方也完全适用。


为 HOOPS+ACIS 三维平台实现一个 Split Tool Button 命令控件
http://aojian-blog.oss-cn-wuhan-lr.aliyuncs.com/2026/04/10/splittoolbutton/
作者
遨见
发布于
2026年4月10日
许可协议