diff --git a/AGENTS.md b/AGENTS.md index a5ce5b65475dcf1665cec0a98edbcaa6cc7c60d5..8ff165337c9400fd468dd8f47f9d52add6d4d559 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -135,6 +135,7 @@ - **新特性必须配套测试用例**;修 bug 时尽量补可复现的回归测试。 - 不要向无测试的模块强行塞测试框架;遵循该模块既有测试模式。 - 工具检测用 PowerShell:例如 `Get-Command makensis`(不要用 `where makensis`)。 +- **插件共享头文件需全量构建**:修改被插件共享的 `core/`、`model/` 头文件(如 `InteractionState.h`、`InteractiveTypes.h`)后必须全量构建(含插件目标)再做手动验证:插件 DLL 运行时动态加载、不是 `PreCess.exe` 的链接依赖,`cmake --build --target PreCess` 不会让插件随之重建;新旧 ABI 混用会产生难以排查的内存错乱(如"测量崩溃"即此原因)。 --- diff --git a/app/SideBar.qml b/app/SideBar.qml index abd9634501c7d3db92e1bd4ba6693c64b8ab99a3..40db5e9fd984d60103676cbfe77bebd85fb5b428 100644 --- a/app/SideBar.qml +++ b/app/SideBar.qml @@ -22,6 +22,9 @@ Item{ onActiveOpChanged: { parameters = [] resultText = "" + // 活动操作声明视口交互能力则激活其交互,否则全部下线(幂等,守卫在功能系统内) + var interactive = !!(activeOp && activeOp.info && activeOp.info.interactive) + QModelManager.featureSystem.setFeatureActive(interactive ? activeOp.info.name : "") } // 写入参数值;功能的参数为持久参数,修改即时写回功能系统实时生效 @@ -40,24 +43,36 @@ Item{ } } - Button{ - id: commitButton - text: "执行" - enabled: !!(root.activeOp && root.activeOp.info) + RowLayout{ + id: buttonRow anchors.top: parent.top anchors.left: parent.left anchors.right: parent.right - height:30 - onClicked:{ - if (root.activeOp && root.activeOp.execute) - root.resultText = root.activeOp.execute(App.selection.activeComponentId, root.parameters) - if (App.registry.renderWindow) - App.registry.renderWindow.clearSelection() + height: 30 + Button{ + id: commitButton + text: "执行" + enabled: !!(root.activeOp && root.activeOp.info) + Layout.fillWidth: true + onClicked:{ + if (root.activeOp && root.activeOp.execute) + root.resultText = root.activeOp.execute(App.selection.activeComponentId, root.parameters) + if (App.registry.renderWindow) + App.registry.renderWindow.clearSelection() + } + } + Button{ + id: confirmButton + text: "确认" + enabled: !!(root.activeOp && root.activeOp.info) + Layout.fillWidth: true + // 确认 = 结束当前操作,取消操作选中;再次执行需重新点选算法 + onClicked: App.activeOperation = null } } TextArea { id: resultArea - anchors.top: commitButton.bottom + anchors.top: buttonRow.bottom anchors.left: parent.left anchors.right: parent.right // 不可见时不占锚定布局高度,避免留下空白 @@ -110,6 +125,9 @@ Item{ if(model.type === QArgType.Bool){ //布尔值 return boolComponent } + if(model.type === QArgType.Button){ //按钮 + return buttonComponent + } } } } @@ -397,4 +415,17 @@ Item{ } } } + Component{ + id: buttonComponent + RowLayout{ + spacing: 5 + width: parameterList.width + Button{ + // Button 是无值触发器:计数器载荷,功能约定忽略值只读参数下标 + text: model.name + Layout.fillWidth: true + onClicked: root.setParam(index, (root.parameters[index] || 0) + 1) + } + } + } } diff --git a/app/model/QArgObject.cpp b/app/model/QArgObject.cpp index 2ee8cf9054c84a9727f89c7c545d9d9f8ba5146b..d8cca961cf9099126e539b73760bf0ce128e2a81 100644 --- a/app/model/QArgObject.cpp +++ b/app/model/QArgObject.cpp @@ -46,6 +46,10 @@ std::optional QArgObject::getValue() const } break; } + case ArgTypeEnum::Button: + // Button 是无值触发器:计数器载荷,功能约定忽略值只读参数下标 + ret = ArgObject::create(value_.toInt(&canConvert)); + break; default: canConvert = false; break; diff --git a/app/model/systems/feature/QFeatureSystemAdaptor.cpp b/app/model/systems/feature/QFeatureSystemAdaptor.cpp index d9a5a25883ed18cea64f349bc846a3a12dcb6431..3622e14afe1f954524c7ee20825d73588fec5837 100644 --- a/app/model/systems/feature/QFeatureSystemAdaptor.cpp +++ b/app/model/systems/feature/QFeatureSystemAdaptor.cpp @@ -103,6 +103,11 @@ bool QFeatureSystemAdaptor::postKeyEvent(int key, int modifiers, bool pressed) return feature_system_->dispatchKeyEvent(KeyEvent { key, modifiers, pressed }); } +bool QFeatureSystemAdaptor::setFeatureActive(const QString& unique_name) +{ + return feature_system_->setFeatureActive(unique_name.toStdString()); +} + void QFeatureSystemAdaptor::setActiveModel(int id) { active_model_id_ = id; diff --git a/app/model/systems/feature/QFeatureSystemAdaptor.h b/app/model/systems/feature/QFeatureSystemAdaptor.h index 15cff6e4478bdff046e2451556a8bfe9383d2b9d..d9e43f5d2943660173c83fcb256026b717c69d9b 100644 --- a/app/model/systems/feature/QFeatureSystemAdaptor.h +++ b/app/model/systems/feature/QFeatureSystemAdaptor.h @@ -41,6 +41,11 @@ public: * @return 事件已被功能消费(应 accept)时为 true */ Q_INVOKABLE bool postKeyEvent(int key, int modifiers, bool pressed); + /** + * @brief 启用当前活动功能(活动操作切换驱动,幂等) + * @param unique_name 要激活的功能唯一名称(须声明 interactive);空串表示全部下线 + */ + Q_INVOKABLE bool setFeatureActive(const QString& unique_name); /** * @brief QML侧同步当前活动模型id,供功能上下文动态获取 */ diff --git a/app/render/InteractionService.cpp b/app/render/InteractionService.cpp index 8ca93059c080be455e4f203ca741ca69f581d1c3..1dd7d3d3afbbdbb8dbf9daca0d9c1710be45c305 100644 --- a/app/render/InteractionService.cpp +++ b/app/render/InteractionService.cpp @@ -197,6 +197,25 @@ void InteractionService::hover(double posx, double posy) } } +void InteractionService::pollRefresh() +{ + if (current_ && current_->needs_refresh) { + current_->needs_refresh = false; + // GUI 线程经 deferred_clear 延迟清空操作,在渲染线程安全执行 + if (current_->deferred_clear) { + current_->deferred_clear(); + current_->deferred_clear = nullptr; + } + // GUI 线程经 deferred_deactivate 延迟停用:syncState 检测 active=false 并执行 on_deactivate + clearActors + if (current_->deferred_deactivate) { + current_->deferred_deactivate = nullptr; + syncState(); + return; + } + refreshAnnotations(); + } +} + void InteractionService::clearActors() { points_poly_->Initialize(); diff --git a/app/render/InteractionService.h b/app/render/InteractionService.h index 946d3ee69e5985af4e7d269d7d65dc8d7a023a95..39c26641e03fd38ce4ccfb1e669b3e11e21daba2 100644 --- a/app/render/InteractionService.h +++ b/app/render/InteractionService.h @@ -48,6 +48,9 @@ public: void pick(double posx, double posy); //! @brief 悬停:同步激活状态,调用 on_hover 回调做动态预览 void hover(double posx, double posy); + //! @brief 检查是否有 GUI 线程发起的标注变更,有则拉取并刷新 VTK actor + void pollRefresh(); + private: //! @brief 同步激活状态:迁移时执行下线(on_deactivate/清标注/还原吸附)与上线(吸附/on_activate/刷新) systems::interaction::InteractionState* syncState(); diff --git a/app/render/QRenderWindow.cpp b/app/render/QRenderWindow.cpp index d1ca990e483357a689952db6fd7b845596b75348..34255098cbb8e00d43e98500de5e9618a2d1d5ae 100644 --- a/app/render/QRenderWindow.cpp +++ b/app/render/QRenderWindow.cpp @@ -26,7 +26,7 @@ #include namespace { -//! @brief 每帧渲染前把比例尺段的像素长度换算为世界长度写入 Range,使刻度值随相机缩放更新 +//! @brief 每帧按 1-2-5 整数档反算精确段长并居中重设端点,使标尺段长与刻度值随相机缩放联动更新 class ScaleBarRangeUpdater : public vtkCommand { public: static ScaleBarRangeUpdater* New() { return new ScaleBarRangeUpdater; } @@ -40,15 +40,10 @@ public: if (!axis_ || !cam) return; const int* size = renderer_->GetSize(); - if (!size || size[1] <= 0) + if (!size || size[0] <= 0 || size[1] <= 0) return; - // 段像素长度(轴两端点为归一化视口坐标) - const double* v1 = axis_->GetPositionCoordinate()->GetValue(); - const double* v2 = axis_->GetPosition2Coordinate()->GetValue(); - const double pixel_len = std::abs(v2[0] - v1[0]) * size[0]; - - // 解析式求世界长度,避免每帧三次投影/反投影矩阵运算: + // 解析式求世界/像素比,避免每帧三次投影/反投影矩阵运算: // 平行投影窗口世界高度 = 2*parallelScale;透视投影焦平面世界高度 = 2*距离*tan(fov/2) double world_per_pixel; if (cam->GetParallelProjection()) { @@ -57,10 +52,25 @@ public: world_per_pixel = 2.0 * cam->GetDistance() * std::tan(vtkMath::RadiansFromDegrees(cam->GetViewAngle()) / 2.0) / size[1]; } - const double nice = niceNumber(pixel_len * world_per_pixel); + + // 以视口宽度 22% 为参考段长,换算为参考世界长度后取 1-2-5 整数档 + constexpr double kTargetFrac = 0.22; + const double ref_world = kTargetFrac * size[0] * world_per_pixel; + const double nice = niceNumber(ref_world); + + // 按 nice 整数值反算精确像素段长,使标尺始终精确代表 nice 个世界单位(消除固定段长 + // 的量化误差);缩放时段长连续伸缩,刻度视觉反馈平滑。段长变化才重设端点,避免无缩放 + // 时逐帧重建轴几何 + const double needed_frac = nice / world_per_pixel / size[0]; + const double half = needed_frac * 0.5; + if (needed_frac != last_frac_) { + last_frac_ = needed_frac; + axis_->GetPositionCoordinate()->SetValue(0.5 - half, 0.05); + axis_->GetPosition2Coordinate()->SetValue(0.5 + half, 0.05); + } // 刻度值跨档才 SetRange:vtkAxisActor2D 每次 SetRange 都会重建刻度文本, - // 连续缩放时逐帧重建是主要帧率开销 + // 同一档位内段长虽变但 nice 值不变,跳过以避免冗余文本重建 if (nice == last_range_) return; last_range_ = nice; @@ -81,6 +91,7 @@ private: return nice_frac * base; } + double last_frac_ = -1.0; //> 上次写入的段长(归一化视口宽度,初始必触发一次) double last_range_ = -1.0; //> 上次写入的刻度值(初始必触发一次) }; } @@ -124,8 +135,9 @@ QQuickVTKItem::vtkUserData QRenderWindow::initializeVTK(vtkRenderWindow* renderW vtk->overlay_renderer_->SetActiveCamera(vtk->renderer_->GetActiveCamera()); // 共享相机,免逐帧同步 renderWindow->AddRenderer(vtk->overlay_renderer_); - // 比例尺(叠加层底部中央的一段标尺轴):端点视口位置固定,ScaleBarRangeUpdater - // 每帧把段长换算为世界长度写入 Range,刻度值随缩放自动更新 + // 比例尺(叠加层底部中央的一段标尺轴):端点坐标系为归一化视口,初始段长占视口 22%; + // ScaleBarRangeUpdater 每帧按 1-2-5 整数档反算精确段长并居中重设端点,使标尺始终精确 + // 代表显示的整数世界长度,刻度值与段长随缩放联动更新 vtk->scale_bar_axis_->GetPositionCoordinate()->SetCoordinateSystemToNormalizedViewport(); vtk->scale_bar_axis_->GetPositionCoordinate()->SetValue(0.39, 0.05); vtk->scale_bar_axis_->GetPosition2Coordinate()->SetCoordinateSystemToNormalizedViewport(); @@ -161,6 +173,8 @@ QQuickVTKItem::vtkUserData QRenderWindow::initializeVTK(vtkRenderWindow* renderW auto* feature_system = feature_adaptor_ ? feature_adaptor_->featureSystem() : nullptr; return feature_system ? feature_system->activeInteraction() : nullptr; }; + // 注入渲染刷新回调(与 setFeatureAdaptor 互补,确保两者先后顺序均能生效) + injectRenderRefreshCallback(); vtk->orientationWidget->AnimateOff(); vtk->orientationWidget->SetParentRenderer(vtk->renderer_); @@ -512,6 +526,8 @@ void QRenderWindow::clearSelection() void QRenderWindow::setFeatureAdaptor(QObject* adaptor) { feature_adaptor_ = qobject_cast(adaptor); + // 注入渲染刷新回调:功能经 requestRefresh() 触发 VTK 重绘 + injectRenderRefreshCallback(); } void QRenderWindow::setScaleBarVisible(bool on) @@ -618,3 +634,15 @@ void QRenderWindow::cancelAttri() } vtkStandardNewMacro(QRenderWindow::Data); + +void QRenderWindow::injectRenderRefreshCallback() +{ + if (!feature_adaptor_ || !feature_adaptor_->featureSystem() || !interaction_service_) + return; + feature_adaptor_->featureSystem()->setRenderRefreshCallback([this]() { + dispatch_async([this](vtkRenderWindow* rw, vtkUserData) { + interaction_service_->pollRefresh(); // 拉取标注到 VTK actor + rw->Render(); + }); + }); +} diff --git a/app/render/QRenderWindow.h b/app/render/QRenderWindow.h index 1d2a931d884a5c6f8aa8fdf7119ff8d17e3b7070..0396c81f05a8fb8dc82f7d9a523c8f95c21cd493 100644 --- a/app/render/QRenderWindow.h +++ b/app/render/QRenderWindow.h @@ -63,7 +63,7 @@ public: vtkNew plane_widget_; - vtkNew scale_bar_axis_; //> 比例尺标尺轴(叠加层左下角,刻度随相机缩放更新) + vtkNew scale_bar_axis_; //> 比例尺标尺轴(叠加层底部中央,段长与刻度随相机缩放联动更新) vtkNew global_points_; }; @@ -210,5 +210,7 @@ private: QModelQuery* model_query_ {}; void updateGlobalVtkPointsImpl(Data* vtk); + //! @brief 注入渲染刷新回调到 FeatureSystem(initializeVTK 与 setFeatureAdaptor 各调一次,确保初始化顺序无关) + void injectRenderRefreshCallback(); }; #endif // Q_RENDER_WINDOW_H diff --git a/app/render/QRenderWindowStyle.cpp b/app/render/QRenderWindowStyle.cpp index 1693d76896a682855bb85cc3dc9ef1a8a555aa7f..315c335bbadfde663016cf9edcdc0ab3ae730325 100644 --- a/app/render/QRenderWindowStyle.cpp +++ b/app/render/QRenderWindowStyle.cpp @@ -32,6 +32,9 @@ void QRenderWindowStyle::OnLeftButtonUp() else if (select_manager_) select_manager_->select(pos[0], pos[1]); } + // GUI 线程可能已修改标注(如“清除”按钮),趁渲染线程事件时拉取 + if (interaction_service_) + interaction_service_->pollRefresh(); vtkInteractorStyleTrackballCamera::OnLeftButtonUp(); } @@ -45,6 +48,7 @@ void QRenderWindowStyle::OnMouseMove() int pos[2]; this->GetInteractor()->GetEventPosition(pos); interaction_service_->hover(pos[0], pos[1]); + interaction_service_->pollRefresh(); this->GetInteractor()->Render(); } } diff --git a/app/render/test/TestInteractionService.cpp b/app/render/test/TestInteractionService.cpp index 3f86fc962890c2a18842f5317ca6b9513d6fe2b2..3f1de81bf3030593f70181180acd6d779e6b861e 100644 --- a/app/render/test/TestInteractionService.cpp +++ b/app/render/test/TestInteractionService.cpp @@ -154,7 +154,6 @@ int main(int argc, char* argv[]) MeshActorManager mesh_manager(pts.GetPointer()); mesh_manager.bindRender(renderer.GetPointer()); - mesh_manager.loadMesh(0, test_mesh_data, renderer.GetPointer()); GeometryActorManager geometry_manager; geometry_manager.bindRender(renderer.GetPointer()); @@ -165,6 +164,10 @@ int main(int argc, char* argv[]) FakeInteraction fake; InteractionService service(*renderer, *overlay_renderer, mesh_manager.op(), sel_mgr); + // 拾取列表在服务构造时登记观察,网格须在此之后加载才会进入拾取列表 + // (与应用一致:服务于 initializeVTK 创建,模型其后加载) + mesh_manager.loadMesh(0, test_mesh_data, renderer.GetPointer()); + // 交互状态经 provider 提供:开关开启前无激活状态,pick 不转发 bool interaction_enabled = false; service.state_provider = [&]() -> systems::interaction::InteractionState* { diff --git a/core/ArgType.h b/core/ArgType.h index 615a92f081f11cfaae0ee284d6b4ef7c864a6cae..556b01afd7f7c43b3877abe62458a2550debd357 100644 --- a/core/ArgType.h +++ b/core/ArgType.h @@ -77,7 +77,14 @@ struct ArgType::TypeMap { template <> struct ArgType::TypeMap { using type = std::shared_ptr; -}; +}; +/** + * @brief 按钮类型,内容为点击计数器(无值触发器,仅作事件触发载荷,功能约定忽略值只读参数下标) + */ +template <> +struct ArgType::TypeMap { + using type = int; +}; } #endif // !ARG_TYPE_H \ No newline at end of file diff --git a/core/private_ArgTypeEnum.h b/core/private_ArgTypeEnum.h index 567bf7fff626f3c0fafe84422802d38ca44b5f05..80dbee99a0ce075607ac3e5ea25f039bfbefee54 100644 --- a/core/private_ArgTypeEnum.h +++ b/core/private_ArgTypeEnum.h @@ -16,5 +16,6 @@ enum class ArgTypeEnum { // OBJECT, // 对象类型 // ARRAY, // 数组类型 // MAP, // 映射类型 - Selector // 选择器类型 + Selector, // 选择器类型 + Button // 按钮类型(无值触发器:点击即动作,语义由功能自定义;载荷为点击计数器,功能约定忽略值只读下标) }; \ No newline at end of file diff --git a/model/systems/core/InteractionState.h b/model/systems/core/InteractionState.h index 2a1e0f4f11ddab904308fce336c9474da3bba3df..dc514ae082fae149c2210ae9e7d3717772ea6da4 100644 --- a/model/systems/core/InteractionState.h +++ b/model/systems/core/InteractionState.h @@ -21,6 +21,7 @@ namespace systems::interaction { */ struct InteractionState { bool active = false; //> 交互激活态:功能经 setActive 写入(GUI 线程),渲染层读取路由拾取 + bool needs_refresh = false; //> GUI 线程修改标注后置位,渲染层拉取后复位 std::function on_activate; //> 交互会话开始(通常清空功能内部状态) std::function on_deactivate; //> 交互会话结束 //! @brief 左键拾取:返回是否有状态变化(需要刷新标注) @@ -29,6 +30,10 @@ struct InteractionState { std::function on_hover; AnnotationBatch annotations; //> 功能在回调中直接更新,渲染层事件后拉取绘制 + //! @brief GUI 线程延迟清空回调:GUI 线程置位后经 requestRefresh 通知渲染线程执行,避免跨线程数据竞争 + std::function deferred_clear; + //! @brief GUI 线程延迟停用回调:setActive(false) 时置位,渲染线程执行 on_deactivate + 清理 VTK actors + std::function deferred_deactivate; //! @brief 会话结束时的渲染层清理:清空标注(不动订阅,订阅随功能常驻) void clearSession() diff --git a/model/systems/feature/FeatureParams.cpp b/model/systems/feature/FeatureParams.cpp index 12b53eddd368cbd2605cde9b25acbf1d26bb39a2..3cf5b0bea63f1f56b41f881be4391e009d5422e3 100644 --- a/model/systems/feature/FeatureParams.cpp +++ b/model/systems/feature/FeatureParams.cpp @@ -60,6 +60,9 @@ namespace { return core::ArgObject::create(parseComboIndex(type.content)); case ArgTypeEnum::Selector: return core::ArgObject::create({}); + case ArgTypeEnum::Button: + // Button 为无值触发器:默认 0 次点击,载荷仅作触发事件的计数器 + return core::ArgObject::create(0); case ArgTypeEnum::None: default: spdlog::warn("FeatureParams: param '{}' has None type, storing empty value", type.name); diff --git a/model/systems/feature/FeatureSystem.cpp b/model/systems/feature/FeatureSystem.cpp index 8331716d049903c6aeaad5aa664eba7fee97e8f2..3078346624ee35cba36e6b3142a25c46c180c2ff 100644 --- a/model/systems/feature/FeatureSystem.cpp +++ b/model/systems/feature/FeatureSystem.cpp @@ -79,6 +79,11 @@ bool FeatureSystem::registerHandler(const HandlerMetaData& meta_data, SystemHand } } }; + // 注入渲染刷新回调:功能经 requestRefresh() 通知 app 层拉取标注并重绘视口 + entry.interaction_context.render_refresh_ = [this] { + if (render_refresh_callback_) + render_refresh_callback_(); + }; // 激活失败则撤掉整个条目,不留下半注册状态 try { @@ -161,6 +166,24 @@ interaction::InteractionState* FeatureSystem::activeInteraction() return nullptr; } +bool FeatureSystem::setFeatureActive(const std::string& unique_name) +{ + // 空串 = 活动操作无交互能力:全部下线(重复调用由 InteractionContext 的目标状态守卫兜底) + if (unique_name.empty()) { + for (auto&& [name, entry] : entries_) + entry.interaction_context.setActive(false); + return true; + } + + auto it = entries_.find(unique_name); + if (it == entries_.end() || !it->second.info->interactive) { + spdlog::warn("FeatureSystem::setFeatureActive: feature '{}' not found or not interactive", unique_name); + return false; + } + it->second.interaction_context.setActive(true); // 单激活约定:自动下线其他功能 + return true; +} + void FeatureSystem::setOnFeatureInfosChanged(std::function callback) { on_feature_infos_changed_ = std::move(callback); @@ -176,6 +199,11 @@ void FeatureSystem::setActiveComponentProvider(std::function callback) +{ + render_refresh_callback_ = std::move(callback); +} + bool FeatureSystem::dispatchKeyEvent(const KeyEvent& event) { // 原始事件流先广播,观察者总能收到;再做按键绑定路由并返回消费结果 diff --git a/model/systems/feature/FeatureSystem.h b/model/systems/feature/FeatureSystem.h index 5f50e3012fbda2276e5c74d9f598c8a58023849e..74c3186a756d3b073a4e67f2c9a415d6b2580129 100644 --- a/model/systems/feature/FeatureSystem.h +++ b/model/systems/feature/FeatureSystem.h @@ -93,6 +93,15 @@ public: * @return 声明 interactive 且 active 的功能状态;无激活交互时为 nullptr */ interaction::InteractionState* activeInteraction(); + /** + * @brief 启用当前活动功能(声明 interactive 的功能专用,活动操作切换驱动) + * + * 单激活约定:激活一个功能会经 InteractionContext 下线其他功能的交互; + * 启停为幂等的状态应用(InteractionContext 以目标状态为守卫),重复设置无副作用。 + * @param unique_name 要激活的功能唯一名称;空串表示全部下线(活动操作无交互能力) + * @return 名称为空,或功能存在且声明 interactive 时为 true + */ + bool setFeatureActive(const std::string& unique_name); /** * @brief 设置功能信息变更回调函数 */ @@ -103,6 +112,10 @@ public: */ void setActiveModelProvider(std::function()> provider); void setActiveComponentProvider(std::function()> provider); + /** + * @brief 设置视口渲染刷新回调(app 层注入,功能经 InteractionContext::requestRefresh 触发) + */ + void setRenderRefreshCallback(std::function callback); private: struct FeatureEntry { @@ -122,6 +135,7 @@ private: std::function()> active_model_provider_; std::function()> active_component_provider_; std::function on_feature_infos_changed_; + std::function render_refresh_callback_; //> app 层注入:通知渲染窗口拉取标注并重绘 }; } #endif // FEATURE_SYSTEM_H diff --git a/model/systems/feature/InteractionContext.cpp b/model/systems/feature/InteractionContext.cpp index 6a6782ddce3e6e3b9d8c2ca42709fd18228bee9f..1155b83d28f2856a9ca5cfdaf5d28186078b4b22 100644 --- a/model/systems/feature/InteractionContext.cpp +++ b/model/systems/feature/InteractionContext.cpp @@ -36,13 +36,37 @@ systems::interaction::AnnotationBatch& InteractionContext::annotations() return state_->annotations; } +void InteractionContext::requestRefresh() +{ + state_->needs_refresh = true; + if (render_refresh_) + render_refresh_(); +} + +void InteractionContext::deferClear(std::function clear_fn) +{ + state_->deferred_clear = std::move(clear_fn); + requestRefresh(); +} + +void InteractionContext::deferDeactivate() +{ + state_->deferred_deactivate = [] {}; + requestRefresh(); +} + void InteractionContext::setActive(bool on) { + // 幂等守卫:目标状态已达则直接返回,重复启停无副作用 + if (state_->active == on) + return; // 单激活约定:激活自己前先下线其他功能的交互 if (on && deactivate_others_) { deactivate_others_(); } state_->active = on; + if (!on) + deferDeactivate(); } } // namespace systems::feature diff --git a/model/systems/feature/InteractionContext.h b/model/systems/feature/InteractionContext.h index 0527a2185d06ec3d32b8ed330389823707a505f6..0621f8fc7208a5822717b3ee341ec744310594b1 100644 --- a/model/systems/feature/InteractionContext.h +++ b/model/systems/feature/InteractionContext.h @@ -34,11 +34,20 @@ public: //! @brief 标注集:功能在回调中直接更新,渲染层拉取绘制 systems::interaction::AnnotationBatch& annotations(); + //! @brief GUI 线程修改标注后请求渲染刷新(渲染层拉取并复位 needs_refresh) + void requestRefresh(); + //! @brief GUI 线程延迟清空:将清空回调存入 InteractionState,经 requestRefresh 通知渲染线程执行 + void deferClear(std::function clear_fn); + //! @brief GUI 线程延迟停用:将 syncState 回调存入 InteractionState,经 requestRefresh 通知渲染线程执行 + void deferDeactivate(); + //! @brief 设置本功能交互激活态(单激活:激活自己时会先下线其他功能的交互) void setActive(bool on); //! @brief 由 FeatureSystem 装配时注入:激活本功能前下线其他功能的交互(单激活约定) std::function deactivate_others_; + //! @brief 由 FeatureSystem 装配时注入:通知渲染层拉取标注并刷新视口 + std::function render_refresh_; private: systems::interaction::InteractionState* state_; diff --git a/model/systems/feature/test/TestFeatureSystem.cpp b/model/systems/feature/test/TestFeatureSystem.cpp index 7fc29a048ad362318a2c726f2b30cd6de29335cd..436bcd8849145672fb94a8adc12c08d40afd6d7f 100644 --- a/model/systems/feature/test/TestFeatureSystem.cpp +++ b/model/systems/feature/test/TestFeatureSystem.cpp @@ -259,7 +259,7 @@ TEST_CASE("FeatureSystem propagates interactive metadata to FeatureInfo", "[Feat REQUIRE(infos.size() == 1); REQUIRE(infos[0]->interactive); - // 未声明时为空串与 false + // 未声明时为 false FeatureSystem::SystemHandlerPtr plain { new FakeFeatureHandler }; auto plain_meta = makeMetaData(); plain_meta.name = "PlainFeature"; @@ -313,3 +313,68 @@ TEST_CASE("FeatureSystem::activeInteraction tracks interactive activation", "[Fe raw2->context->interaction.setActive(false); REQUIRE(system.activeInteraction() == nullptr); } + +TEST_CASE("FeatureSystem::setFeatureActive drives activation by feature name", "[FeatureSystem]") +{ + core::EventBus bus; + ModelLayer model_layer; + FeatureSystem system(model_layer, bus); + + auto interactive_meta = makeMetaData(); + interactive_meta.name = "InteractiveFeature"; + interactive_meta.interactive = true; + FeatureSystem::SystemHandlerPtr interactive { new FakeFeatureHandler }; + REQUIRE(system.registerHandler(interactive_meta, std::move(interactive))); + + auto plain_meta = makeMetaData(); + plain_meta.name = "PlainFeature"; + FeatureSystem::SystemHandlerPtr plain { new FakeFeatureHandler }; + REQUIRE(system.registerHandler(plain_meta, std::move(plain))); + + // 未注册功能与未声明 interactive 的功能不可激活 + REQUIRE_FALSE(system.setFeatureActive("Unknown")); + REQUIRE_FALSE(system.setFeatureActive("PlainFeature")); + REQUIRE(system.activeInteraction() == nullptr); + + // 按名激活(幂等:重复设置无副作用) + REQUIRE(system.setFeatureActive("InteractiveFeature")); + REQUIRE(system.setFeatureActive("InteractiveFeature")); + auto* active = system.activeInteraction(); + REQUIRE(active != nullptr); + REQUIRE(active->active); + + // 活动操作切到无交互能力的功能:空串全部下线 + REQUIRE(system.setFeatureActive("")); + REQUIRE(system.activeInteraction() == nullptr); +} + +TEST_CASE("InteractionContext::setActive(false) defers deactivation for render-thread cleanup", "[FeatureSystem]") +{ + core::EventBus bus; + ModelLayer model_layer; + FeatureSystem system(model_layer, bus); + + auto meta = makeMetaData(); + meta.name = "DeferTest"; + meta.interactive = true; + auto* raw = new FakeFeatureHandler; + FeatureSystem::SystemHandlerPtr handler { raw }; + REQUIRE(system.registerHandler(meta, std::move(handler))); + + raw->context->interaction.setActive(true); + auto* state = system.activeInteraction(); + REQUIRE(state != nullptr); + + // setActive(false) 不直接触发 on_deactivate,而是存储延迟停用回调 + needs_refresh + raw->context->interaction.setActive(false); + CHECK_FALSE(state->active); + CHECK(state->needs_refresh); + REQUIRE(state->deferred_deactivate); + + // 幂等守卫:重复调用不重复置位 + state->deferred_deactivate = nullptr; + state->needs_refresh = false; + raw->context->interaction.setActive(false); + CHECK_FALSE(state->deferred_deactivate); + CHECK_FALSE(state->needs_refresh); +} diff --git a/plugins/feature/CMakeLists.txt b/plugins/feature/CMakeLists.txt index 5a4028fa90d35c3e66a174333ac76ad9353a43b7..2a45edb99c958b5be403b1a819ed2770adc70849 100644 --- a/plugins/feature/CMakeLists.txt +++ b/plugins/feature/CMakeLists.txt @@ -1 +1,3 @@ add_subdirectory(FeatureDemoPlugin) +add_subdirectory(MeasurePlugin) +add_subdirectory(DimensionPlugin) diff --git a/plugins/feature/DimensionPlugin/CMakeLists.txt b/plugins/feature/DimensionPlugin/CMakeLists.txt new file mode 100644 index 0000000000000000000000000000000000000000..22407200ea55bed6f4aa25493b87b6dfd48112b6 --- /dev/null +++ b/plugins/feature/DimensionPlugin/CMakeLists.txt @@ -0,0 +1,12 @@ +precess_add_feature_plugin(DimensionPlugin + SOURCES "DimensionHandler.cpp" + PLUGIN_H "DimensionPlugin.h" +) + +precess_plugin_link_libraries(DimensionPlugin + TKernel # Standard / gp 基础类型 + TKBRep # BRep_Tool、BRepAdaptor + TKTopAlgo # BRepGProp、BRepBndLib +) + +add_subdirectory(test) diff --git a/plugins/feature/DimensionPlugin/DimensionHandler.cpp b/plugins/feature/DimensionPlugin/DimensionHandler.cpp new file mode 100644 index 0000000000000000000000000000000000000000..07c78a0c6decdf82259ea45cef23743cd5116276 --- /dev/null +++ b/plugins/feature/DimensionPlugin/DimensionHandler.cpp @@ -0,0 +1,962 @@ +/** + * @file DimensionHandler.cpp + * @brief 尺寸标注处理器:网格与几何双路径的距离、角度、半径、长度、面积、体积、包围盒与重心 + * @author 范成通 email 1941804585@qq.com + */ + +#include "DimensionHandler.h" +#include "ArgObject.h" +#include "ComponentData.h" +#include "FeatureContext.h" +#include "FeatureParams.h" +#include "FeatureRegistrar.h" +#include "GeometryData.h" +#include "GeometryRegistry.h" +#include "MeshData.h" +#include "ModelLayer.h" +#include "Selection.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace systems::feature { + +namespace { +using Vec3 = std::array; + +// 参数下标:测量类型 / 选择对象(与 setup() 注册顺序一致) +constexpr std::size_t kTypeParam = 0; +constexpr std::size_t kSelectionParam = 1; + +Vec3 operator-(const Vec3& a, const Vec3& b) +{ + return { a[0] - b[0], a[1] - b[1], a[2] - b[2] }; +} + +Vec3 operator+(const Vec3& a, const Vec3& b) +{ + return { a[0] + b[0], a[1] + b[1], a[2] + b[2] }; +} + +Vec3 operator*(const Vec3& a, double s) +{ + return { a[0] * s, a[1] * s, a[2] * s }; +} + +Vec3 operator/(const Vec3& a, double s) +{ + return { a[0] / s, a[1] / s, a[2] / s }; +} + +//! @brief 两点中点 +Vec3 midpoint(const Vec3& a, const Vec3& b) +{ + return { (a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0, (a[2] + b[2]) / 2.0 }; +} + +double dot(const Vec3& a, const Vec3& b) +{ + return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; +} + +double length(const Vec3& v) +{ + return std::sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]); +} + +Vec3 cross(const Vec3& a, const Vec3& b) +{ + return { + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0] + }; +} + +enum class MeasureType { + Distance, + Angle, + Radius, + Length, + Area, + Volume, + BoundingBox, + Centroid +}; + +//! @brief 网格测量实现签名 +using MeshExecFn = std::string (*)(const MeshData&, const ModelLayer&, const Selection&); +//! @brief 几何测量实现签名 +using GeomExecFn = std::string (*)(const GeometryRegistry&, const Selection&); + +//! @brief 测量操作表项:名称、选择器模式与两套实现的统一注册处(新增类型只需在 kMeasureOps 加一行) +struct MeasureOp { + MeasureType type; + const char* name; //> Combo 显示名(表内顺序即下标) + const char* mesh_selector_modes; //> 网格组件的选择器模式 + const char* geom_selector_modes; //> 几何组件的选择器模式 + MeshExecFn mesh_exec; //> 网格实现 + GeomExecFn geom_exec; //> 几何实现(nullptr 表示暂不支持几何模型) +}; + +bool isValidVertexId(const MeshData& mesh, const ModelLayer& manager, Index id) +{ + if (id < 0) + return false; + if (mesh.local_to_global_.empty()) + return id < static_cast(mesh.vertex_positions_.size()); + return id < static_cast(manager.globalPoints().size()); +} + +const Vec3& getPosition(const MeshData& mesh, const ModelLayer& manager, Index id) +{ + if (!mesh.local_to_global_.empty() && !manager.globalPoints().empty()) + return manager.globalPoints()[id]; + return mesh.vertex_positions_[id]; +} + +std::string toString(double value, int precision = 6) +{ + std::ostringstream oss; + oss.setf(std::ios::fixed, std::ios::floatfield); + oss.precision(precision); + oss << value; + return oss.str(); +} + +std::string vecString(const Vec3& v) +{ + return "(" + toString(v[0]) + ", " + toString(v[1]) + ", " + toString(v[2]) + ")"; +} + +double angleBetween(const Vec3& u, const Vec3& v) +{ + const double lu = length(u); + const double lv = length(v); + if (lu < std::numeric_limits::epsilon() || lv < std::numeric_limits::epsilon()) + return 0.0; + + double cos_theta = dot(u, v) / (lu * lv); + cos_theta = std::max(-1.0, std::min(1.0, cos_theta)); + return std::acos(cos_theta) * 180.0 / 3.14159265358979323846; +} + +double triangleArea(const Vec3& a, const Vec3& b, const Vec3& c) +{ + return 0.5 * length(cross(b - a, c - a)); +} + +double polygonArea(const std::vector& points) +{ + const size_t n = points.size(); + if (n < 3) + return 0.0; + + double area = 0.0; + for (size_t i = 2; i < n; ++i) + area += triangleArea(points[0], points[i - 1], points[i]); + return area; +} + +double tetraVolume(const Vec3& a, const Vec3& b, const Vec3& c, const Vec3& d) +{ + return std::abs(dot(b - a, cross(c - a, d - a))) / 6.0; +} + +double polyhedronVolume(const MeshData& mesh, const ModelLayer& manager, Index solid_id) +{ + if (solid_id + 1 >= static_cast(mesh.solid_faces_offset_.size())) + return 0.0; + + const Index face_start = mesh.solid_faces_offset_[solid_id]; + const Index face_end = mesh.solid_faces_offset_[solid_id + 1]; + double volume = 0.0; + + for (Index f = face_start; f < face_end; ++f) { + const Index face_id = mesh.solid_faces_[f]; + if (face_id + 1 >= static_cast(mesh.solid_faces_vertices_offset_.size())) + continue; + + const Index vert_start = mesh.solid_faces_vertices_offset_[face_id]; + const Index vert_end = mesh.solid_faces_vertices_offset_[face_id + 1]; + if (vert_end - vert_start < 3) + continue; + + const Vec3& v0 = getPosition(mesh, manager, mesh.solid_faces_vertices_[vert_start]); + for (Index i = vert_start + 1; i + 1 < vert_end; ++i) { + const Vec3& vi = getPosition(mesh, manager, mesh.solid_faces_vertices_[i]); + const Vec3& vj = getPosition(mesh, manager, mesh.solid_faces_vertices_[i + 1]); + volume += dot(v0, cross(vi, vj)) / 6.0; + } + } + + return std::abs(volume); +} + +double solidVolume(const MeshData& mesh, const ModelLayer& manager, Index solid_id) +{ + if (solid_id < 0 || solid_id + 1 >= static_cast(mesh.solid_vertices_offset_.size())) + return 0.0; + if (solid_id >= static_cast(mesh.solid_types_.size())) + return 0.0; + + const unsigned char cell_type = mesh.solid_types_[solid_id]; + const Index start = mesh.solid_vertices_offset_[solid_id]; + const Index end = mesh.solid_vertices_offset_[solid_id + 1]; + + // VTK_TETRA = 10 + if (cell_type == 10) { + if (end - start != 4) + return 0.0; + const Vec3& a = getPosition(mesh, manager, mesh.solid_vertices_[start]); + const Vec3& b = getPosition(mesh, manager, mesh.solid_vertices_[start + 1]); + const Vec3& c = getPosition(mesh, manager, mesh.solid_vertices_[start + 2]); + const Vec3& d = getPosition(mesh, manager, mesh.solid_vertices_[start + 3]); + return tetraVolume(a, b, c, d); + } + + // VTK_POLYHEDRON = 42 + if (cell_type == 42) + return polyhedronVolume(mesh, manager, solid_id); + + return 0.0; +} + +std::vector collectPositions(const MeshData& mesh, const ModelLayer& manager, + ElementEnum::Type type, const std::vector& ids) +{ + std::vector positions; + positions.reserve(ids.size() * 4); + + switch (type) { + case ElementEnum::Vertex: + case ElementEnum::Edge: + // 边选择的 ids 即每条边端点的顶点 id(EdgeSelectorHighlight 约定),与点一样按顶点处理 + for (Index id : ids) { + if (isValidVertexId(mesh, manager, id)) + positions.push_back(getPosition(mesh, manager, id)); + } + break; + case ElementEnum::Face: + for (Index id : ids) { + if (id + 1 >= static_cast(mesh.face_vertices_offset_.size())) + continue; + for (Index i = mesh.face_vertices_offset_[id]; i < mesh.face_vertices_offset_[id + 1]; ++i) { + const Index v = mesh.face_vertices_[i]; + if (isValidVertexId(mesh, manager, v)) + positions.push_back(getPosition(mesh, manager, v)); + } + } + break; + case ElementEnum::Solid: + for (Index id : ids) { + if (id + 1 >= static_cast(mesh.solid_vertices_offset_.size())) + continue; + for (Index i = mesh.solid_vertices_offset_[id]; i < mesh.solid_vertices_offset_[id + 1]; ++i) { + const Index v = mesh.solid_vertices_[i]; + if (isValidVertexId(mesh, manager, v)) + positions.push_back(getPosition(mesh, manager, v)); + } + } + break; + default: + break; + } + + return positions; +} + +std::string formatDistance(const Vec3& a, const Vec3& b) +{ + const Vec3 d = b - a; + std::ostringstream oss; + oss << "Distance: " << toString(length(d)) << "\n" + << "Dx: " << toString(d[0]) << "\n" + << "Dy: " << toString(d[1]) << "\n" + << "Dz: " << toString(d[2]); + return oss.str(); +} + +std::string formatAngle(const Vec3& a, const Vec3& b, const Vec3& c) +{ + std::ostringstream oss; + oss << "Angle: " << toString(angleBetween(a - b, c - b)) << " deg"; + return oss.str(); +} + +std::string formatRadius(const Vec3& a, const Vec3& b, const Vec3& c) +{ + const Vec3 ab = b - a; + const Vec3 ac = c - a; + const Vec3 n = cross(ab, ac); + const double n_len2 = dot(n, n); + + if (n_len2 < std::numeric_limits::epsilon()) + return "错误:三点共线或重合,无法计算半径"; + + const Vec3 circumcenter = a + (cross(ac, n) * dot(ab, ab) - cross(ab, n) * dot(ac, ac)) / (2.0 * n_len2); + const double radius = length(circumcenter - a); + + std::ostringstream oss; + oss << "Radius: " << toString(radius) << "\n" + << "Center: " << vecString(circumcenter); + return oss.str(); +} + +std::string formatEdgeLength(const MeshData& mesh, const ModelLayer& manager, const std::vector& ids) +{ + double total = 0.0; + std::ostringstream oss; + + // 边选择的 ids 是每条边两个端点的顶点 id 对,逐对计算长度 + for (size_t i = 0; i + 1 < ids.size(); i += 2) { + const Index v0 = ids[i]; + const Index v1 = ids[i + 1]; + const size_t edge_no = i / 2; + if (!isValidVertexId(mesh, manager, v0) || !isValidVertexId(mesh, manager, v1)) { + oss << "边 " << edge_no << ": 无效顶点\n"; + continue; + } + + const double len = length(getPosition(mesh, manager, v1) - getPosition(mesh, manager, v0)); + total += len; + oss << "边 " << edge_no << ": " << toString(len) << "\n"; + } + + oss << "累计长度: " << toString(total); + return oss.str(); +} + +std::string formatFaceArea(const MeshData& mesh, const ModelLayer& manager, const std::vector& ids) +{ + double total = 0.0; + std::ostringstream oss; + + for (Index id : ids) { + if (id + 1 >= static_cast(mesh.face_vertices_offset_.size())) { + oss << "面 " << id << ": 无效索引\n"; + continue; + } + + std::vector points; + for (Index i = mesh.face_vertices_offset_[id]; i < mesh.face_vertices_offset_[id + 1]; ++i) + points.push_back(getPosition(mesh, manager, mesh.face_vertices_[i])); + + const double area = polygonArea(points); + total += area; + oss << "面 " << id << ": " << toString(area) << "\n"; + } + + oss << "总面积: " << toString(total); + return oss.str(); +} + +std::string formatSolidVolume(const MeshData& mesh, const ModelLayer& manager, const std::vector& ids) +{ + double total = 0.0; + std::ostringstream oss; + + for (Index id : ids) { + const double volume = solidVolume(mesh, manager, id); + if (volume <= 0.0) { + oss << "体 " << id << ": 不支持的体类型或无体积\n"; + continue; + } + total += volume; + oss << "体 " << id << ": " << toString(volume) << "\n"; + } + + oss << "总体积: " << toString(total); + return oss.str(); +} + +std::string formatBoundingBox(const std::vector& positions) +{ + // 防御性判空:防止被其他路径以空容器调用时越界访问首元素 + if (positions.empty()) { + spdlog::error("formatBoundingBox: positions 为空"); + return std::string("错误:没有可用的几何数据"); + } + + Vec3 min = positions[0]; + Vec3 max = positions[0]; + + for (const auto& p : positions) { + for (int i = 0; i < 3; ++i) { + min[i] = std::min(min[i], p[i]); + max[i] = std::max(max[i], p[i]); + } + } + + const Vec3 size = max - min; + + std::ostringstream oss; + oss << "包围盒:\n" + << "min: " << vecString(min) << "\n" + << "max: " << vecString(max) << "\n" + << "size: " << vecString(size); + return oss.str(); +} + +std::string formatCentroid(const std::vector& positions) +{ + // 防御性判空:防止空容器时除零产生 NaN + if (positions.empty()) { + spdlog::error("formatCentroid: positions 为空"); + return std::string("错误:没有可用的几何数据"); + } + + Vec3 sum = { 0.0, 0.0, 0.0 }; + for (const auto& p : positions) + sum = sum + p; + const Vec3 centroid = sum / static_cast(positions.size()); + + std::ostringstream oss; + oss << "重心: " << vecString(centroid); + return oss.str(); +} + +// ---------------- 几何(OCC)测量:选择 id 为 GeometryRegistry 分配的全局几何 id ---------------- + +Vec3 toVec3(const gp_Pnt& p) +{ + return { p.X(), p.Y(), p.Z() }; +} + +//! @brief 按选择类型从几何注册表查找子形状,找不到返回 nullptr +const TopoDS_Shape* findGeometryShape(const GeometryRegistry& reg, ElementEnum::Type type, Index id) +{ + switch (type) { + case ElementEnum::GeometryVertex: + return reg.getVertex(id); + case ElementEnum::GeometryEdge: + return reg.getEdge(id); + case ElementEnum::GeometryFace: + return reg.getFace(id); + case ElementEnum::GeometrySolid: + return reg.getSolid(id); + default: + return nullptr; + } +} + +//! @brief 收集选择中有效的几何子形状 +std::vector collectGeometryShapes(const GeometryRegistry& reg, + ElementEnum::Type type, const std::vector& ids) +{ + std::vector shapes; + shapes.reserve(ids.size()); + for (Index id : ids) { + if (const TopoDS_Shape* s = findGeometryShape(reg, type, id)) + shapes.push_back(*s); + } + return shapes; +} + +//! @brief 取几何点坐标 +bool geometryVertexPoint(const GeometryRegistry& reg, Index id, Vec3& out) +{ + const TopoDS_Shape* s = reg.getVertex(id); + if (!s) + return false; + out = toVec3(BRep_Tool::Pnt(TopoDS::Vertex(*s))); + return true; +} + +//! @brief 取几何边中点处的切向作为边方向 +bool geometryEdgeDirection(const TopoDS_Edge& edge, Vec3& out) +{ + BRepAdaptor_Curve curve(edge); + const double u = 0.5 * (curve.FirstParameter() + curve.LastParameter()); + gp_Pnt p; + gp_Vec v; + curve.D1(u, p, v); + if (v.Magnitude() < std::numeric_limits::epsilon()) + return false; + v.Normalize(); + out = { v.X(), v.Y(), v.Z() }; + return true; +} + +std::string formatGeometryEdgeLength(const GeometryRegistry& reg, const std::vector& ids) +{ + double total = 0.0; + std::ostringstream oss; + + for (Index id : ids) { + const TopoDS_Shape* s = reg.getEdge(id); + if (!s) { + oss << "边 " << id << ": 无效索引\n"; + continue; + } + GProp_GProps props; + BRepGProp::LinearProperties(*s, props); + const double len = props.Mass(); + total += len; + oss << "边 " << id << ": " << toString(len) << "\n"; + } + + oss << "累计长度: " << toString(total); + return oss.str(); +} + +std::string formatGeometryFaceArea(const GeometryRegistry& reg, const std::vector& ids) +{ + double total = 0.0; + std::ostringstream oss; + + for (Index id : ids) { + const TopoDS_Shape* s = reg.getFace(id); + if (!s) { + oss << "面 " << id << ": 无效索引\n"; + continue; + } + GProp_GProps props; + BRepGProp::SurfaceProperties(*s, props); + const double area = std::abs(props.Mass()); + total += area; + oss << "面 " << id << ": " << toString(area) << "\n"; + } + + oss << "总面积: " << toString(total); + return oss.str(); +} + +std::string formatGeometrySolidVolume(const GeometryRegistry& reg, const std::vector& ids) +{ + double total = 0.0; + std::ostringstream oss; + + for (Index id : ids) { + const TopoDS_Shape* s = reg.getSolid(id); + if (!s) { + oss << "体 " << id << ": 无效索引\n"; + continue; + } + GProp_GProps props; + BRepGProp::VolumeProperties(*s, props); + const double volume = std::abs(props.Mass()); + total += volume; + oss << "体 " << id << ": " << toString(volume) << "\n"; + } + + oss << "总体积: " << toString(total); + return oss.str(); +} + +std::string formatGeometryBoundingBox(const std::vector& shapes) +{ + Bnd_Box box; + for (const auto& s : shapes) + BRepBndLib::AddOptimal(s, box, false); // 不依赖三角剖分,直接按解析几何求界 + + if (box.IsVoid()) + return std::string("错误:无法计算包围盒"); + + Standard_Real xmin = 0.0, ymin = 0.0, zmin = 0.0, xmax = 0.0, ymax = 0.0, zmax = 0.0; + box.Get(xmin, ymin, zmin, xmax, ymax, zmax); + const Vec3 min { xmin, ymin, zmin }; + const Vec3 max { xmax, ymax, zmax }; + const Vec3 size = max - min; + + std::ostringstream oss; + oss << "包围盒:\n" + << "min: " << vecString(min) << "\n" + << "max: " << vecString(max) << "\n" + << "size: " << vecString(size); + return oss.str(); +} + +//! @brief 按边长 / 面积 / 体积加权的重心 +std::string formatGeometryCentroid(const GeometryRegistry& reg, + ElementEnum::Type type, const std::vector& ids) +{ + double total_mass = 0.0; + Vec3 sum { 0.0, 0.0, 0.0 }; + + for (Index id : ids) { + const TopoDS_Shape* s = findGeometryShape(reg, type, id); + if (!s) + continue; + + GProp_GProps props; + switch (type) { + case ElementEnum::GeometryEdge: + BRepGProp::LinearProperties(*s, props); + break; + case ElementEnum::GeometryFace: + BRepGProp::SurfaceProperties(*s, props); + break; + case ElementEnum::GeometrySolid: + BRepGProp::VolumeProperties(*s, props); + break; + default: + continue; + } + + const double mass = std::abs(props.Mass()); + if (mass < std::numeric_limits::epsilon()) + continue; + sum = sum + toVec3(props.CentreOfMass()) * mass; + total_mass += mass; + } + + if (total_mass < std::numeric_limits::epsilon()) + return std::string("错误:未找到可计算重心的几何对象"); + + std::ostringstream oss; + oss << "重心: " << vecString(sum / total_mass); + return oss.str(); +} + +// ---------------- 几何(OCC)测量实现:选择 id 为 GeometryRegistry 分配的全局几何 id ---------------- + +std::string geomDistance(const GeometryRegistry& reg, const Selection& selection) +{ + if (selection.type != ElementEnum::GeometryVertex || selection.ids.size() != 2) { + return std::string("错误:距离测量需要选择两个几何点"); + } + Vec3 a, b; + if (!geometryVertexPoint(reg, selection.ids[0], a) || !geometryVertexPoint(reg, selection.ids[1], b)) + return std::string("错误:无效的几何点 id"); + return formatDistance(a, b); +} + +std::string geomAngle(const GeometryRegistry& reg, const Selection& selection) +{ + if (selection.type == ElementEnum::GeometryVertex && selection.ids.size() == 3) { + Vec3 a, b, c; + if (!geometryVertexPoint(reg, selection.ids[0], a) + || !geometryVertexPoint(reg, selection.ids[1], b) + || !geometryVertexPoint(reg, selection.ids[2], c)) + return std::string("错误:无效的几何点 id"); + return formatAngle(a, b, c); + } + if (selection.type == ElementEnum::GeometryEdge && selection.ids.size() == 2) { + const TopoDS_Shape* e0 = reg.getEdge(selection.ids[0]); + const TopoDS_Shape* e1 = reg.getEdge(selection.ids[1]); + Vec3 u, v; + if (!e0 || !e1 + || !geometryEdgeDirection(TopoDS::Edge(*e0), u) + || !geometryEdgeDirection(TopoDS::Edge(*e1), v)) + return std::string("错误:无效的几何边 id 或边方向无法计算"); + std::ostringstream oss; + oss << "Angle: " << toString(angleBetween(u, v)) << " deg"; + return oss.str(); + } + return std::string("错误:角度测量需要选择三个几何点或两条几何边"); +} + +std::string geomRadius(const GeometryRegistry& reg, const Selection& selection) +{ + if (selection.type == ElementEnum::GeometryVertex && selection.ids.size() == 3) { + Vec3 a, b, c; + if (!geometryVertexPoint(reg, selection.ids[0], a) + || !geometryVertexPoint(reg, selection.ids[1], b) + || !geometryVertexPoint(reg, selection.ids[2], c)) + return std::string("错误:无效的几何点 id"); + return formatRadius(a, b, c); + } + if (selection.type == ElementEnum::GeometryEdge && selection.ids.size() == 1) { + const TopoDS_Shape* s = reg.getEdge(selection.ids[0]); + if (!s) + return std::string("错误:无效的几何边 id"); + BRepAdaptor_Curve curve(TopoDS::Edge(*s)); + if (curve.GetType() != GeomAbs_Circle) + return std::string("错误:所选边不是圆弧,无法直接测半径(可改选三个几何点)"); + const gp_Circ circ = curve.Circle(); + std::ostringstream oss; + oss << "Radius: " << toString(circ.Radius()) << "\n" + << "Center: " << vecString(toVec3(circ.Location())); + return oss.str(); + } + return std::string("错误:半径测量需要选择三个几何点或一条圆弧边"); +} + +std::string geomLength(const GeometryRegistry& reg, const Selection& selection) +{ + if (selection.type != ElementEnum::GeometryEdge || selection.ids.empty()) { + return std::string("错误:长度测量需要选择几何边"); + } + return formatGeometryEdgeLength(reg, selection.ids); +} + +std::string geomArea(const GeometryRegistry& reg, const Selection& selection) +{ + if (selection.type != ElementEnum::GeometryFace || selection.ids.empty()) { + return std::string("错误:面积测量需要选择几何面"); + } + return formatGeometryFaceArea(reg, selection.ids); +} + +std::string geomVolume(const GeometryRegistry& reg, const Selection& selection) +{ + if (selection.type != ElementEnum::GeometrySolid || selection.ids.empty()) { + return std::string("错误:体积测量需要选择几何体"); + } + return formatGeometrySolidVolume(reg, selection.ids); +} + +std::string geomBoundingBox(const GeometryRegistry& reg, const Selection& selection) +{ + const auto shapes = collectGeometryShapes(reg, selection.type, selection.ids); + if (shapes.empty()) { + return std::string("错误:未找到有效的几何对象"); + } + return formatGeometryBoundingBox(shapes); +} + +std::string geomCentroid(const GeometryRegistry& reg, const Selection& selection) +{ + if (selection.type == ElementEnum::GeometryVertex) { + std::vector points; + points.reserve(selection.ids.size()); + for (Index id : selection.ids) { + Vec3 p; + if (geometryVertexPoint(reg, id, p)) + points.push_back(p); + } + if (points.empty()) + return std::string("错误:未找到有效的几何点"); + return formatCentroid(points); + } + if (selection.ids.empty()) { + return std::string("错误:未找到有效的几何对象"); + } + return formatGeometryCentroid(reg, selection.type, selection.ids); +} + +// ---------------- 网格测量实现 ---------------- + +std::string meshDistance(const MeshData& mesh, const ModelLayer& manager, const Selection& selection) +{ + if (selection.type != ElementEnum::Vertex || selection.ids.size() != 2) { + spdlog::error("DimensionHandler::execute: 距离测量需要选择两个点"); + return std::string("错误:距离测量需要选择两个点"); + } + const Vec3& a = getPosition(mesh, manager, selection.ids[0]); + const Vec3& b = getPosition(mesh, manager, selection.ids[1]); + return formatDistance(a, b); +} + +std::string meshAngle(const MeshData& mesh, const ModelLayer& manager, const Selection& selection) +{ + if (selection.type == ElementEnum::Vertex && selection.ids.size() == 3) { + const Vec3& a = getPosition(mesh, manager, selection.ids[0]); + const Vec3& b = getPosition(mesh, manager, selection.ids[1]); + const Vec3& c = getPosition(mesh, manager, selection.ids[2]); + return formatAngle(a, b, c); + } + if (selection.type == ElementEnum::Edge && selection.ids.size() == 4) { + // 边选择的 ids 是每条边两个端点的顶点 id(EdgeSelectorHighlight 约定) + const auto& ids = selection.ids; + const bool valid = isValidVertexId(mesh, manager, ids[0]) && isValidVertexId(mesh, manager, ids[1]) + && isValidVertexId(mesh, manager, ids[2]) && isValidVertexId(mesh, manager, ids[3]); + if (valid) { + const Vec3 u = getPosition(mesh, manager, ids[1]) - getPosition(mesh, manager, ids[0]); + const Vec3 v = getPosition(mesh, manager, ids[3]) - getPosition(mesh, manager, ids[2]); + std::ostringstream oss; + oss << "Angle: " << toString(angleBetween(u, v)) << " deg"; + return oss.str(); + } + } + spdlog::error("DimensionHandler::execute: 角度测量需要选择三个点或两条边"); + return std::string("错误:角度测量需要选择三个点或两条边"); +} + +std::string meshRadius(const MeshData& mesh, const ModelLayer& manager, const Selection& selection) +{ + if (selection.type != ElementEnum::Vertex || selection.ids.size() != 3) { + spdlog::error("DimensionHandler::execute: 半径测量需要选择三个点"); + return std::string("错误:半径测量需要选择三个点"); + } + const Vec3& a = getPosition(mesh, manager, selection.ids[0]); + const Vec3& b = getPosition(mesh, manager, selection.ids[1]); + const Vec3& c = getPosition(mesh, manager, selection.ids[2]); + return formatRadius(a, b, c); +} + +std::string meshLength(const MeshData& mesh, const ModelLayer& manager, const Selection& selection) +{ + // 边选择的 ids 是每条边两个端点的顶点 id 对,数量应为正偶数 + if (selection.type != ElementEnum::Edge || selection.ids.empty() || selection.ids.size() % 2 != 0) { + spdlog::error("DimensionHandler::execute: 长度测量需要选择边"); + return std::string("错误:长度测量需要选择边"); + } + return formatEdgeLength(mesh, manager, selection.ids); +} + +std::string meshArea(const MeshData& mesh, const ModelLayer& manager, const Selection& selection) +{ + if (selection.type != ElementEnum::Face) { + spdlog::error("DimensionHandler::execute: 面积测量需要选择面"); + return std::string("错误:面积测量需要选择面"); + } + return formatFaceArea(mesh, manager, selection.ids); +} + +std::string meshVolume(const MeshData& mesh, const ModelLayer& manager, const Selection& selection) +{ + if (selection.type != ElementEnum::Solid) { + spdlog::error("DimensionHandler::execute: 体积测量需要选择体"); + return std::string("错误:体积测量需要选择体"); + } + return formatSolidVolume(mesh, manager, selection.ids); +} + +std::string meshBoundingBox(const MeshData& mesh, const ModelLayer& manager, const Selection& selection) +{ + const auto positions = collectPositions(mesh, manager, selection.type, selection.ids); + if (positions.empty()) { + spdlog::error("DimensionHandler::execute: 未找到可用顶点"); + return std::string("错误:未找到可用顶点"); + } + return formatBoundingBox(positions); +} + +std::string meshCentroid(const MeshData& mesh, const ModelLayer& manager, const Selection& selection) +{ + const auto positions = collectPositions(mesh, manager, selection.type, selection.ids); + if (positions.empty()) { + spdlog::error("DimensionHandler::execute: 未找到可用顶点"); + return std::string("错误:未找到可用顶点"); + } + return formatCentroid(positions); +} + +//! @brief 测量操作注册表:新增测量类型只需在此加一行,args_type/校验/分发全部由表生成 +const MeasureOp kMeasureOps[] = { + { MeasureType::Distance, "距离", "Vertex", "GeometryVertex", meshDistance, geomDistance }, + { MeasureType::Angle, "角度", "Vertex,Edge", "GeometryVertex,GeometryEdge", meshAngle, geomAngle }, + { MeasureType::Radius, "半径", "Vertex", "GeometryVertex,GeometryEdge", meshRadius, geomRadius }, + { MeasureType::Length, "长度", "Edge", "GeometryEdge", meshLength, geomLength }, + { MeasureType::Area, "面积", "Face", "GeometryFace", meshArea, geomArea }, + { MeasureType::Volume, "体积", "Solid", "GeometrySolid", meshVolume, geomVolume }, + { MeasureType::BoundingBox, "包围盒", "Vertex,Edge,Face,Solid", "GeometryVertex,GeometryEdge,GeometryFace,GeometrySolid", meshBoundingBox, geomBoundingBox }, + { MeasureType::Centroid, "重心", "Vertex,Edge,Face,Solid", "GeometryVertex,GeometryEdge,GeometryFace,GeometrySolid", meshCentroid, geomCentroid }, +}; + +//! @brief 按下标查注册表,越界返回 nullptr +const MeasureOp* findMeasureOp(int index) +{ + if (index < 0 || index >= static_cast(sizeof(kMeasureOps) / sizeof(kMeasureOps[0]))) + return nullptr; + return &kMeasureOps[index]; +} + +//! @brief 由注册表生成 Combo 内容(名称与下标天然一致) +std::string comboContent() +{ + std::string out; + for (const MeasureOp& op : kMeasureOps) { + if (!out.empty()) + out += ","; + out += op.name; + } + return out; +} + +//! @brief 由注册表生成选择器内容:网格模式按 Vertex,Edge,Face,Solid 规范序取并集 +std::string selectorContent() +{ + static const char* canonical[] = { "Vertex", "Edge", "Face", "Solid" }; + std::string out; + for (const char* mode : canonical) { + bool used = false; + for (const MeasureOp& op : kMeasureOps) { + if (std::string(op.mesh_selector_modes).find(mode) != std::string::npos) { + used = true; + break; + } + } + if (used) { + if (!out.empty()) + out += ","; + out += mode; + } + } + return out; +} +} + +void DimensionHandler::setup(FeatureRegistrar& reg) +{ + // Combo 内容全部由测量操作注册表生成,名称与下标天然一致 + reg.addParameter({ ArgTypeEnum::Combo, "测量类型", comboContent() + "|0" }); + reg.addParameter({ ArgTypeEnum::Selector, "选择对象", selectorContent() }); + reg.addMenuItem({ "工具", "尺寸标注" }); +} + +std::any DimensionHandler::execute(FeatureContext& ctx) +{ + const int* type_index = ctx.params.value(kTypeParam).get(); + if (!type_index) { + spdlog::error("DimensionHandler::execute: 测量类型参数无效"); + return std::string("错误:测量类型参数无效"); + } + + const auto selection_ptr = ctx.params.value(kSelectionParam).get(); + if (!selection_ptr || !*selection_ptr) { + spdlog::error("DimensionHandler::execute: 选择对象参数无效"); + return std::string("错误:选择对象参数无效"); + } + + const auto& selection = **selection_ptr; + + // 选择集未带组件 id 时回退当前活动组件 + Index selected_component_id = selection.component_id; + if (selected_component_id < 0) { + const auto active_component = ctx.activeComponent ? ctx.activeComponent() : std::nullopt; + if (active_component) { + selected_component_id = *active_component; + } + } + ComponentData* comp = ctx.model.findComponent(selected_component_id); + if (!comp) { + spdlog::error("DimensionHandler::execute: 找不到选择对象所在的组件"); + return std::string("错误:找不到选择对象所在的组件"); + } + ModelLayer& manager = ctx.model; + const MeasureOp* op = findMeasureOp(*type_index); + if (!op) { + spdlog::error("DimensionHandler::execute: 未知测量类型下标 {}", *type_index); + return std::string("错误:未知测量类型"); + } + + MeshData* mesh = comp->asMeshData(); + if (!mesh) { + // 无网格但有几何(STEP/IGES):走 OCC 几何测量 + if (comp->geometry) { + comp->geometry->ensureIndexBuilt(manager.geomRegistry()); + if (!op->geom_exec) + return std::string("错误:") + op->name + "测量暂不支持几何模型"; + return op->geom_exec(manager.geomRegistry(), selection); + } + spdlog::error("DimensionHandler::execute: 选择对象所在组件没有网格或几何数据"); + return std::string("错误:选择对象所在组件没有网格或几何数据"); + } + + return op->mesh_exec(*mesh, manager, selection); +} +} // namespace systems::feature diff --git a/plugins/feature/DimensionPlugin/DimensionHandler.h b/plugins/feature/DimensionPlugin/DimensionHandler.h new file mode 100644 index 0000000000000000000000000000000000000000..1cf933297622696f4f8b46f88eab61be9836f4d0 --- /dev/null +++ b/plugins/feature/DimensionPlugin/DimensionHandler.h @@ -0,0 +1,27 @@ +/** + * @file DimensionHandler.h + * @brief 尺寸标注处理器声明:距离、角度、半径、长度、面积、体积、包围盒与重心 + * @author 范成通 email 1941804585@qq.com + */ +#pragma once +#include "FeatureHandler.h" + +namespace systems::feature { + +/** + * @brief 尺寸标注处理器:功能参数(测量类型 + 选择对象)+ 菜单执行 → 返回结果文本 + * + * 纯参数执行功能:setup()/execute() 均在 GUI 线程调用,execute 无状态, + * 与视口交互测量(MeasurePlugin)相互独立。 + */ +class DimensionHandler : public FeatureHandler { +public: + DimensionHandler() = default; + ~DimensionHandler() override = default; + + //! @brief 声明功能参数(测量类型、选择对象)与菜单项 + void setup(FeatureRegistrar& reg) override; + //! @brief 尺寸标注:按参数中的测量类型与选择对象执行,返回结果文本 + std::any execute(FeatureContext& ctx) override; +}; +} diff --git a/plugins/feature/DimensionPlugin/DimensionPlugin.h b/plugins/feature/DimensionPlugin/DimensionPlugin.h new file mode 100644 index 0000000000000000000000000000000000000000..a91308686ee92ecaffbe56741609d087f71c2e76 --- /dev/null +++ b/plugins/feature/DimensionPlugin/DimensionPlugin.h @@ -0,0 +1,24 @@ +/** + * @file DimensionPlugin.h + * @brief 尺寸标注插件声明:将 DimensionHandler 注册到功能系统 + * @author 范成通 email 1941804585@qq.com + */ +#pragma once +#include "DimensionHandler.h" +#include "HandlerCreatorDestroyerFactory.h" +#include "PluginBase.h" + +#include + +namespace systems::feature { +class DimensionPlugin : public QObject, public PluginBase { + Q_OBJECT + Q_INTERFACES(systems::PluginBase) + Q_PLUGIN_METADATA(IID "com.PreCess.systems.feature.DimensionPlugin/1.0" FILE "DimensionPlugin.json") +private: + const HandlerCreatorDestroyer& getHandlerCreatorDestroyer() noexcept override final + { + return HandlerCreatorDestroyerFactory::get(); + } +}; +} diff --git a/plugins/feature/DimensionPlugin/DimensionPlugin.json b/plugins/feature/DimensionPlugin/DimensionPlugin.json new file mode 100644 index 0000000000000000000000000000000000000000..9eadbd4743ebe2411b569cc0f28129c6850f535c --- /dev/null +++ b/plugins/feature/DimensionPlugin/DimensionPlugin.json @@ -0,0 +1,8 @@ +{ + "system": "FeatureSystem", + "handler": { + "name": "DimensionPlugin", + "display_name": "尺寸标注", + "description": "参数化尺寸标注:按测量类型(距离/角度/半径/长度/面积/体积/包围盒/重心)与选择对象执行并返回结果文本" + } +} diff --git a/plugins/feature/DimensionPlugin/test/CMakeLists.txt b/plugins/feature/DimensionPlugin/test/CMakeLists.txt new file mode 100644 index 0000000000000000000000000000000000000000..b16907ee6c20c67effc8a4eeaa6492a91eb25e09 --- /dev/null +++ b/plugins/feature/DimensionPlugin/test/CMakeLists.txt @@ -0,0 +1,2 @@ +precess_add_test(TestDimensionHandler TestDimensionHandler.cpp) +precess_test_link_libraries(TestDimensionHandler DimensionPluginlib DataTest TKPrim TKBRep TKTopAlgo TKernel) diff --git a/plugins/feature/DimensionPlugin/test/TestDimensionHandler.cpp b/plugins/feature/DimensionPlugin/test/TestDimensionHandler.cpp new file mode 100644 index 0000000000000000000000000000000000000000..6d9852039574df7810ac0e66c09f94e72f9c92bd --- /dev/null +++ b/plugins/feature/DimensionPlugin/test/TestDimensionHandler.cpp @@ -0,0 +1,268 @@ +/** + * @file TestDimensionHandler.cpp + * @brief 尺寸标注处理器的单元测试 + * @author 范成通 email 1941804585@qq.com + */ + +#include "DimensionHandler.h" +#include "ArgObject.h" +#include "ComponentData.h" +#include "EventBus.h" +#include "FeatureContext.h" +#include "FeatureParams.h" +#include "FeatureRegistrar.h" +#include "GeometryData.h" +#include "InteractionContext.h" +#include "InteractionState.h" +#include "MakeMeshData.h" +#include "MeshData.h" +#include "ModelLayer.h" +#include "Selection.h" + +#include +#include + +#include + +#include +#include +#include +#include + +using namespace systems::feature; + +namespace { + +std::shared_ptr makeVertexSelection(const std::vector& ids) +{ + return std::make_shared(Selection { ids, ElementEnum::Vertex, 0 }); +} + +std::shared_ptr makeEdgeSelection(const std::vector& ids) +{ + return std::make_shared(Selection { ids, ElementEnum::Edge, 0 }); +} + +std::shared_ptr makeGeometrySelection(ElementEnum::Type type, const std::vector& ids) +{ + return std::make_shared(Selection { ids, type, 0 }); +} + +//! @brief 功能测试环境:手工装配 FeatureContext(参数集 + 活动组件 provider),替代原算法 HandlerContext +struct FeatureTestEnv { + ModelLayer mgr; + core::EventBus bus; + DimensionHandler handler; + FeatureRegistrar registrar; + std::optional active_component; + std::unique_ptr params; + systems::interaction::InteractionState interaction_state_; + InteractionContext interaction_ctx_; + std::unique_ptr ctx; + + FeatureTestEnv() + : interaction_ctx_(interaction_state_) + { + // 参数声明直接取自被测功能的 setup,避免测试中重复维护一份 + handler.setup(registrar); + params = std::make_unique(registrar.argTypes()); + ctx = std::make_unique(FeatureContext { + mgr, + bus, + *params, + interaction_ctx_, + []() -> std::optional { return std::nullopt; }, + [this]() { return active_component; }, + [this](Index component_id) { return mgr.getComponentOperator(component_id); }, + }); + } + + //! @brief 写入功能参数并执行尺寸标注,返回结果字符串(下标 0=测量类型 1=选择对象,见 setup) + std::string executeDimension(int measure_type_index, std::shared_ptr selection) + { + params->setValue(0, core::ArgObject::create(measure_type_index)); + params->setValue(1, core::ArgObject::create(std::move(selection))); + auto result_any = handler.execute(*ctx); + if (const std::string* result = std::any_cast(&result_any)) + return *result; + return {}; + } +}; + +//! @brief 在 10×10×10 的 OCC 立方体几何组件上执行一次测量,返回结果字符串 +std::string executeGeometryMeasureOnBox(int measure_type_index, ElementEnum::Type sel_type, int local_id) +{ + BRepPrimAPI_MakeBox box_maker(10.0, 10.0, 10.0); + box_maker.Build(); + if (!box_maker.IsDone()) + return {}; + + auto geometry = std::make_unique(); + geometry->rootShape = std::make_unique(box_maker.Shape()); + + auto c = std::make_unique(); + c->id = -1; + c->name = "box"; + c->geometry = std::move(geometry); + + ComponentDatas comps; + comps.push_back(std::move(c)); + + FeatureTestEnv env; + const Index model_id = env.mgr.addModel("box", std::move(comps)); + auto cids = env.mgr.modelById(model_id)->componentIds(); + if (cids.size() != 1) + return {}; + + ComponentData* comp = env.mgr.findComponent(cids[0]); + if (!comp) + return {}; + comp->geometry->ensureIndexBuilt(env.mgr.geomRegistry()); + + Index gid = -1; + switch (sel_type) { + case ElementEnum::GeometryEdge: + gid = comp->geometry->index.edgeGlobalId(local_id); + break; + case ElementEnum::GeometryFace: + gid = comp->geometry->index.faceGlobalId(local_id); + break; + case ElementEnum::GeometrySolid: + gid = comp->geometry->index.solidGlobalId(local_id); + break; + default: + break; + } + + auto selection = makeGeometrySelection(sel_type, { gid }); + selection->component_id = cids[0]; + return env.executeDimension(measure_type_index, selection); +} + +} // namespace + +TEST_CASE("DimensionHandler setup declares measure parameters and menu") +{ + DimensionHandler handler; + FeatureRegistrar reg; + handler.setup(reg); + + REQUIRE(reg.argTypes().size() == 2); + CHECK(reg.argTypes()[0].type == ArgTypeEnum::Combo); + CHECK(reg.argTypes()[1].type == ArgTypeEnum::Selector); + REQUIRE(reg.menuItems().size() == 1); +} + +TEST_CASE("DimensionHandler: distance between two vertices") +{ + auto mesh = std::make_unique(MakeMeshData()); + auto c = std::make_unique(); + c->id = -1; + c->name = "Comp"; + c->mesh = std::move(mesh); + + ComponentDatas comps; + comps.push_back(std::move(c)); + + FeatureTestEnv env; + const Index model_id = env.mgr.addModel("test", std::move(comps)); + + auto cids = env.mgr.modelById(model_id)->componentIds(); + REQUIRE(cids.size() == 1); + + ComponentData* comp = env.mgr.findComponent(cids[0]); + REQUIRE(comp); + + MeshData* mesh_ptr = comp->asMeshData(); + REQUIRE(mesh_ptr); + const Index base = mesh_ptr->local_to_global_[0]; + + auto selection = makeVertexSelection({ base + 0, base + 1 }); + selection->component_id = cids[0]; + const std::string result = env.executeDimension(0, selection); // 距离 + CHECK(result.find("Distance") != std::string::npos); + CHECK(result.find("1.000000") != std::string::npos); +} + +TEST_CASE("DimensionHandler: length of edges selected as endpoint vertex id pairs") +{ + auto mesh = std::make_unique(MakeMeshData()); + auto c = std::make_unique(); + c->id = -1; + c->name = "Comp"; + c->mesh = std::move(mesh); + + ComponentDatas comps; + comps.push_back(std::move(c)); + + FeatureTestEnv env; + const Index model_id = env.mgr.addModel("test", std::move(comps)); + + auto cids = env.mgr.modelById(model_id)->componentIds(); + REQUIRE(cids.size() == 1); + + ComponentData* comp = env.mgr.findComponent(cids[0]); + REQUIRE(comp); + + MeshData* mesh_ptr = comp->asMeshData(); + REQUIRE(mesh_ptr); + const Index base = mesh_ptr->local_to_global_[0]; + + // 边选择的 ids 是端点顶点 id 对:{12,13} 与 {0,1} 长度均为 1 + auto selection = makeEdgeSelection({ base + 12, base + 13, base + 0, base + 1 }); + selection->component_id = cids[0]; + const std::string result = env.executeDimension(3, selection); // 长度 + CHECK(result.find("累计长度: 2.000000") != std::string::npos); +} + +TEST_CASE("DimensionHandler: angle between two edges selected as endpoint vertex id pairs") +{ + auto mesh = std::make_unique(MakeMeshData()); + auto c = std::make_unique(); + c->id = -1; + c->name = "Comp"; + c->mesh = std::move(mesh); + + ComponentDatas comps; + comps.push_back(std::move(c)); + + FeatureTestEnv env; + const Index model_id = env.mgr.addModel("test", std::move(comps)); + + auto cids = env.mgr.modelById(model_id)->componentIds(); + REQUIRE(cids.size() == 1); + + ComponentData* comp = env.mgr.findComponent(cids[0]); + REQUIRE(comp); + + MeshData* mesh_ptr = comp->asMeshData(); + REQUIRE(mesh_ptr); + const Index base = mesh_ptr->local_to_global_[0]; + + // 边 {0,1} 方向 (1,0,0),边 {0,3} 方向 (0,1,0),夹角 90° + auto selection = makeEdgeSelection({ base + 0, base + 1, base + 0, base + 3 }); + selection->component_id = cids[0]; + const std::string result = env.executeDimension(1, selection); // 角度 + CHECK(result.find("Angle: 90.000000 deg") != std::string::npos); +} + +TEST_CASE("DimensionHandler: geometry edge length on OCC box") +{ + // 10×10×10 立方体任一条几何边长度均为 10 + const std::string result = executeGeometryMeasureOnBox(3, ElementEnum::GeometryEdge, 1); + CHECK(result.find("累计长度: 10.000000") != std::string::npos); +} + +TEST_CASE("DimensionHandler: geometry face area on OCC box") +{ + // 10×10×10 立方体任一个几何面面积均为 100 + const std::string result = executeGeometryMeasureOnBox(4, ElementEnum::GeometryFace, 1); + CHECK(result.find("总面积: 100.000000") != std::string::npos); +} + +TEST_CASE("DimensionHandler: geometry solid volume on OCC box") +{ + // 10×10×10 立方体体积为 1000 + const std::string result = executeGeometryMeasureOnBox(5, ElementEnum::GeometrySolid, 1); + CHECK(result.find("总体积: 1000.000000") != std::string::npos); +} diff --git a/plugins/feature/MeasurePlugin/CMakeLists.txt b/plugins/feature/MeasurePlugin/CMakeLists.txt new file mode 100644 index 0000000000000000000000000000000000000000..a6cffa4a1c1ecdb786ad77912f5c0078933aea91 --- /dev/null +++ b/plugins/feature/MeasurePlugin/CMakeLists.txt @@ -0,0 +1,6 @@ +precess_add_feature_plugin(MeasurePlugin + SOURCES "MeasureHandler.cpp" + PLUGIN_H "MeasurePlugin.h" +) + +add_subdirectory(test) diff --git a/plugins/feature/MeasurePlugin/MeasureHandler.cpp b/plugins/feature/MeasurePlugin/MeasureHandler.cpp new file mode 100644 index 0000000000000000000000000000000000000000..dbdd53e993e636a722357521e2c871c0721277cb --- /dev/null +++ b/plugins/feature/MeasurePlugin/MeasureHandler.cpp @@ -0,0 +1,256 @@ +/** + * @file MeasureHandler.cpp + * @brief 测量处理器:交互式两点成线测距与共端点夹角标注 + * @author 范成通 email 1941804585@qq.com + */ + +#include "MeasureHandler.h" +#include "EventBus.h" +#include "FeatureContext.h" +#include "FeatureEvents.h" +#include "FeatureRegistrar.h" +#include "InteractionContext.h" + +#include +#include +#include +#include +#include +#include + +namespace systems::feature { + +namespace { +using Vec3 = std::array; + +constexpr double kEps = 1e-9; + +Vec3 operator-(const Vec3& a, const Vec3& b) +{ + return { a[0] - b[0], a[1] - b[1], a[2] - b[2] }; +} + +//! @brief 两点中点 +Vec3 midpoint(const Vec3& a, const Vec3& b) +{ + return { (a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0, (a[2] + b[2]) / 2.0 }; +} + +double dot(const Vec3& a, const Vec3& b) +{ + return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; +} + +double length(const Vec3& v) +{ + return std::sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]); +} + +std::string toString(double value, int precision = 6) +{ + std::ostringstream oss; + oss.setf(std::ios::fixed, std::ios::floatfield); + oss.precision(precision); + oss << value; + return oss.str(); +} + +double angleBetween(const Vec3& u, const Vec3& v) +{ + const double lu = length(u); + const double lv = length(v); + if (lu < std::numeric_limits::epsilon() || lv < std::numeric_limits::epsilon()) + return 0.0; + + double cos_theta = dot(u, v) / (lu * lv); + cos_theta = std::max(-1.0, std::min(1.0, cos_theta)); + return std::acos(cos_theta) * 180.0 / 3.14159265358979323846; +} +} + +void MeasureHandler::setup(FeatureRegistrar& reg) +{ + // "清除"按钮参数:无值触发器,点击发布 ParameterChangedEvent,功能内部订阅并清空 + reg.addParameter({ ArgTypeEnum::Button, "清除", "" }); + reg.addMenuItem({ "工具", "测量" }); +} + +void MeasureHandler::activate(FeatureContext& ctx) +{ + // 标注集绑定到交互状态:渲染层事件后从 InteractionState.annotations 拉取绘制 + annotations_ = &ctx.interaction.annotations(); + interaction_ctx_ = &ctx.interaction; + ctx.interaction.onActivate([this]() { this->clear(); }); + ctx.interaction.onDeactivate([this]() { this->clear(); }); + ctx.interaction.onPick([this](const PickInfo& p) { return this->onPick(p); }); + ctx.interaction.onHover([this](const PickInfo& p) { return this->onHover(p); }); + + // "清除"按钮经 ParameterChangedEvent 触发:GUI 线程只置位延迟清空,渲染线程安全执行 + param_sub_ = ctx.events.subscribe([this](const ParameterChangedEvent& e) { + if (e.param_index == 0 && interaction_ctx_) + interaction_ctx_->deferClear([this] { this->clear(); }); + }); +} + +// ---------------- 交互回调(经 InteractionContext 注册,渲染线程驱动) ---------------- + +bool MeasureHandler::samePoint(const PickInfo& a, const PickInfo& b) +{ + if (a.mesh_id >= 0 && a.mesh_id == b.mesh_id) + return true; + if (a.geom_id >= 0 && a.geom_id == b.geom_id) + return true; + // 同源顶点再次拾取坐标位级一致,作无 id 时的兜底 + return a.world_pos == b.world_pos; +} + +bool MeasureHandler::onPick(const PickInfo& pick) +{ + if (!pick.valid) + return false; + + if (!pending_) { + pending_ = pick; + } else if (samePoint(*pending_, pick)) { + pending_.reset(); // 同一点再点一次 = 取消本次起笔 + } else { + addLine(*pending_, pick); + pending_.reset(); + } + + refreshAnnotations(); + return true; +} + +bool MeasureHandler::onHover(const PickInfo& pick) +{ + // 未吸附或无起笔:清除已有预览;本来无预览则无需刷新 + if (!pending_ || !pick.valid) { + if (!has_preview_) + return false; + has_preview_ = false; + refreshAnnotations(); + return true; + } + + has_preview_ = true; + preview_ = pick; + refreshAnnotations(); + return true; +} + +void MeasureHandler::addLine(const PickInfo& a, const PickInfo& b) +{ + // 与已有线完全重复(含反向)则忽略 + for (const MeasureLine& l : lines_) { + if ((samePoint(l.a, a) && samePoint(l.b, b)) || (samePoint(l.a, b) && samePoint(l.b, a))) + return; + } + + // 与每条已有线做端点匹配,共端点即记录一组夹角 + const int new_idx = static_cast(lines_.size()); + for (size_t i = 0; i < lines_.size(); ++i) { + const MeasureLine& l = lines_[i]; + auto try_share = [&](const PickInfo& old_shared, const PickInfo& old_other, + const PickInfo& new_shared, const PickInfo& new_other) { + if (!samePoint(old_shared, new_shared)) + return; + MeasureAngle ang; + ang.line1 = static_cast(i); + ang.line2 = new_idx; + ang.at = old_shared.world_pos; + ang.p = old_other.world_pos; + ang.q = new_other.world_pos; + ang.angle = angleBetween(ang.p - ang.at, ang.q - ang.at); + angles_.push_back(ang); + }; + try_share(l.a, l.b, a, b); + try_share(l.a, l.b, b, a); + try_share(l.b, l.a, a, b); + try_share(l.b, l.a, b, a); + } + lines_.push_back({ a, b }); +} + +void MeasureHandler::refreshAnnotations() +{ + annotations_->clear(); + + // 已确认线的端点与线段(红色端点、绿色实线) + for (const MeasureLine& l : lines_) { + annotations_->points.push_back({ l.a.world_pos }); + annotations_->points.push_back({ l.b.world_pos }); + annotations_->lines.push_back({ l.a.world_pos, l.b.world_pos }); + } + if (pending_) + annotations_->points.push_back({ pending_->world_pos }); + + // 长度文本:每线一个,放线段中点(白色) + for (const MeasureLine& l : lines_) { + const Vec3 mid = midpoint(l.a.world_pos, l.b.world_pos); + annotations_->texts.push_back({ mid, "L: " + toString(length(l.b.world_pos - l.a.world_pos), 2), 1.0, 1.0, 1.0 }); + } + + // 夹角文本:放共点沿角平分线偏移(青色);同一点多个夹角按序号加大偏移防重叠 + for (size_t ai = 0; ai < angles_.size(); ++ai) { + const MeasureAngle& ang = angles_[ai]; + int stack = 0; + for (size_t j = 0; j < ai; ++j) { + if (angles_[j].at == ang.at) + ++stack; + } + + const Vec3 u = ang.p - ang.at; + const Vec3 v = ang.q - ang.at; + const double lu = length(u); + const double lv = length(v); + Vec3 dir { 0.0, 0.0, 0.0 }; + if (lu > kEps && lv > kEps) { + // 角平分线方向;u、v 近反向(180°)时退化为两端点中点方向 + const Vec3 s { u[0] / lu + v[0] / lv, u[1] / lu + v[1] / lv, u[2] / lu + v[2] / lv }; + const double ls = length(s); + if (ls > kEps) { + dir = { s[0] / ls, s[1] / ls, s[2] / ls }; + } else { + const Vec3 d = midpoint(ang.p, ang.q) - ang.at; + const double ld = length(d); + dir = ld > kEps ? Vec3 { d[0] / ld, d[1] / ld, d[2] / ld } + : Vec3 { u[0] / lu, u[1] / lu, u[2] / lu }; + } + } + const double dist = 0.25 * std::min(lu, lv) * (1.0 + 0.3 * stack); + annotations_->texts.push_back({ { ang.at[0] + dir[0] * dist, ang.at[1] + dir[1] * dist, + ang.at[2] + dir[2] * dist }, + "Ang: " + toString(ang.angle, 2), 0.3, 0.9, 1.0 }); + } + + // 悬停动态预览:黄色虚线 + 黄色长度文本 + if (pending_ && has_preview_) { + AnnotationLine preview; + preview.p0 = pending_->world_pos; + preview.p1 = preview_.world_pos; + preview.r = 1.0; + preview.g = 0.9; + preview.b = 0.1; + preview.dashed = true; + annotations_->lines.push_back(preview); + + const Vec3 mid = midpoint(pending_->world_pos, preview_.world_pos); + annotations_->texts.push_back({ mid, + "L: " + toString(length(preview_.world_pos - pending_->world_pos), 2), 1.0, 0.9, 0.1 }); + } +} + +void MeasureHandler::clear() +{ + pending_.reset(); + has_preview_ = false; + lines_.clear(); + angles_.clear(); + refreshAnnotations(); + // GUI 线程清空后通知渲染层拉取标注并刷新视口 + if (interaction_ctx_) + interaction_ctx_->requestRefresh(); +} + +} // namespace systems::feature diff --git a/plugins/feature/MeasurePlugin/MeasureHandler.h b/plugins/feature/MeasurePlugin/MeasureHandler.h new file mode 100644 index 0000000000000000000000000000000000000000..feee5e7bc92bb702cdbe15d0977940b6cff633e2 --- /dev/null +++ b/plugins/feature/MeasurePlugin/MeasureHandler.h @@ -0,0 +1,79 @@ +/** + * @file MeasureHandler.h + * @brief 测量处理器声明:交互式两点成线测距与共端点夹角标注 + * @author 范成通 email 1941804585@qq.com + */ +#pragma once +#include "EventBus.h" +#include "FeatureHandler.h" +#include "InteractiveTypes.h" +#include +#include +#include + +namespace systems::feature { + +class InteractionContext; + +using systems::interaction::AnnotationBatch; +using systems::interaction::AnnotationLine; +using systems::interaction::PickInfo; + +/** + * @brief 测量处理器:交互式两点成线测距与共端点两线夹角标注 + * + * 纯视口交互功能:"清除"按钮参数经 ParameterChangedEvent 触发清空, + * 交互回调(拾取/悬停/激活/停用)由渲染线程经 InteractionService 驱动, + * 全部状态成员仅被交互回调访问。 + */ +class MeasureHandler : public FeatureHandler { +public: + MeasureHandler() = default; + ~MeasureHandler() override = default; + + //! @brief 声明"清除"按钮参数与菜单项 + void setup(FeatureRegistrar& reg) override; + //! @brief 激活:经 ctx.interaction 注册交互回调(拾取/悬停/激活/停用),订阅参数变更事件 + void activate(FeatureContext& ctx) override; + + //! @brief 交互测量状态查询(测试与面板用) + int lineCount() const { return static_cast(lines_.size()); } + bool hasPending() const { return pending_.has_value(); } + +private: + // ---- 交互回调(由 activate() 注册到 InteractionContext,渲染线程驱动) ---- + bool onPick(const PickInfo& pick); + bool onHover(const PickInfo& pick); + void clear(); + + //! @brief 交互测量线:两个吸附点(PickInfo 已含世界坐标与两套顶点 id) + struct MeasureLine { + PickInfo a, b; + }; + //! @brief 共端点两线的夹角:at 为共点,p/q 为两线各自另一端点 + struct MeasureAngle { + int line1 = 0; //> lines_ 下标 + int line2 = 0; + std::array at; + std::array p; + std::array q; + double angle = 0.0; + }; + + static bool samePoint(const PickInfo& a, const PickInfo& b); + void addLine(const PickInfo& a, const PickInfo& b); + //! @brief 由当前状态重建标注集 + void refreshAnnotations(); + + std::optional pending_; //> 已起笔未成线的首点 + bool has_preview_ = false; //> 悬停是否正在预览 + PickInfo preview_ {}; //> 悬停预览吸附点 + std::vector lines_; + std::vector angles_; + // 标注集契约:功能在回调中直接更新 InteractionState.annotations(activate 时绑定), + // 渲染层事件后拉取绘制;自持成员会导致渲染层永远拉到空标注 + AnnotationBatch* annotations_ { nullptr }; + InteractionContext* interaction_ctx_ { nullptr }; //> GUI 线程清除时经此请求渲染刷新 + core::EventBus::Subscription param_sub_; //> ParameterChangedEvent 订阅句柄(析构自动退订) +}; +} diff --git a/plugins/feature/MeasurePlugin/MeasurePlugin.h b/plugins/feature/MeasurePlugin/MeasurePlugin.h new file mode 100644 index 0000000000000000000000000000000000000000..381b20d3d2dce620bc183ed10758deeb520f38a5 --- /dev/null +++ b/plugins/feature/MeasurePlugin/MeasurePlugin.h @@ -0,0 +1,24 @@ +/** + * @file MeasurePlugin.h + * @brief 测量插件声明:将 MeasureHandler 注册到功能系统 + * @author 范成通 email 1941804585@qq.com + */ +#pragma once +#include "HandlerCreatorDestroyerFactory.h" +#include "MeasureHandler.h" +#include "PluginBase.h" + +#include + +namespace systems::feature { +class MeasurePlugin : public QObject, public PluginBase { + Q_OBJECT + Q_INTERFACES(systems::PluginBase) + Q_PLUGIN_METADATA(IID "com.PreCess.systems.feature.MeasurePlugin/1.0" FILE "MeasurePlugin.json") +private: + const HandlerCreatorDestroyer& getHandlerCreatorDestroyer() noexcept override final + { + return HandlerCreatorDestroyerFactory::get(); + } +}; +} diff --git a/plugins/feature/MeasurePlugin/MeasurePlugin.json b/plugins/feature/MeasurePlugin/MeasurePlugin.json new file mode 100644 index 0000000000000000000000000000000000000000..18733c599ca7d9d464baf9682ab0b4c5238ce967 --- /dev/null +++ b/plugins/feature/MeasurePlugin/MeasurePlugin.json @@ -0,0 +1,9 @@ +{ + "system": "FeatureSystem", + "handler": { + "name": "MeasurePlugin", + "display_name": "测量", + "description": "交互式测量:依次点击拾取两点构成一条直线并显示长度;可连续画多条线,两线共端点时自动显示夹角;同一点连点两次取消当前起笔。", + "interactive": true + } +} diff --git a/plugins/feature/MeasurePlugin/test/CMakeLists.txt b/plugins/feature/MeasurePlugin/test/CMakeLists.txt new file mode 100644 index 0000000000000000000000000000000000000000..f4ea5bf0621d9fe2d111a53ebe902830cf959b4b --- /dev/null +++ b/plugins/feature/MeasurePlugin/test/CMakeLists.txt @@ -0,0 +1,2 @@ +precess_add_test(TestMeasureHandler TestMeasureHandler.cpp) +precess_test_link_libraries(TestMeasureHandler MeasurePluginlib) diff --git a/plugins/feature/MeasurePlugin/test/TestMeasureHandler.cpp b/plugins/feature/MeasurePlugin/test/TestMeasureHandler.cpp new file mode 100644 index 0000000000000000000000000000000000000000..168ed4e98866532d95b8f6aa6a8382533bae209b --- /dev/null +++ b/plugins/feature/MeasurePlugin/test/TestMeasureHandler.cpp @@ -0,0 +1,109 @@ +/** + * @file TestMeasureHandler.cpp + * @brief 测量处理器的单元测试 + * @author 范成通 email 1941804585@qq.com + */ + +#include "MeasureHandler.h" +#include "EventBus.h" +#include "FeatureContext.h" +#include "FeatureEvents.h" +#include "FeatureParams.h" +#include "FeatureRegistrar.h" +#include "InteractionContext.h" +#include "InteractionState.h" +#include "ModelLayer.h" + +#include + +#include +#include + +using namespace systems::feature; + +namespace { + +//! @brief 功能测试环境:手工装配 FeatureContext(参数集 + 交互上下文),替代运行时注册流程 +struct FeatureTestEnv { + ModelLayer mgr; + core::EventBus bus; + MeasureHandler handler; + FeatureRegistrar registrar; + std::optional active_component; + std::unique_ptr params; + systems::interaction::InteractionState interaction_state_; + InteractionContext interaction_ctx_; + std::unique_ptr ctx; + + FeatureTestEnv() + : interaction_ctx_(interaction_state_) + { + // 参数声明直接取自被测功能的 setup,避免测试中重复维护一份 + handler.setup(registrar); + params = std::make_unique(registrar.argTypes()); + ctx = std::make_unique(FeatureContext { + mgr, + bus, + *params, + interaction_ctx_, + []() -> std::optional { return std::nullopt; }, + [this]() { return active_component; }, + [this](Index component_id) { return mgr.getComponentOperator(component_id); }, + }); + } +}; + +} // namespace + +TEST_CASE("MeasureHandler setup declares clear button parameter and menu") +{ + MeasureHandler handler; + FeatureRegistrar reg; + handler.setup(reg); + + // 纯交互功能:仅"清除"按钮参数(无值触发器)与菜单项 + REQUIRE(reg.argTypes().size() == 1); + CHECK(reg.argTypes()[0].type == ArgTypeEnum::Button); + REQUIRE(reg.menuItems().size() == 1); +} + +TEST_CASE("MeasureHandler: interactive picks update state annotations and ParameterChangedEvent clears") +{ + FeatureTestEnv env; + env.handler.activate(*env.ctx); + REQUIRE(env.interaction_state_.on_pick); + + // 两点成线:(0,0,0) → (1,0,0) + systems::interaction::PickInfo p1; + p1.valid = true; + p1.world_pos = { 0.0, 0.0, 0.0 }; + p1.mesh_id = 0; + systems::interaction::PickInfo p2; + p2.valid = true; + p2.world_pos = { 1.0, 0.0, 0.0 }; + p2.mesh_id = 1; + + env.interaction_state_.on_pick(p1); + CHECK(env.handler.hasPending()); + env.interaction_state_.on_pick(p2); + CHECK(env.handler.lineCount() == 1); + + // 交互标注写入 InteractionState.annotations(渲染层拉取绘制的契约) + CHECK(env.interaction_state_.annotations.lines.size() == 1); + CHECK(env.interaction_state_.annotations.points.size() == 2); + CHECK(env.interaction_state_.annotations.texts.size() == 1); + + // 面板"清除"按钮参数:通过 ParameterChangedEvent 触发延迟清空(GUI 线程置位,渲染线程执行) + env.bus.publish(ParameterChangedEvent { "Measure", 0, core::ArgObject {} }); + // GUI 线程只置位延迟清空回调与刷新标志,不直接修改功能状态 + CHECK(env.interaction_state_.needs_refresh); + REQUIRE(env.interaction_state_.deferred_clear); + // 模拟渲染线程 pollRefresh:执行延迟清空后检查状态 + env.interaction_state_.deferred_clear(); + env.interaction_state_.deferred_clear = nullptr; + CHECK(env.handler.lineCount() == 0); + CHECK(!env.handler.hasPending()); + CHECK(env.interaction_state_.annotations.lines.empty()); + CHECK(env.interaction_state_.annotations.points.empty()); + CHECK(env.interaction_state_.annotations.texts.empty()); +}