From 21745a3bb0f1d274cafad579cad8b3b2be8beaaf Mon Sep 17 00:00:00 2001 From: lh <2334563547@qq.com> Date: Tue, 3 Nov 2026 16:24:25 +0800 Subject: [PATCH] =?UTF-8?q?feat(nmNum):=20=E6=96=B0=E5=A2=9E=E4=B8=89?= =?UTF-8?q?=E7=BB=B4=E5=8D=95=E5=B9=B3=E9=9D=A2=E5=88=87=E7=89=87=E4=B8=8E?= =?UTF-8?q?=E4=BA=A4=E4=BA=92=E5=8F=82=E6=95=B0=E8=AE=BE=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 支持任意方向平面切片、参数输入及拖动松手更新 - 新增非模态切片设置窗口,支持重置和原模型参照 - 复用结果底部工具栏的渲染模式与属性选择 - 完善切片显隐、模型更换及返回计算结果的状态恢复 - 同步中文翻译 --- Bin/Config/Lang/cn/nmNum_cn.qm | Bin 198346 -> 203807 bytes Bin/Config/Lang/cn/nmNum_cn.ts | 231 +++++++++++++ .../nmNum/nmSubWxs/nmVolumeSceneController.h | 26 +- .../nmNum/nmSubWxs/nmVolumeSliceInteraction.h | 62 ++++ .../nmNum/nmSubWxs/nmVolumeSlicePipeline.h | 25 ++ .../nmNum/nmSubWxs/nmVolumeSliceSettings.h | 29 ++ .../nmNum/nmSubWxs/nmWxPebiResultControlBar.h | 15 +- .../nmNum/nmSubWxs/nmWxPebiResultViewWidget.h | 23 ++ .../nmSubWxs/nmWxVolumeSliceSettingsDlg.h | 81 +++++ .../nmSubWxs/nmVolumeSceneController.cpp | 284 ++++++++++++---- .../nmSubWxs/nmVolumeSliceInteraction.cpp | 178 ++++++++++ Src/nmNum/nmSubWxs/nmVolumeSlicePipeline.cpp | 105 ++++++ Src/nmNum/nmSubWxs/nmVolumeSliceSettings.cpp | 54 +++ .../nmSubWxs/nmWxPebiResultControlBar.cpp | 109 +++++- .../nmSubWxs/nmWxPebiResultViewWidget.cpp | 241 ++++++++++++- .../nmSubWxs/nmWxVolumeSliceSettingsDlg.cpp | 317 ++++++++++++++++++ 16 files changed, 1696 insertions(+), 84 deletions(-) create mode 100644 Include/nmNum/nmSubWxs/nmVolumeSliceInteraction.h create mode 100644 Include/nmNum/nmSubWxs/nmVolumeSlicePipeline.h create mode 100644 Include/nmNum/nmSubWxs/nmVolumeSliceSettings.h create mode 100644 Include/nmNum/nmSubWxs/nmWxVolumeSliceSettingsDlg.h create mode 100644 Src/nmNum/nmSubWxs/nmVolumeSliceInteraction.cpp create mode 100644 Src/nmNum/nmSubWxs/nmVolumeSlicePipeline.cpp create mode 100644 Src/nmNum/nmSubWxs/nmVolumeSliceSettings.cpp create mode 100644 Src/nmNum/nmSubWxs/nmWxVolumeSliceSettingsDlg.cpp diff --git a/Bin/Config/Lang/cn/nmNum_cn.qm b/Bin/Config/Lang/cn/nmNum_cn.qm index a2b8c22ba1665760c31f7094976fd046ad2a72bb..40d091fa8c6185b586a711b553dcf60d51857df6 100644 GIT binary patch delta 18156 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

)V8rp@)Jvk7= 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 b(SYWkzWD!dL>;F8?hkMnd(&RhE@uA^6iE|o delta 13361 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 diff --git a/Bin/Config/Lang/cn/nmNum_cn.ts b/Bin/Config/Lang/cn/nmNum_cn.ts index d30a0533..a53004fd 100644 --- a/Bin/Config/Lang/cn/nmNum_cn.ts +++ b/Bin/Config/Lang/cn/nmNum_cn.ts @@ -3349,6 +3349,7 @@ No valid error was returned; keep the last accepted result and reduce the step.< nmWxPebiResultControlBar + Solid Color纯色 Display显示 Point点 Wireframe线框 @@ -3464,6 +3465,58 @@ No valid error was returned; keep the last accepted result and reduce the step.< %1结果动画已保存到: %1 Unable to export the result animation.无法导出结果动画。 + + Import 3D Model + 导入三维模型 + + + Import 3D Model... + 导入三维模型… + + + Loading 3D model... + 正在加载三维模型… + + + Replace 3D Model... + 更换三维模型… + + + Return to Calculation Result + 返回计算结果 + + + Slice + 切片 + + + Unable to display the 3D model. + 无法显示三维模型。 + + + Unable to return to the latest calculation result: %1 + 无法返回最新计算结果:%1 + + + VTK Volume Mesh (*.vtk *.vtu) + VTK 三维体网格 (*.vtk *.vtu) + + + Show Slice + 显示切片 + + + Slice Settings... + 切片设置… + + + The slice plane does not intersect the model. + 切片平面与模型无交集。 + + + Unable to display the slice. + 无法显示截面。 + nmWxPebiGridViewWidget @@ -8968,4 +9021,182 @@ Average pressure in contour: %2 MPa 表皮系数 + + nmVolumeData + + Not enough memory to import the volume mesh. + 内存不足,无法导入三维网格。 + + + Only linear tetrahedron, voxel, hexahedron, wedge and pyramid volume cells are supported. + 当前仅支持线性四面体、体素、六面体、楔形和金字塔体单元。 + + + Select a .vtk or .vtu volume mesh file. + 请选择 .vtk 或 .vtu 三维体网格文件。 + + + Skipped invalid scalar array: %1 + 已跳过无效标量属性:%1 + + + Skipped scalar array without finite values: %1 + 已跳过没有有限值的标量属性:%1 + + + The file contains no volume mesh. + 文件中没有有效的三维体网格。 + + + The mesh connectivity is invalid. + 网格连接关系无效。 + + + The mesh contains an invalid point index. + 网格包含越界的节点索引。 + + + The mesh contains non-finite coordinates. + 网格包含无效坐标(NaN 或无穷值)。 + + + The mesh contains repeated cell vertices. + 网格单元包含重复顶点。 + + + The mesh must have a finite extent in all three directions. + 三维网格必须在三个方向均具有有效范围。 + + + The temporary directory path cannot be read by VTK. + VTK 无法读取当前临时目录路径。 + + + The volume mesh file is empty or cannot be opened. + 三维网格文件为空或无法打开。 + + + Unable to create a temporary file for this path. + 无法为该文件路径创建临时文件。 + + + Unable to read the volume mesh file. + 无法读取三维网格文件。 + + + VTK could not read the volume mesh: %1 + VTK 无法读取三维网格:%1 + + + Volume import canceled. + 已取消三维模型导入。 + + + + nmVolumeSceneController + + Not enough memory to display the volume mesh. + 内存不足,无法显示三维网格。 + + + Unable to bind the volume scalar property. + 无法绑定三维模型的标量属性。 + + + + nmVolumeImportTask + + An unexpected error occurred while importing the volume mesh. + 导入三维网格时发生意外错误。 + + + + nmVolumeSliceSettings + + Origin and normal must contain finite numbers. + 原点和法向必须为有限数值。 + + + The plane normal must not be zero. + 平面法向不能为零向量。 + + + + nmVolumeSlicePipeline + + No volume model is available. + 当前没有可用的三维模型。 + + + Unable to compute a valid slice. The previous view has been kept. + 无法生成有效截面,已保留原画面。 + + + Not enough memory to compute the slice. + 内存不足,无法计算截面。 + + + + nmWxVolumeSliceSettingsDlg + + Origin %1 + 原点 %1 + + + Normal %1 + 法向 %1 + + + Plane Parameters + 平面参数 + + + Display Options + 显示选项 + + + Slice Settings + 切片设置 + + + Origin + 原点 + + + Normal + 法向 + + + Model Center + 模型中心 + + + Show Plane + 显示平面 + + + Show Original Model + 显示原模型 + + + Apply + 应用 + + + Reset + 重置 + + + Close + 关闭 + + + Enter valid numbers for origin and normal. + 请为原点和法向输入有效数值。 + + + Drag the center or outline to move the plane; drag arrows to rotate. + 拖动中心手柄或平面轮廓移动平面,拖动箭头旋转平面。 + + diff --git a/Include/nmNum/nmSubWxs/nmVolumeSceneController.h b/Include/nmNum/nmSubWxs/nmVolumeSceneController.h index c8d1e696..2fea3457 100644 --- a/Include/nmNum/nmSubWxs/nmVolumeSceneController.h +++ b/Include/nmNum/nmSubWxs/nmVolumeSceneController.h @@ -3,6 +3,8 @@ #include "nmSubWxs_global.h" #include "nmVolumeData.h" +#include "nmVolumeSliceSettings.h" +#include "nmVTKMeshLayer.h" class nmWxVTKRenderContainerWidget; class nmVolumeSceneControllerPrivate; @@ -30,18 +32,34 @@ public: bool hasData() const; /** @brief 按稳定 ID 原子切换着色属性;失败保留原属性,成功至多 Render 一次。 */ bool applyProperty(const QString& sPropertyId, QString* pError = nullptr); + /** @brief 复用结果窗口的网格显示模式,立即生效,不重算截面或移动相机。 */ + void setMeshDisplayMode(nmVTKMeshLayer::DisplayMode eMode); + /** @brief 返回完整模型与截面共用的显示模式,供底部工具栏同步。 */ + nmVTKMeshLayer::DisplayMode getMeshDisplayMode() const; /** @brief 更新色标响应式布局,不触发渲染,供容器 Resize 通知使用。 */ void setViewportSize(int nWidth, int nHeight); /** @brief 保存色标显隐偏好;无标量时始终隐藏,重复设置不渲染。 */ void setScalarBarVisible(bool bVisible); - /** @brief 保存三维坐标框显隐偏好;重复设置不渲染。 */ - void setScaleGridVisible(bool bVisible); /** @brief 返回色标显隐偏好,供 QAction 同步勾选状态。 */ bool isScalarBarVisible() const; - /** @brief 返回三维坐标框显隐偏好,供 QAction 同步勾选状态。 */ - bool isScaleGridVisible() const; /** @brief 返回当前模型是否选中了可用于着色的合法属性。 */ bool hasScalarProperty() const; + /** @brief 返回当前只读数据,用于参数目录及平面范围初始化。 */ + QSharedPointer getData() const; + /** @brief 返回最后成功应用的切片参数;首次切片前返回模型中心默认参数。 */ + nmVolumeSliceSettings getSliceSettings() const; + /** @brief 启用或停用切片;首次启用计算中心截面,后续复用已有截面。 */ + bool setSliceEnabled(bool bEnabled, QString* pError = nullptr); + /** @brief 事务式应用参数,成功后启用切片;失败保留旧画面及参数。 */ + bool applySliceSettings(const nmVolumeSliceSettings& oSettings, QString* pError = nullptr); + /** @brief 立即更新辅助平面及参照模型偏好,不执行切片计算。 */ + void setSliceDisplayOptions(bool bShowPlane, bool bShowSource); + /** @brief 返回当前是否显示截面,供菜单同步勾选状态。 */ + bool isSliceEnabled() const; + /** @brief 返回当前切片是否为合法的空截面,供界面显示无交集提示。 */ + bool isSliceEmpty() const; + /** @brief 返回当前数据成功提交截面的次数,便于观察及验证预览不重算。 */ + unsigned int getSliceRevision() const; private: Q_DISABLE_COPY(nmVolumeSceneController) diff --git a/Include/nmNum/nmSubWxs/nmVolumeSliceInteraction.h b/Include/nmNum/nmSubWxs/nmVolumeSliceInteraction.h new file mode 100644 index 00000000..c20c4c3c --- /dev/null +++ b/Include/nmNum/nmSubWxs/nmVolumeSliceInteraction.h @@ -0,0 +1,62 @@ +#ifndef NMVOLUMESLICEINTERACTION_H +#define NMVOLUMESLICEINTERACTION_H + +#include "nmSubWxs_global.h" +#include "nmVolumeSliceSettings.h" +#include +#include + +class nmWxVTKRenderContainerWidget; +class vtkObject; +class vtkCallbackCommand; +class vtkImplicitPlaneWidget2; +class vtkImplicitPlaneRepresentation; + +/** @brief 管理预览平面及 VTK/Qt 事件边界;不持有 Cutter,任何预览均不能触发切片计算。 */ +class NM_SUB_WXS_EXPORT nmVolumeSliceInteraction : public QObject +{ + Q_OBJECT +public: + /** @brief 绑定生命周期更长的渲染容器;构造时不显示 Widget。 */ + explicit nmVolumeSliceInteraction(nmWxVTKRenderContainerWidget* pContainer, QObject* pParent = nullptr); + /** @brief 先禁用 Widget、解除观察者,再释放 VTK 引用。 */ + virtual ~nmVolumeSliceInteraction(); + /** @brief 为当前数据身份放置预览平面;同一数据的输入更新使用 setPreview。 */ + void bind(const QString& sDataId, const double dBounds[6], const nmVolumeSliceSettings& oSettings); + /** @brief 解除数据及 Interactor,并使尚未处理的拖动事件失效。 */ + void unbind(); + /** @brief 更新有效草稿的位置及方向,非法参数不改变预览。 */ + void setPreview(const nmVolumeSliceSettings& oSettings); + /** @brief 控制辅助平面及事件处理;关闭时丢弃排队事件,保持绑定参数。 */ + void setEnabled(bool bEnabled); + /** @brief 返回辅助平面的实际启用状态,供测试与界面核对。 */ + bool isEnabled() const; + +signals: + /** @brief 队列合并后的拖动预览,仅同步参数,不应计算截面。 */ + void previewChanged(const QString& sDataId, const nmVolumeSliceSettings& oSettings); + /** @brief 松手后发出一次应用请求;接收者还需核对当前数据身份和模式。 */ + void applyRequested(const QString& sDataId, const nmVolumeSliceSettings& oSettings); + +private slots: + /** @brief 离开 VTK 回调栈后交付最后一次预览及可选应用请求。 */ + void deliverPendingEvent(); + +private: + Q_DISABLE_COPY(nmVolumeSliceInteraction) + /** @brief VTK 回调只复制数据、安排 Qt 队列,不执行界面槽或切片计算。 */ + static void onPlaneEvent(vtkObject* pCaller, unsigned long nEvent, void* pClientData, void* pCallData); + nmWxVTKRenderContainerWidget* m_pContainer; ///< 非所有权,必须在此对象之后销毁。 + vtkSmartPointer m_pRepresentation; ///< 独立的预览几何,不与计算平面共享。 + vtkSmartPointer m_pWidget; ///< 本窗口独占的 VTK 交互 Widget。 + vtkSmartPointer m_pCallback; ///< ClientData 指向本对象,析构前解除。 + QString m_sDataId; ///< 当前模型身份,防止旧模型拖动事件修改新模型。 + nmVolumeSliceSettings m_oPreview; ///< 最近一次有效草稿及显示选项。 + nmVolumeSliceSettings m_oPending; ///< 回调复制的最新平面参数。 + unsigned int m_nGeneration; ///< 绑定或关闭交互时递增,隔离同一模型的过期事件。 + unsigned int m_nPendingGeneration; ///< 排队事件所属交互代次。 + bool m_bQueued; ///< 是否已有待执行 Qt 队列槽,避免每次 MouseMove 排队。 + bool m_bPendingApply; ///< 最新事件序列是否包含松手应用。 +}; + +#endif // NMVOLUMESLICEINTERACTION_H diff --git a/Include/nmNum/nmSubWxs/nmVolumeSlicePipeline.h b/Include/nmNum/nmSubWxs/nmVolumeSlicePipeline.h new file mode 100644 index 00000000..96a03695 --- /dev/null +++ b/Include/nmNum/nmSubWxs/nmVolumeSlicePipeline.h @@ -0,0 +1,25 @@ +#ifndef NMVOLUMESLICEPIPELINE_H +#define NMVOLUMESLICEPIPELINE_H + +#include "nmSubWxs_global.h" +#include "nmVolumeData.h" +#include "nmVolumeSliceSettings.h" + +class vtkPolyData; + +/** @brief 单次切片计算入口,无窗口依赖;每次计算独占 Plane/Cutter,不连接预览平面。 */ +class NM_SUB_WXS_EXPORT nmVolumeSlicePipeline +{ +public: + /** + * @brief 从已发布体网格生成独立截面,不修改原始几何或属性数组。 + * @param pOutput 仅成功时替换;零单元输出代表合法的无交集结果。 + * @return 参数、VTK 计算或输出校验失败返回 false,并保持原输出引用。 + */ + static bool build(const QSharedPointer& pData, + const nmVolumeSliceSettings& oSettings, + vtkSmartPointer& pOutput, + QString* pError = nullptr); +}; + +#endif // NMVOLUMESLICEPIPELINE_H diff --git a/Include/nmNum/nmSubWxs/nmVolumeSliceSettings.h b/Include/nmNum/nmSubWxs/nmVolumeSliceSettings.h new file mode 100644 index 00000000..cf686cc5 --- /dev/null +++ b/Include/nmNum/nmSubWxs/nmVolumeSliceSettings.h @@ -0,0 +1,29 @@ +#ifndef NMVOLUMESLICESETTINGS_H +#define NMVOLUMESLICESETTINGS_H + +#include "nmSubWxs_global.h" +#include +#include + +class nmVolumeData; + +/** @brief 单平面切片的值类型参数;不持有 VTK 对象,可安全复制为界面草稿。 */ +struct NM_SUB_WXS_EXPORT nmVolumeSliceSettings +{ + /** @brief 初始化 Z 向平面,默认显示辅助平面、不显示原模型。 */ + nmVolumeSliceSettings(); + /** @brief 从完整模型边界初始化中心平面,并选取模型的默认着色属性。 */ + static nmVolumeSliceSettings fromData(const nmVolumeData& oData); + /** @brief 校验有限原点及非零法向;成功才原位归一化,失败不改变参数。 */ + bool normalize(QString* pError = nullptr); + + double m_dOrigin[3]; ///< 平面经过的位置,使用数据的原始坐标,不推断单位。 + double m_dNormal[3]; ///< 草稿允许任意非零向量,提交后的向量始终为单位向量。 + QString m_sPropertyId; ///< 关联类型及数组名组成的属性身份;无属性模型为空。 + bool m_bShowPlane; ///< 参数窗口打开时是否显示辅助平面及拖动手柄。 + bool m_bShowSource; ///< 切片模式下是否显示灰色半透明的原模型。 +}; + +Q_DECLARE_METATYPE(nmVolumeSliceSettings) + +#endif // NMVOLUMESLICESETTINGS_H diff --git a/Include/nmNum/nmSubWxs/nmWxPebiResultControlBar.h b/Include/nmNum/nmSubWxs/nmWxPebiResultControlBar.h index 617c0d66..5582fb9c 100644 --- a/Include/nmNum/nmSubWxs/nmWxPebiResultControlBar.h +++ b/Include/nmNum/nmSubWxs/nmWxPebiResultControlBar.h @@ -15,11 +15,12 @@ class QVariant; class nmPebiResultPlaybackController; class nmPebiResultSceneController; +class nmVolumeSceneController; /** - * @brief 结果属性选择与时间回放底部控制条。 + * @brief 结果与外部三维模型共用的显示、属性及时间回放底部控制条。 * - * 控件只镜像 PlaybackController/SceneController 状态,不创建 Renderer、 + * 控件只镜像当前数据源的控制器状态;三维模式禁用时间回放,不创建 Renderer、 * Actor、Mapper、Adapter 或任何额外 VTK 管线。 */ class NM_SUB_WXS_EXPORT nmWxPebiResultControlBar : public QWidget @@ -38,6 +39,8 @@ public: void stopPlayback(); /** @brief 在外部完成 Scene 快照提交或清理后同步全部控件。 */ void synchronizeFromScene(); + /** @brief 绑定外部三维场景以复用显示和属性控件;传空指针恢复计算结果底栏。 */ + void setVolumeSceneController(nmVolumeSceneController* pController); /** * @brief 返回本控制条独占的回放控制器。 * @note 仅供组合窗口查询或配置,调用方不得保存到控制条生命周期之外。 @@ -100,6 +103,8 @@ private: /** @brief 按固定顺序创建属性、导航、时间、播放和设置控件。 */ void createUi(); + /** @brief 镜像三维场景的显示模式和原始属性名,保留但禁用时间、动画及颜色设置。 */ + void refreshFromVolumeController(); /** @brief 按快照属性顺序重建属性项,itemData 保存稳定 ID。 */ void rebuildPropertyItems(const QStringList& listPropertyIds); /** @brief 按小时制时间轴重建时间项,itemData 保存零基帧。 */ @@ -114,8 +119,12 @@ private: /** @brief 本控制条独占的 QObject 子对象,负责全部回放业务状态。 */ nmPebiResultPlaybackController* m_pPlaybackController; - /** @brief 已渲染属性、帧和显示设置的唯一真源;本控件不拥有。 */ + /** @brief 计算结果的属性、帧和显示设置来源;三维模式保留但不修改,本控件不拥有。 */ nmPebiResultSceneController* m_pSceneController; + /** @brief 当前外部三维场景,非所有权;宿主必须在释放控制器之前解除绑定。 */ + nmVolumeSceneController* m_pVolumeSceneController; + /** @brief 三维属性列表对应的数据身份,更换模型时据此重建条目。 */ + QString m_sDisplayedVolumeDataId; /** @brief 显示模式选择框,条目数据保存 DisplayMode 枚举值。 */ QComboBox* m_pDisplayModeCombo; /** @brief 属性选择框,条目数据保存稳定英文属性 ID。 */ diff --git a/Include/nmNum/nmSubWxs/nmWxPebiResultViewWidget.h b/Include/nmNum/nmSubWxs/nmWxPebiResultViewWidget.h index 7c485a01..5b25a354 100644 --- a/Include/nmNum/nmSubWxs/nmWxPebiResultViewWidget.h +++ b/Include/nmNum/nmSubWxs/nmWxPebiResultViewWidget.h @@ -24,6 +24,7 @@ class iUnitGroup; class nmDataAnalyzeManager; class nmWxPebiResultRegionState; class nmWxPebiVolumeState; +struct nmVolumeSliceSettings; class nmPebiResultSceneController; class nmPebiResultSnapshot; class nmWxPebiResultControlBar; @@ -136,6 +137,26 @@ public: bool isImportedVolumeMode() const; private slots: + /** @brief 在完整模型与已应用截面之间切换,首次启用时计算中心截面。 */ + void onVolumeSliceToggled(bool bEnabled); + /** @brief 按需打开非模态参数窗口,并绑定当前数据的辅助平面。 */ + void onShowVolumeSliceSettings(); + /** @brief 校验窗口草稿并事务式提交一次截面。 */ + void onApplyVolumeSlice(); + /** @brief 将有效参数草稿同步到辅助平面,绝不执行切片计算。 */ + void onVolumeSliceDraftChanged(); + /** @brief 即时更新辅助平面和原模型的显示偏好。 */ + void onVolumeSliceDisplayChanged(bool bShowPlane, bool bShowSource); + /** @brief 隐藏窗口时解绑辅助平面,保留已应用截面。 */ + void onVolumeSliceDialogHidden(); + /** @brief 接收经过队列合并的拖动预览,检查数据身份后同步输入框。 */ + void onVolumeSlicePreview(const QString& sDataId, const nmVolumeSliceSettings& oSettings); + /** @brief 处理松手请求;再次检查当前模型和模式,然后应用当前有效草稿。 */ + void onVolumeSliceDragApplied(const QString& sDataId, const nmVolumeSliceSettings& oSettings); + /** @brief 根据窗口、加载任务及旋转中心拾取状态,统一控制平面交互可用性。 */ + void synchronizeSliceInteraction(); + /** @brief 在当前场景事务结束后的事件循环刷新手柄显隐,避免拾取时残留旧画面。 */ + void refreshVolumeSliceView(); /** @brief 从切片菜单选择文件,再启动可取消后台导入。 */ void onImportVolumeRequested(); /** @brief 原子标记取消;不等待或强行终止 Reader。 */ @@ -199,6 +220,8 @@ private: bool canImportVolume() const; /** @brief 同步切片菜单及外部模式下的业务动作,不触发渲染。 */ void synchronizeVolumeActions(); + /** @brief 关闭参数窗口并解绑平面,不渲染;释放数据时同时解除对旧模型的引用。 */ + void closeVolumeSliceSettings(bool bReleaseData); /** @brief 创建区域图层和临时交互样式,并冻结当前 pressure 显示身份。 */ bool startRegionPressureDrawing(); /** @brief 解除临时交互、观察者和控制条锁,不修改已准备的统计区域。 */ diff --git a/Include/nmNum/nmSubWxs/nmWxVolumeSliceSettingsDlg.h b/Include/nmNum/nmSubWxs/nmWxVolumeSliceSettingsDlg.h new file mode 100644 index 00000000..3f8ce729 --- /dev/null +++ b/Include/nmNum/nmSubWxs/nmWxVolumeSliceSettingsDlg.h @@ -0,0 +1,81 @@ +#ifndef NMWXVOLUMESLICESETTINGSDLG_H +#define NMWXVOLUMESLICESETTINGSDLG_H + +#include "iDlgBase.h" +#include "nmSubWxs_global.h" +#include "nmVolumeData.h" +#include "nmVolumeSliceSettings.h" + +class QCheckBox; +class QHideEvent; +class QLabel; +class QLineEdit; +class QPushButton; + +/** @brief 复用通用参数对话框基类的非模态切片窗口;草稿与已应用参数分离。 */ +class NM_SUB_WXS_EXPORT nmWxVolumeSliceSettingsDlg : public iDlgBase +{ + Q_OBJECT +public: + /** @brief 创建原点、法向及显示选项控件,不自动显示窗口;着色属性由底栏控制。 */ + explicit nmWxVolumeSliceSettingsDlg(QWidget* pParent = nullptr); + /** @brief 更换当前模型及已应用参数,清除前一模型的草稿和错误。 */ + void setData(const QSharedPointer& pData, const nmVolumeSliceSettings& oApplied); + /** @brief 提交成功后同步归一化参数,同时更新重置目标。 */ + void setAppliedSettings(const nmVolumeSliceSettings& oSettings); + /** @brief 同步底栏已应用的着色属性,保留原点和法向的未应用草稿。 */ + void setAppliedProperty(const QString& sPropertyId); + /** @brief 从当前输入读取并校验草稿,失败不改变输出。 */ + bool getDraft(nmVolumeSliceSettings& oSettings, QString* pError = nullptr) const; + /** @brief 从拖动事件更新原点和法向,不改变底栏已应用的属性,不回发预览信号。 */ + void setPlanePreview(const nmVolumeSliceSettings& oSettings); + /** @brief 在窗口内显示计算或绑定错误,不弹出阻塞对话框。 */ + void setError(const QString& sError); + +signals: + /** @brief 请求应用当前有效草稿,由所属结果窗口负责事务提交。 */ + void applyRequested(); + /** @brief 有效草稿变化,仅用于同步辅助平面,不能触发截面计算。 */ + void previewChanged(); + /** @brief 显示选项立即生效,与原点、法向草稿的应用周期分离。 */ + void displayOptionsChanged(bool bShowPlane, bool bShowSource); + /** @brief 窗口隐藏后通知宿主关闭交互手柄;截面继续显示。 */ + void dialogHidden(); + +protected: + /** @brief 任何隐藏路径均丢弃未应用草稿,避免重开后显示过期参数。 */ + virtual void hideEvent(QHideEvent* pEvent); + +private slots: + /** @brief 校验当前输入并更新应用按钮;有效输入更新平面预览。 */ + void onDraftChanged(); + /** @brief 将平面法向设置为 XY/XZ/YZ 对应轴,保持原点。 */ + void onAxisRequested(); + /** @brief 将草稿原点移到模型中心,保持法向与属性。 */ + void onCenterRequested(); + /** @brief 恢复最后已应用参数并同步辅助平面。 */ + void onResetRequested(); + /** @brief 校验有效后请求宿主提交当前草稿。 */ + void onApplyRequested(); + /** @brief 更新已应用状态中的显示偏好,不提交原点和法向草稿。 */ + void onDisplayOptionsChanged(); + +private: + /** @brief 按现有参数窗口样式创建分组、对齐的编辑控件及固定顺序的底部按钮。 */ + void createUi(); + /** @brief 阻止信号递归地把值对象写入所有控件,然后更新校验提示。 */ + void writeSettings(const nmVolumeSliceSettings& oSettings); + /** @brief 更新按钮及校验消息,不发送预览或应用信号。 */ + void updateValidation(); + QSharedPointer m_pData; ///< 当前只读模型,用于有效性检查及中心位置。 + nmVolumeSliceSettings m_oApplied; ///< 最后成功提交的参数,含立即生效的显示偏好。 + QLineEdit* m_pOriginEdits[3]; ///< 原点 X/Y/Z 文本输入,支持科学计数法。 + QLineEdit* m_pNormalEdits[3]; ///< 法向 X/Y/Z 文本输入,应用时归一化。 + QCheckBox* m_pShowPlaneCheck; ///< 参数窗口打开时显示辅助平面的开关。 + QCheckBox* m_pShowSourceCheck; ///< 灰色半透明原模型的即时显示开关。 + QLabel* m_pErrorLabel; ///< 非模态输入或计算错误提示。 + QPushButton* m_pApplyButton; ///< 草稿合法且模型存在时才启用。 + bool m_bUpdating; ///< 程序同步控件时屏蔽业务信号,防止反馈循环。 +}; + +#endif // NMWXVOLUMESLICESETTINGSDLG_H diff --git a/Src/nmNum/nmSubWxs/nmVolumeSceneController.cpp b/Src/nmNum/nmSubWxs/nmVolumeSceneController.cpp index a8458042..d023625d 100644 --- a/Src/nmNum/nmSubWxs/nmVolumeSceneController.cpp +++ b/Src/nmNum/nmSubWxs/nmVolumeSceneController.cpp @@ -1,7 +1,7 @@ #include "nmVolumeSceneController.h" #include "nmVolumeDataRenderAdapter.h" +#include "nmVolumeSlicePipeline.h" #include "nmWxVTKRenderContainerWidget.h" -#include "nmVTKBoundsGridLayer.h" #include "nmVTKMeshLayer.h" #include "nmVTKScalarBarLayer.h" #include "nmVTKScene.h" @@ -13,18 +13,18 @@ #include #include #include -#include +#include +#include #include #include #include #include -#include +#include +#include namespace { // 固定文本标记供 Qt 4 lupdate 提取;运行期通过统一上下文翻译。 static const char* const s_pVolumeTranslationMarkers[] = { - QT_TRANSLATE_NOOP("nmVolumeSceneController", " (Cell)"), - QT_TRANSLATE_NOOP("nmVolumeSceneController", " (Point)"), QT_TRANSLATE_NOOP("nmVolumeSceneController", "Not enough memory to display the volume mesh."), QT_TRANSLATE_NOOP("nmVolumeSceneController", "Unable to bind the volume scalar property.") }; @@ -41,6 +41,48 @@ QString sceneText(const char* pText) Q_UNUSED(s_pVolumeTranslationMarkers); return QCoreApplication::translate("nmVolumeSceneController", pText); } + +/** @brief 完整模型与截面共用的全局线性颜色表;常量范围只为渲染作最小扩展。 */ +vtkSmartPointer makeVolumeLookupTable(const nmVolumePropertyDescriptor& oProperty) +{ + double dMinimum = oProperty.m_dMinimum; + double dMaximum = oProperty.m_dMaximum; + if(dMinimum == dMaximum) { + const double dPadding = qMax(1.0, std::fabs(dMinimum)) * 1e-6; + dMaximum += dPadding; + if(!vtkMath::IsFinite(dMaximum)) { dMaximum = dMinimum; dMinimum -= dPadding; } + } + vtkSmartPointer pTable = vtkSmartPointer::New(); + pTable->SetNumberOfTableValues(256); + pTable->SetHueRange(0.67, 0.0); + pTable->SetTableRange(dMinimum, dMaximum); + pTable->SetNanColor(0.65, 0.65, 0.65, 1.0); + pTable->Build(); + return pTable; +} + +/** @brief 候选挂入 Scene 后的回退保护;异常或提前返回时仅撤销该候选图层。 */ +class VolumeSliceLayerGuard +{ +public: + /** @brief 在挂载之前准备 ID,避免挂载后字符串分配失败造成图层遗留。 */ + VolumeSliceLayerGuard(nmVTKScene* pScene, const QString& sId) + : m_pScene(pScene), m_sId(sId), m_pLayer(nullptr) {} + /** @brief 候选未提交时解除 Scene 所有权并删除,已提交时不操作。 */ + ~VolumeSliceLayerGuard() + { + if(m_pLayer != nullptr && m_pScene->getLayer(m_sId) == m_pLayer) m_pScene->removeLayer(m_sId); + } + /** @brief 记录成功挂载的图层,由 Scene 实际拥有对象。 */ + void hold(nmVTKMeshLayer* pLayer) { m_pLayer = pLayer; } + /** @brief 事务提交后解除回退保护。 */ + void release() { m_pLayer = nullptr; } +private: + Q_DISABLE_COPY(VolumeSliceLayerGuard) + nmVTKScene* m_pScene; ///< 非所有权 Scene,存活期覆盖本次同步事务。 + QString m_sId; ///< 待提交候选的唯一身份。 + nmVTKMeshLayer* m_pLayer; ///< Scene 所有的候选指针,仅用于身份检查。 +}; } class nmVolumeSceneControllerPrivate @@ -49,17 +91,19 @@ public: /** @brief 初始化未挂载图层的空场景,构造本身不改动画面。 */ explicit nmVolumeSceneControllerPrivate(nmWxVTKRenderContainerWidget* pContainer) : m_pContainer(pContainer), m_pAdapter(nullptr), m_pMesh(nullptr), - m_pScalarBar(nullptr), m_pBounds(nullptr), m_bVisible(false), - m_bScalarBarVisible(true), m_bScaleGridVisible(false) {} + m_pScalarBar(nullptr), m_pSliceMesh(nullptr), + m_eMeshDisplayMode(nmVTKMeshLayer::DisplayMode_SurfaceWithEdges), + m_bSliceEnabled(false), m_nSliceRevision(0), m_bVisible(false), + m_bScalarBarVisible(true) {} /** @brief 仅移除仍具有相同对象身份的图层,防止误删外部同 ID 图层。 */ ~nmVolumeSceneControllerPrivate() { // Scene 拥有图层,先解除 Mapper 对适配器的引用,再释放数据。 nmVTKScene* pScene = m_pContainer == nullptr ? nullptr : m_pContainer->getScene(); if(pScene != nullptr) { + if(m_pSliceMesh != nullptr && pScene->getLayer(m_sSliceId) == m_pSliceMesh) pScene->removeLayer(m_sSliceId); if(m_pMesh != nullptr && pScene->getLayer(m_sMeshId) == m_pMesh) pScene->removeLayer(m_sMeshId); if(m_pScalarBar != nullptr && pScene->getLayer(m_sScalarBarId) == m_pScalarBar) pScene->removeLayer(m_sScalarBarId); - if(m_pBounds != nullptr && pScene->getLayer(m_sBoundsId) == m_pBounds) pScene->removeLayer(m_sBoundsId); } delete m_pAdapter; } @@ -68,55 +112,53 @@ public: { nmVTKScene* pScene = m_pContainer == nullptr ? nullptr : m_pContainer->getScene(); return !m_pData.isNull() && pScene != nullptr && m_pMesh != nullptr && - m_pScalarBar != nullptr && m_pBounds != nullptr && + m_pScalarBar != nullptr && pScene->getLayer(m_sMeshId) == m_pMesh && pScene->getLayer(m_sScalarBarId) == m_pScalarBar && - pScene->getLayer(m_sBoundsId) == m_pBounds; + (m_pSliceMesh == nullptr || pScene->getLayer(m_sSliceId) == m_pSliceMesh); } /** @brief 将模型可见状态与各覆盖层偏好合成,不触发 Render。 */ void updateVisibility() { if(!isIntact()) return; - m_pMesh->setVisible(m_bVisible); - m_pScalarBar->setVisible(m_bVisible && m_bScalarBarVisible && !m_sPropertyId.isEmpty()); - m_pBounds->setVisible(m_bVisible && m_bScaleGridVisible); + const bool bHasSlice = m_pSliceData != nullptr && m_pSliceData->GetNumberOfCells() > 0; + // 参照模型固定为灰色半透明表面;完整模型和截面沿用底栏选中的显示模式。 + m_pMesh->setOpacity(m_bSliceEnabled ? 0.15 : 1.0); + m_pMesh->setScalarVisibility(!m_bSliceEnabled && !m_sPropertyId.isEmpty()); + m_pMesh->setDisplayMode(m_bSliceEnabled + ? nmVTKMeshLayer::DisplayMode_Surface : m_eMeshDisplayMode); + m_pMesh->setVisible(m_bVisible && (!m_bSliceEnabled || m_oSliceSettings.m_bShowSource)); + if(m_pSliceMesh != nullptr) { + m_pSliceMesh->setDisplayMode(m_eMeshDisplayMode); + m_pSliceMesh->setVisible(m_bVisible && m_bSliceEnabled && bHasSlice); + } + m_pScalarBar->setVisible(m_bVisible && m_bScalarBarVisible && !m_sPropertyId.isEmpty() && (!m_bSliceEnabled || bHasSlice)); + } + /** @brief 仅在当前场景激活时更新拾取目标,隐藏候选不会抢占其他业务场景。 */ + void updatePickTarget() + { + if(!m_bVisible || !isIntact()) return; + vtkActor* pTarget = m_pMesh->getActor(); + if(m_bSliceEnabled) { + pTarget = m_pSliceMesh != nullptr && m_pSliceData != nullptr && m_pSliceData->GetNumberOfCells() > 0 + ? m_pSliceMesh->getActor() : nullptr; + } + m_pContainer->setRotationCenterPickTarget(pTarget); } /** @brief 完整验证候选 LUT 和属性后提交 Mapper,失败时保持旧绑定。 */ - bool bindProperty(const QString& sPropertyId) + bool bindProperty(const QString& sPropertyId, vtkLookupTable* pPreparedTable = nullptr) { const nmVolumePropertyDescriptor* pProperty = m_pData->findProperty(sPropertyId); if(pProperty == nullptr) return false; - double dMinimum = pProperty->m_dMinimum; - double dMaximum = pProperty->m_dMaximum; - const bool bConstant = dMinimum == dMaximum; - if(bConstant) { - // LUT 需要严格递增区间;色标仍显示真实常量值,不能伪造业务范围。 - const double dPadding = qMax(1.0, std::fabs(dMinimum)) * 1e-6; - dMaximum += dPadding; - if(!vtkMath::IsFinite(dMaximum)) { - dMaximum = dMinimum; - dMinimum -= dPadding; - } - } - vtkSmartPointer pTable = vtkSmartPointer::New(); - pTable->SetNumberOfTableValues(256); - pTable->SetHueRange(0.67, 0.0); - pTable->SetTableRange(dMinimum, dMaximum); - pTable->SetNanColor(0.65, 0.65, 0.65, 1.0); - pTable->Build(); + const bool bConstant = pProperty->m_dMinimum == pProperty->m_dMaximum; + vtkSmartPointer pTable = pPreparedTable; + if(pTable == nullptr) pTable = makeVolumeLookupTable(*pProperty); const nmVTKMeshLayer::ScalarAssociation eAssociation = pProperty->m_eAssociation == NM_VolumeProperty_CellData ? nmVTKMeshLayer::ScalarAssociation_CellData : nmVTKMeshLayer::ScalarAssociation_PointData; if(!m_pMesh->setInputConnection(m_pAdapter->getOutputPort(), pProperty->m_sArrayName, eAssociation, pTable)) return false; m_pMesh->setScalarVisibility(true); - QString sTitle = pProperty->m_sArrayName; - const QString sOtherId = (pProperty->m_eAssociation == NM_VolumeProperty_CellData - ? QString::fromLatin1("point:") : QString::fromLatin1("cell:")) + sTitle; - if(m_pData->findProperty(sOtherId) != nullptr) { - sTitle += pProperty->m_eAssociation == NM_VolumeProperty_CellData - ? sceneText(" (Cell)") : sceneText(" (Point)"); - } - m_pScalarBar->setTitle(sTitle); + m_pScalarBar->setTitle(pProperty->m_sArrayName); m_pScalarBar->setLookupTable(pTable); m_pScalarBar->setConstantValueLabel(bConstant, QString::number(pProperty->m_dMinimum, 'g', 8)); m_pScalarBar->setNumberOfLabels(bConstant ? 0 : m_pScalarBar->getResponsiveLabelCount()); @@ -128,14 +170,18 @@ public: nmVolumeDataRenderAdapter* m_pAdapter; ///< 本状态独占的渲染管线适配器。 nmVTKMeshLayer* m_pMesh; ///< Scene 所有的主模型图层,仅作非所有权观察。 nmVTKScalarBarLayer* m_pScalarBar; ///< Scene 所有的色标图层。 - nmVTKBoundsGridLayer* m_pBounds; ///< Scene 所有的三维坐标框图层。 + nmVTKMeshLayer* m_pSliceMesh; ///< Scene 所有的截面图层;每次成功计算后替换。 + vtkSmartPointer m_pSliceData; ///< 与 Cutter 断开的独立截面,关闭显示时保留。 + nmVolumeSliceSettings m_oSliceSettings; ///< 当前数据最后一次成功应用的切片参数。 + nmVTKMeshLayer::DisplayMode m_eMeshDisplayMode; ///< 完整模型及截面的共用显示偏好,不参与切片计算。 + QString m_sSliceId; ///< 当前截面图层唯一 ID,不与完整模型图层复用。 + bool m_bSliceEnabled; ///< 是否正在显示截面,而非完整模型。 + unsigned int m_nSliceRevision; ///< 本次数据成功应用截面的次数,预览不增加。 QString m_sMeshId; ///< 主模型图层稳定 ID,带本次候选唯一前缀。 QString m_sScalarBarId; ///< 色标图层稳定 ID。 - QString m_sBoundsId; ///< 三维坐标框图层稳定 ID。 QString m_sPropertyId; ///< 最后成功绑定的属性 ID,纯几何时为空。 bool m_bVisible; ///< 是否已由窗口事务激活,而非尚在准备的隐藏候选。 bool m_bScalarBarVisible; ///< 当前三维模型的独立色标偏好。 - bool m_bScaleGridVisible; ///< 当前三维模型的独立坐标框偏好。 }; nmVolumeSceneController::nmVolumeSceneController(nmWxVTKRenderContainerWidget* pContainer) @@ -156,12 +202,12 @@ bool nmVolumeSceneController::setData(const QSharedPointer& try { QScopedPointer pCandidate(new nmVolumeSceneControllerPrivate(m_pPrivate->m_pContainer)); pCandidate->m_pData = pData; + pCandidate->m_oSliceSettings = nmVolumeSliceSettings::fromData(*pData); pCandidate->m_pAdapter = new nmVolumeDataRenderAdapter(pData); // 每次候选使用独立 ID,旧场景直到候选全部准备成功才释放。 const QString sPrefix = QString::fromLatin1("nm.volume.") + QUuid::createUuid().toString(); pCandidate->m_sMeshId = sPrefix + QLatin1String(".mesh"); pCandidate->m_sScalarBarId = sPrefix + QLatin1String(".scalar"); - pCandidate->m_sBoundsId = sPrefix + QLatin1String(".bounds"); nmVTKScene* pScene = pCandidate->m_pContainer->getScene(); if(pScene == nullptr) return false; QScopedPointer pMesh(new nmVTKMeshLayer(pCandidate->m_sMeshId)); @@ -172,16 +218,7 @@ bool nmVolumeSceneController::setData(const QSharedPointer& pScalarBar->setVisible(false); if(!pScene->addLayer(pScalarBar.data())) return false; pCandidate->m_pScalarBar = pScalarBar.take(); - QScopedPointer pBounds(new nmVTKBoundsGridLayer(pCandidate->m_sBoundsId)); - pBounds->setVisible(false); - if(!pScene->addLayer(pBounds.data())) return false; - pCandidate->m_pBounds = pBounds.take(); - double dBounds[6]; - pData->getBounds(dBounds); - pCandidate->m_pBounds->setCamera(pCandidate->m_pContainer->getRenderer()->GetActiveCamera()); - pCandidate->m_pBounds->setMode(nmVTKBoundsGridLayer::Mode_Box3D); - if(!pCandidate->m_pBounds->setBounds(dBounds[0], dBounds[1], dBounds[2], dBounds[3], dBounds[4], dBounds[5])) return false; - pCandidate->m_pMesh->setDisplayMode(nmVTKMeshLayer::DisplayMode_SurfaceWithEdges); + pCandidate->m_pMesh->setDisplayMode(pCandidate->m_eMeshDisplayMode); pCandidate->m_pMesh->getActor()->GetProperty()->SetEdgeColor(0.0, 0.0, 0.0); pCandidate->m_pMesh->getActor()->GetProperty()->SetColor(0.65, 0.65, 0.65); if(pData->getInitialPropertyId().isEmpty()) { @@ -211,7 +248,7 @@ void nmVolumeSceneController::activate(bool bFitCamera) m_pPrivate->m_bVisible = true; m_pPrivate->updateVisibility(); m_pPrivate->m_pContainer->setPrimaryViewBounds(dBounds); - m_pPrivate->m_pContainer->setRotationCenterPickTarget(m_pPrivate->m_pMesh->getActor()); + m_pPrivate->updatePickTarget(); if(bFitCamera) { m_pPrivate->m_pContainer->fitIsometricViewWithoutRender(); m_pPrivate->m_pContainer->resetRotationCenterWithoutRender(); @@ -241,14 +278,37 @@ bool nmVolumeSceneController::applyProperty(const QString& sPropertyId, QString* if(pError != nullptr) pError->clear(); if(!hasData()) return false; if(sPropertyId == m_pPrivate->m_sPropertyId) return true; + if(m_pPrivate->m_pSliceData != nullptr) { + nmVolumeSliceSettings oSettings = m_pPrivate->m_oSliceSettings; + oSettings.m_sPropertyId = sPropertyId; + const bool bWasEnabled = m_pPrivate->m_bSliceEnabled; + if(!applySliceSettings(oSettings, pError)) return false; + if(!bWasEnabled) setSliceEnabled(false); + return true; + } if(!m_pPrivate->bindProperty(sPropertyId)) { if(pError != nullptr) *pError = sceneText("Unable to bind the volume scalar property."); return false; } m_pPrivate->updateVisibility(); + m_pPrivate->m_oSliceSettings.m_sPropertyId = sPropertyId; if(m_pPrivate->m_bVisible) m_pPrivate->m_pContainer->renderView(); return true; } +void nmVolumeSceneController::setMeshDisplayMode(nmVTKMeshLayer::DisplayMode eMode) +{ + if(!hasData() || !nmVTKMeshLayer::isValidDisplayMode(eMode) || + m_pPrivate->m_eMeshDisplayMode == eMode) return; + m_pPrivate->m_eMeshDisplayMode = eMode; + m_pPrivate->updateVisibility(); + if(m_pPrivate->m_bVisible) m_pPrivate->m_pContainer->renderView(); +} + +nmVTKMeshLayer::DisplayMode nmVolumeSceneController::getMeshDisplayMode() const +{ + return m_pPrivate->m_eMeshDisplayMode; +} + void nmVolumeSceneController::setViewportSize(int nWidth, int nHeight) { if(hasData()) m_pPrivate->m_pScalarBar->setViewportSize(nWidth, nHeight); @@ -261,14 +321,112 @@ void nmVolumeSceneController::setScalarBarVisible(bool bVisible) m_pPrivate->updateVisibility(); if(hasData() && m_pPrivate->m_bVisible) m_pPrivate->m_pContainer->renderView(); } -void nmVolumeSceneController::setScaleGridVisible(bool bVisible) +bool nmVolumeSceneController::isScalarBarVisible() const { return m_pPrivate->m_bScalarBarVisible; } +bool nmVolumeSceneController::hasScalarProperty() const { return hasData() && !m_pPrivate->m_sPropertyId.isEmpty(); } + +QSharedPointer nmVolumeSceneController::getData() const { - if(!isVolumeGuiThread()) return; - if(m_pPrivate->m_bScaleGridVisible == bVisible) return; - m_pPrivate->m_bScaleGridVisible = bVisible; + return hasData() ? m_pPrivate->m_pData : QSharedPointer(); +} + +nmVolumeSliceSettings nmVolumeSceneController::getSliceSettings() const { return m_pPrivate->m_oSliceSettings; } +bool nmVolumeSceneController::isSliceEnabled() const { return hasData() && m_pPrivate->m_bSliceEnabled; } +bool nmVolumeSceneController::isSliceEmpty() const +{ + return isSliceEnabled() && m_pPrivate->m_pSliceData != nullptr && m_pPrivate->m_pSliceData->GetNumberOfCells() == 0; +} +unsigned int nmVolumeSceneController::getSliceRevision() const { return m_pPrivate->m_nSliceRevision; } + +bool nmVolumeSceneController::setSliceEnabled(bool bEnabled, QString* pError) +{ + if(pError != nullptr) pError->clear(); + if(!hasData()) return false; + if(bEnabled && m_pPrivate->m_pSliceData == nullptr) return applySliceSettings(m_pPrivate->m_oSliceSettings, pError); + if(m_pPrivate->m_bSliceEnabled == bEnabled) return true; + m_pPrivate->m_bSliceEnabled = bEnabled; m_pPrivate->updateVisibility(); - if(hasData() && m_pPrivate->m_bVisible) m_pPrivate->m_pContainer->renderView(); + m_pPrivate->updatePickTarget(); + if(m_pPrivate->m_bVisible) m_pPrivate->m_pContainer->renderView(); + return true; +} + +void nmVolumeSceneController::setSliceDisplayOptions(bool bShowPlane, bool bShowSource) +{ + if(!hasData()) return; + m_pPrivate->m_oSliceSettings.m_bShowPlane = bShowPlane; + m_pPrivate->m_oSliceSettings.m_bShowSource = bShowSource; + m_pPrivate->updateVisibility(); + if(m_pPrivate->m_bVisible) m_pPrivate->m_pContainer->renderView(); +} + +bool nmVolumeSceneController::applySliceSettings(const nmVolumeSliceSettings& oSettings, QString* pError) +{ + if(pError != nullptr) pError->clear(); + if(!hasData()) return false; + nmVolumeSliceSettings oCandidateSettings = oSettings; + if(!oCandidateSettings.normalize(pError)) return false; + const nmVolumePropertyDescriptor* pProperty = m_pPrivate->m_pData->findProperty(oCandidateSettings.m_sPropertyId); + if(pProperty == nullptr && (!oCandidateSettings.m_sPropertyId.isEmpty() || !m_pPrivate->m_pData->getProperties().isEmpty())) { + if(pError != nullptr) *pError = sceneText("Unable to bind the volume scalar property."); + return false; + } + vtkSmartPointer pCandidateData; + if(!nmVolumeSlicePipeline::build(m_pPrivate->m_pData, oCandidateSettings, pCandidateData, pError)) return false; + try { + const QString sCandidateId = QString::fromLatin1("nm.volume.slice.") + QUuid::createUuid().toString(); + QScopedPointer pCandidateMesh(new nmVTKMeshLayer(sCandidateId)); + pCandidateMesh->setVisible(false); + pCandidateMesh->setDisplayMode(m_pPrivate->m_eMeshDisplayMode); + pCandidateMesh->getActor()->GetProperty()->SetColor(0.65, 0.65, 0.65); + pCandidateMesh->getActor()->GetProperty()->SetEdgeColor(0.0, 0.0, 0.0); + // 关闭光照明暗,截面颜色直接对应色标,方便定量核对。 + pCandidateMesh->getActor()->GetProperty()->LightingOff(); + if(!pCandidateMesh->setDataSet(pCandidateData)) return false; + bool bHasFiniteValues = false; + if(pProperty != nullptr && pCandidateData->GetNumberOfCells() > 0) { + vtkDataArray* pValues = pProperty->m_eAssociation == NM_VolumeProperty_CellData + ? pCandidateData->GetCellData()->GetArray(pProperty->m_sArrayName.toUtf8().constData()) + : pCandidateData->GetPointData()->GetArray(pProperty->m_sArrayName.toUtf8().constData()); + for(vtkIdType nValue = 0; pValues != nullptr && nValue < pValues->GetNumberOfTuples(); ++nValue) { + if(vtkMath::IsFinite(pValues->GetComponent(nValue, 0))) { bHasFiniteValues = true; break; } + } + } + // LUT 只使用数据发布时的全局范围,不用截面的局部范围覆盖它。 + vtkSmartPointer pTable; + if(pProperty != nullptr) { + pTable = makeVolumeLookupTable(*pProperty); + const nmVTKMeshLayer::ScalarAssociation eAssociation = pProperty->m_eAssociation == NM_VolumeProperty_CellData + ? nmVTKMeshLayer::ScalarAssociation_CellData : nmVTKMeshLayer::ScalarAssociation_PointData; + if(bHasFiniteValues) { + if(!pCandidateMesh->setScalarArray(pProperty->m_sArrayName, eAssociation, pTable)) return false; + pCandidateMesh->setScalarVisibility(true); + } + } + nmVTKScene* pScene = m_pPrivate->m_pContainer->getScene(); + VolumeSliceLayerGuard oGuard(pScene, sCandidateId); + if(!pScene->addLayer(pCandidateMesh.data())) return false; + nmVTKMeshLayer* pNewMesh = pCandidateMesh.take(); + oGuard.hold(pNewMesh); + // 全部候选已验证。完整模型的属性绑定也按原接口原子提交,失败时撤销新图层。 + if(pProperty != nullptr && !m_pPrivate->bindProperty(oCandidateSettings.m_sPropertyId, pTable)) { + if(pError != nullptr) *pError = sceneText("Unable to bind the volume scalar property."); + return false; + } + if(m_pPrivate->m_bVisible) m_pPrivate->m_pContainer->setRotationCenterPickTarget(nullptr); + if(m_pPrivate->m_pSliceMesh != nullptr) pScene->removeLayer(m_pPrivate->m_sSliceId); + m_pPrivate->m_sSliceId = sCandidateId; + m_pPrivate->m_pSliceMesh = pNewMesh; + m_pPrivate->m_pSliceData = pCandidateData; + m_pPrivate->m_oSliceSettings = oCandidateSettings; + m_pPrivate->m_bSliceEnabled = true; + oGuard.release(); + ++m_pPrivate->m_nSliceRevision; + m_pPrivate->updateVisibility(); + m_pPrivate->updatePickTarget(); + if(m_pPrivate->m_bVisible) m_pPrivate->m_pContainer->renderView(); + return true; + } catch(const std::bad_alloc&) { + if(pError != nullptr) *pError = sceneText("Not enough memory to display the volume mesh."); + return false; + } } -bool nmVolumeSceneController::isScalarBarVisible() const { return m_pPrivate->m_bScalarBarVisible; } -bool nmVolumeSceneController::isScaleGridVisible() const { return m_pPrivate->m_bScaleGridVisible; } -bool nmVolumeSceneController::hasScalarProperty() const { return hasData() && !m_pPrivate->m_sPropertyId.isEmpty(); } diff --git a/Src/nmNum/nmSubWxs/nmVolumeSliceInteraction.cpp b/Src/nmNum/nmSubWxs/nmVolumeSliceInteraction.cpp new file mode 100644 index 00000000..d2956995 --- /dev/null +++ b/Src/nmNum/nmSubWxs/nmVolumeSliceInteraction.cpp @@ -0,0 +1,178 @@ +#include "nmVolumeSliceInteraction.h" +#include "nmWxVTKRenderContainerWidget.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +/** @brief VTK 7.1 的 DrawPlaneOff 仍拾取不可见填充面,需将拾取限定到可见轮廓和手柄。 */ +class nmVolumePlaneRepresentation : public vtkImplicitPlaneRepresentation +{ +public: + /** @brief 按 VTK 引用计数规则创建本地预览表示。 */ + static nmVolumePlaneRepresentation* New(); + vtkTypeMacro(nmVolumePlaneRepresentation, vtkImplicitPlaneRepresentation); + /** @brief 中心或轮廓沿法向移动平面,法向箭头继续使用 VTK 内建旋转。 */ + virtual int ComputeInteractionState(int nX, int nY, int nModify = 0) + { + const bool bMoving = this->InteractionState == Moving; + const int nState = this->Superclass::ComputeInteractionState(nX, nY, nModify); + if(bMoving && (nState == MovingOrigin || this->Picker->GetActor() == this->EdgesActor)) { + this->SetInteractionState(Pushing); + this->SetRepresentationState(Pushing); + } + return this->InteractionState; + } +protected: + /** @brief 去除填充面和固定包围盒的拾取,避免在模型空白区域抢占相机操作。 */ + nmVolumePlaneRepresentation() + { + this->Picker->DeletePickList(this->CutActor); + this->Picker->DeletePickList(this->OutlineActor); + this->Picker->AddPickList(this->EdgesActor); + } +}; +vtkStandardNewMacro(nmVolumePlaneRepresentation); + +/** @brief 在隐藏、导入或退出模式时安全取消未松手的拖动,不保留 Interactor 焦点。 */ +class nmVolumePlaneWidget : public vtkImplicitPlaneWidget2 +{ +public: + /** @brief 创建本窗口独占的交互 Widget。 */ + static nmVolumePlaneWidget* New(); + vtkTypeMacro(nmVolumePlaneWidget, vtkImplicitPlaneWidget2); + /** @brief 禁用时先解除主动拖动及焦点,不能发出一次伪造的松手应用。 */ + virtual void SetEnabled(int nEnabled) + { + if(!nEnabled && this->Enabled && this->WidgetState == Active) { + this->ReleaseFocus(); + this->WidgetState = Start; + this->GetImplicitPlaneRepresentation()->SetRepresentationState(vtkImplicitPlaneRepresentation::Outside); + this->EndInteraction(); + } + this->Superclass::SetEnabled(nEnabled); + } +}; +vtkStandardNewMacro(nmVolumePlaneWidget); +} + +nmVolumeSliceInteraction::nmVolumeSliceInteraction(nmWxVTKRenderContainerWidget* pContainer, QObject* pParent) + : QObject(pParent), m_pContainer(pContainer), + m_pRepresentation(vtkSmartPointer::New()), + m_pWidget(vtkSmartPointer::New()), + m_pCallback(vtkSmartPointer::New()), + m_nGeneration(0), m_nPendingGeneration(0), m_bQueued(false), m_bPendingApply(false) +{ + m_pRepresentation->SetPlaceFactor(1.0); + m_pRepresentation->DrawPlaneOff(); + m_pRepresentation->OutlineTranslationOff(); + m_pRepresentation->ScaleEnabledOff(); + m_pRepresentation->OutsideBoundsOn(); + m_pRepresentation->ConstrainToWidgetBoundsOff(); + m_pRepresentation->GetOutlineProperty()->SetColor(0.45, 0.55, 0.65); + m_pWidget->SetRepresentation(m_pRepresentation); + m_pWidget->KeyPressActivationOff(); + // 中键、右键及键盘保留给现有相机和菜单;仅左键手柄用于移动或旋转平面。 + vtkWidgetEventTranslator* pTranslator = m_pWidget->GetEventTranslator(); + pTranslator->RemoveTranslation(vtkCommand::MiddleButtonPressEvent); + pTranslator->RemoveTranslation(vtkCommand::MiddleButtonReleaseEvent); + pTranslator->RemoveTranslation(vtkCommand::RightButtonPressEvent); + pTranslator->RemoveTranslation(vtkCommand::RightButtonReleaseEvent); + pTranslator->RemoveTranslation(vtkCommand::KeyPressEvent); + pTranslator->RemoveTranslation(vtkCommand::KeyReleaseEvent); + m_pCallback->SetClientData(this); + m_pCallback->SetCallback(onPlaneEvent); + m_pWidget->AddObserver(vtkCommand::InteractionEvent, m_pCallback); + m_pWidget->AddObserver(vtkCommand::EndInteractionEvent, m_pCallback); +} + +nmVolumeSliceInteraction::~nmVolumeSliceInteraction() +{ + unbind(); + m_pWidget->RemoveObservers(vtkCommand::InteractionEvent, m_pCallback); + m_pWidget->RemoveObservers(vtkCommand::EndInteractionEvent, m_pCallback); + m_pCallback->SetClientData(nullptr); +} + +void nmVolumeSliceInteraction::bind(const QString& sDataId, const double dBounds[6], const nmVolumeSliceSettings& oSettings) +{ + unbind(); + if(m_pContainer == nullptr || sDataId.isEmpty() || dBounds == nullptr) return; + m_sDataId = sDataId; + m_pWidget->SetInteractor(m_pContainer->getInteractor()); + m_pWidget->SetCurrentRenderer(m_pContainer->getRenderer()); + double dPlacement[6]; + for(int nIndex = 0; nIndex < 6; ++nIndex) dPlacement[nIndex] = dBounds[nIndex]; + m_pRepresentation->PlaceWidget(dPlacement); + setPreview(oSettings); +} + +void nmVolumeSliceInteraction::unbind() +{ + ++m_nGeneration; + m_bPendingApply = false; + m_pWidget->SetEnabled(0); + m_pWidget->SetInteractor(nullptr); + m_pWidget->SetCurrentRenderer(nullptr); + m_sDataId.clear(); +} + +void nmVolumeSliceInteraction::setPreview(const nmVolumeSliceSettings& oSettings) +{ + nmVolumeSliceSettings oNormalized = oSettings; + if(!oNormalized.normalize()) return; + // 数值输入、重置或成功应用取代旧拖动草稿,不能再交付尚未处理的旧松手事件。 + ++m_nGeneration; + m_bPendingApply = false; + m_oPreview = oNormalized; + m_pRepresentation->SetOrigin(m_oPreview.m_dOrigin); + m_pRepresentation->SetNormal(m_oPreview.m_dNormal); +} + +void nmVolumeSliceInteraction::setEnabled(bool bEnabled) +{ + bEnabled = bEnabled && !m_sDataId.isEmpty(); + if(isEnabled() == bEnabled) return; + if(!bEnabled) { ++m_nGeneration; m_bPendingApply = false; } + // VTK 禁用 Widget 时会清空 CurrentRenderer;恢复时不能依赖鼠标仍停留在视口内。 + if(bEnabled) m_pWidget->SetCurrentRenderer(m_pContainer->getRenderer()); + m_pWidget->SetEnabled(bEnabled ? 1 : 0); +} + +bool nmVolumeSliceInteraction::isEnabled() const { return m_pWidget->GetEnabled() != 0; } + +void nmVolumeSliceInteraction::onPlaneEvent(vtkObject* pCaller, unsigned long nEvent, void* pClientData, void*) +{ + nmVolumeSliceInteraction* pSelf = static_cast(pClientData); + if(pSelf == nullptr || !pSelf->isEnabled() || pCaller != pSelf->m_pWidget.GetPointer()) return; + pSelf->m_oPending = pSelf->m_oPreview; + pSelf->m_pRepresentation->GetOrigin(pSelf->m_oPending.m_dOrigin); + pSelf->m_pRepresentation->GetNormal(pSelf->m_oPending.m_dNormal); + pSelf->m_nPendingGeneration = pSelf->m_nGeneration; + pSelf->m_bPendingApply = pSelf->m_bPendingApply || nEvent == vtkCommand::EndInteractionEvent; + if(!pSelf->m_bQueued) { + pSelf->m_bQueued = true; + QMetaObject::invokeMethod(pSelf, "deliverPendingEvent", Qt::QueuedConnection); + } +} + +void nmVolumeSliceInteraction::deliverPendingEvent() +{ + m_bQueued = false; + const bool bApply = m_bPendingApply; + m_bPendingApply = false; + if(!isEnabled() || m_nPendingGeneration != m_nGeneration) return; + const unsigned int nGeneration = m_nGeneration; + const QString sDataId = m_sDataId; + m_oPreview = m_oPending; + emit previewChanged(sDataId, m_oPreview); + // previewChanged 的接收者可能关闭窗口或切换模式,必须再次检查代次。 + if(bApply && isEnabled() && nGeneration == m_nGeneration) emit applyRequested(sDataId, m_oPreview); +} diff --git a/Src/nmNum/nmSubWxs/nmVolumeSlicePipeline.cpp b/Src/nmNum/nmSubWxs/nmVolumeSlicePipeline.cpp new file mode 100644 index 00000000..d5c06fd2 --- /dev/null +++ b/Src/nmNum/nmSubWxs/nmVolumeSlicePipeline.cpp @@ -0,0 +1,105 @@ +#include "nmVolumeSlicePipeline.h" +#include "nmVolumeDataRenderAdapter.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +/** @brief 记录 VTK 错误;回调只访问本次同步计算的栈标记,不接触界面。 */ +void recordSliceError(vtkObject*, unsigned long, void* pClientData, void*) +{ + *static_cast(pClientData) = true; +} +} + +bool nmVolumeSlicePipeline::build(const QSharedPointer& pData, + const nmVolumeSliceSettings& oSettings, vtkSmartPointer& pOutput, + QString* pError) +{ + if(pError != nullptr) pError->clear(); + nmVolumeSliceSettings oNormalized = oSettings; + if(!oNormalized.normalize(pError)) return false; + if(pData.isNull()) { + if(pError != nullptr) *pError = QCoreApplication::translate("nmVolumeSlicePipeline", "No volume model is available."); + return false; + } + try { + nmVolumeDataRenderAdapter oAdapter(pData); + bool bFailed = false; + vtkSmartPointer pObserver = vtkSmartPointer::New(); + pObserver->SetClientData(&bFailed); + pObserver->SetCallback(recordSliceError); + vtkSmartPointer pPlane = vtkSmartPointer::New(); + pPlane->SetOrigin(oNormalized.m_dOrigin); + pPlane->SetNormal(oNormalized.m_dNormal); + vtkSmartPointer pCutter = vtkSmartPointer::New(); + pCutter->SetInputConnection(oAdapter.getOutputPort()); + pCutter->SetCutFunction(pPlane); + pCutter->SetValue(0, 0.0); + pCutter->GenerateCutScalarsOff(); + pCutter->GenerateTrianglesOff(); + pCutter->SetOutputPointsPrecision(vtkAlgorithm::DOUBLE_PRECISION); + pCutter->AddObserver(vtkCommand::ErrorEvent, pObserver); + pCutter->Update(); + pCutter->RemoveObservers(vtkCommand::ErrorEvent, pObserver); + pObserver->SetClientData(nullptr); + vtkPolyData* pResult = pCutter->GetOutput(); + if(!bFailed && pResult != nullptr && pCutter->GetErrorCode() == vtkErrorCode::NoError) { + for(vtkIdType nPoint = 0; nPoint < pResult->GetNumberOfPoints(); ++nPoint) { + double dPoint[3]; + pResult->GetPoint(nPoint, dPoint); + if(!vtkMath::IsFinite(dPoint[0]) || !vtkMath::IsFinite(dPoint[1]) || !vtkMath::IsFinite(dPoint[2])) { + bFailed = true; + break; + } + } + // 空截面可能没有属性数组;非空截面必须完整保留发布数据的属性关联。 + const QVector& vecProperties = pData->getProperties(); + for(int nProperty = 0; !bFailed && pResult->GetNumberOfCells() > 0 && nProperty < vecProperties.size(); ++nProperty) { + const nmVolumePropertyDescriptor& oProperty = vecProperties.at(nProperty); + vtkDataSetAttributes* pAttributes = oProperty.m_eAssociation == NM_VolumeProperty_CellData + ? static_cast(pResult->GetCellData()) + : static_cast(pResult->GetPointData()); + vtkDataArray* pArray = pAttributes->GetArray(oProperty.m_sArrayName.toUtf8().constData()); + const vtkIdType nExpected = oProperty.m_eAssociation == NM_VolumeProperty_CellData + ? pResult->GetNumberOfCells() : pResult->GetNumberOfPoints(); + bFailed = pArray == nullptr || pArray->GetNumberOfComponents() != 1 || pArray->GetNumberOfTuples() != nExpected; + } + if(!bFailed) { + vtkSmartPointer pCandidate = vtkSmartPointer::New(); + // 与临时 Cutter 完全断开;后续视图刷新不能重新执行切片。 + pCandidate->DeepCopy(pResult); + // 节点插值也可能产生非有限数,只规范化独立输出,绝不写入冻结的原始数组。 + for(int nProperty = 0; nProperty < vecProperties.size(); ++nProperty) { + const nmVolumePropertyDescriptor& oProperty = vecProperties.at(nProperty); + vtkDataArray* pArray = oProperty.m_eAssociation == NM_VolumeProperty_CellData + ? pCandidate->GetCellData()->GetArray(oProperty.m_sArrayName.toUtf8().constData()) + : pCandidate->GetPointData()->GetArray(oProperty.m_sArrayName.toUtf8().constData()); + for(vtkIdType nTuple = 0; pArray != nullptr && nTuple < pArray->GetNumberOfTuples(); ++nTuple) { + if(!vtkMath::IsFinite(pArray->GetComponent(nTuple, 0))) { + pArray->SetComponent(nTuple, 0, std::numeric_limits::quiet_NaN()); + } + } + } + pOutput = pCandidate; + return true; + } + } + if(pError != nullptr) *pError = QCoreApplication::translate("nmVolumeSlicePipeline", "Unable to compute a valid slice. The previous view has been kept."); + } catch(const std::bad_alloc&) { + if(pError != nullptr) *pError = QCoreApplication::translate("nmVolumeSlicePipeline", "Not enough memory to compute the slice."); + } + return false; +} diff --git a/Src/nmNum/nmSubWxs/nmVolumeSliceSettings.cpp b/Src/nmNum/nmSubWxs/nmVolumeSliceSettings.cpp new file mode 100644 index 00000000..94dd4b35 --- /dev/null +++ b/Src/nmNum/nmSubWxs/nmVolumeSliceSettings.cpp @@ -0,0 +1,54 @@ +#include "nmVolumeSliceSettings.h" +#include "nmVolumeData.h" +#include +#include +#include + +nmVolumeSliceSettings::nmVolumeSliceSettings() + : m_bShowPlane(true), m_bShowSource(false) +{ + for(int nAxis = 0; nAxis < 3; ++nAxis) { + m_dOrigin[nAxis] = 0.0; + m_dNormal[nAxis] = nAxis == 2 ? 1.0 : 0.0; + } +} + +nmVolumeSliceSettings nmVolumeSliceSettings::fromData(const nmVolumeData& oData) +{ + nmVolumeSliceSettings oSettings; + double dBounds[6]; + oData.getBounds(dBounds); + for(int nAxis = 0; nAxis < 3; ++nAxis) { + // 避免两个同号大坐标相加溢出。 + oSettings.m_dOrigin[nAxis] = dBounds[2 * nAxis] * 0.5 + dBounds[2 * nAxis + 1] * 0.5; + } + oSettings.m_sPropertyId = oData.getInitialPropertyId(); + return oSettings; +} + +bool nmVolumeSliceSettings::normalize(QString* pError) +{ + if(pError != nullptr) pError->clear(); + double dMaximum = 0.0; + for(int nAxis = 0; nAxis < 3; ++nAxis) { + if(!vtkMath::IsFinite(m_dOrigin[nAxis]) || !vtkMath::IsFinite(m_dNormal[nAxis])) { + if(pError != nullptr) *pError = QCoreApplication::translate("nmVolumeSliceSettings", "Origin and normal must contain finite numbers."); + return false; + } + dMaximum = qMax(dMaximum, std::fabs(m_dNormal[nAxis])); + } + if(dMaximum == 0.0) { + if(pError != nullptr) *pError = QCoreApplication::translate("nmVolumeSliceSettings", "The plane normal must not be zero."); + return false; + } + // 先缩放再求模,兼容科学计数法输入的极大、极小非零法向。 + double dNormal[3]; + double dLengthSquared = 0.0; + for(int nAxis = 0; nAxis < 3; ++nAxis) { + dNormal[nAxis] = m_dNormal[nAxis] / dMaximum; + dLengthSquared += dNormal[nAxis] * dNormal[nAxis]; + } + const double dLength = std::sqrt(dLengthSquared); + for(int nAxis = 0; nAxis < 3; ++nAxis) m_dNormal[nAxis] = dNormal[nAxis] / dLength; + return true; +} diff --git a/Src/nmNum/nmSubWxs/nmWxPebiResultControlBar.cpp b/Src/nmNum/nmSubWxs/nmWxPebiResultControlBar.cpp index 0c00ac64..09e0596a 100644 --- a/Src/nmNum/nmSubWxs/nmWxPebiResultControlBar.cpp +++ b/Src/nmNum/nmSubWxs/nmWxPebiResultControlBar.cpp @@ -5,6 +5,7 @@ #include "nmPebiResultPropertyDisplayText.h" #include "nmPebiResultSceneController.h" #include "nmVTKMeshLayer.h" +#include "nmVolumeSceneController.h" #include "nmWxPebiResultAnimationSettingsDlg.h" #include "nmWxPebiResultColorScaleSettingsDlg.h" @@ -57,6 +58,7 @@ nmWxPebiResultControlBar::nmWxPebiResultControlBar( : QWidget(pParent), m_pPlaybackController(nullptr), m_pSceneController(pSceneController), + m_pVolumeSceneController(nullptr), m_pDisplayModeCombo(nullptr), m_pPropertyCombo(nullptr), m_pColorSettingsButton(nullptr), @@ -104,11 +106,33 @@ void nmWxPebiResultControlBar::stopPlayback() void nmWxPebiResultControlBar::synchronizeFromScene() { + if(m_pVolumeSceneController != nullptr) { + refreshFromVolumeController(); + return; + } if(m_pPlaybackController != nullptr) { m_pPlaybackController->synchronizeFromScene(); } } +void nmWxPebiResultControlBar::setVolumeSceneController( + nmVolumeSceneController* pController) +{ + if(m_pVolumeSceneController != pController) { + if(pController != nullptr) stopPlayback(); + m_pVolumeSceneController = pController; + // 两类数据可能拥有相同属性名,切换来源时必须使列表缓存失效。 + m_sDisplayedVolumeDataId.clear(); + m_sDisplayedSnapshotId.clear(); + m_nDisplayedBindingRevision = 0; + m_listDisplayedPropertyIds.clear(); + clearTimeItems(); + // 两种数据源共用 createUi 的宽度与伸缩策略,切换时不重新分配底栏空间。 + m_pPropertyCombo->setToolTip(tr("Display property")); + } + synchronizeFromScene(); +} + nmPebiResultPlaybackController* nmWxPebiResultControlBar::getPlaybackController() const { @@ -161,7 +185,7 @@ void nmWxPebiResultControlBar::onDisplayModeIndexChanged(int nIndex) m_pPlaybackController == nullptr || m_pSceneController == nullptr || m_bExportInProgress || m_bExternalInteractionLocked || m_pPlaybackController->isPlaying() || - !m_pSceneController->hasSnapshot() || nIndex < 0 || + (m_pVolumeSceneController == nullptr && !m_pSceneController->hasSnapshot()) || nIndex < 0 || nIndex >= m_pDisplayModeCombo->count()) { return; } @@ -176,6 +200,13 @@ void nmWxPebiResultControlBar::onDisplayModeIndexChanged(int nIndex) refreshFromController(); return; } + if(m_pVolumeSceneController != nullptr) { + if(!m_pVolumeSceneController->hasData() || + eMode == m_pVolumeSceneController->getMeshDisplayMode()) return; + m_pVolumeSceneController->setMeshDisplayMode(eMode); + emit operationSucceeded(); + return; + } if(eMode == m_pSceneController->getMeshDisplayMode()) { return; } @@ -185,11 +216,23 @@ void nmWxPebiResultControlBar::onDisplayModeIndexChanged(int nIndex) void nmWxPebiResultControlBar::onPropertyIndexChanged(int nIndex) { - if(m_pPlaybackController == nullptr || m_bExportInProgress || + if(m_pPropertyCombo == nullptr || !m_pPropertyCombo->isEnabled() || + nIndex >= m_pPropertyCombo->count() || + m_pPlaybackController == nullptr || m_bExportInProgress || m_bExternalInteractionLocked || m_pPlaybackController->isPlaying() || nIndex < 0) { return; } + if(m_pVolumeSceneController != nullptr) { + QString sError; + const bool bApplied = m_pVolumeSceneController->applyProperty( + m_pPropertyCombo->itemData(nIndex).toString(), &sError); + // 失败时恢复到场景中仍在使用的属性,不能让下拉框与当前画面错位。 + refreshFromVolumeController(); + if(bApplied) emit operationSucceeded(); + else emit operationFailed(sError); + return; + } if(m_pPlaybackController->selectProperty( m_pPropertyCombo->itemData(nIndex).toString())) { emit operationSucceeded(); @@ -198,6 +241,7 @@ void nmWxPebiResultControlBar::onPropertyIndexChanged(int nIndex) void nmWxPebiResultControlBar::onTimeIndexChanged(int nIndex) { + if(m_pVolumeSceneController != nullptr) return; if(m_pPlaybackController == nullptr || m_bExportInProgress || m_bExternalInteractionLocked || m_pPlaybackController->isPlaying() || nIndex < 0) { @@ -211,6 +255,7 @@ void nmWxPebiResultControlBar::onTimeIndexChanged(int nIndex) void nmWxPebiResultControlBar::onColorSettingsClicked() { + if(m_pVolumeSceneController != nullptr) return; if(m_pPlaybackController == nullptr || m_pSceneController == nullptr || m_bExportInProgress || m_bExternalInteractionLocked || @@ -230,6 +275,7 @@ void nmWxPebiResultControlBar::onColorSettingsClicked() void nmWxPebiResultControlBar::onFirstFrameClicked() { + if(m_pVolumeSceneController != nullptr) return; if(m_pPlaybackController == nullptr || m_bExportInProgress || m_bExternalInteractionLocked || m_pPlaybackController->isPlaying()) { @@ -242,6 +288,7 @@ void nmWxPebiResultControlBar::onFirstFrameClicked() void nmWxPebiResultControlBar::onPreviousFrameClicked() { + if(m_pVolumeSceneController != nullptr) return; if(m_pPlaybackController == nullptr || m_bExportInProgress || m_bExternalInteractionLocked || m_pPlaybackController->isPlaying()) { @@ -254,6 +301,7 @@ void nmWxPebiResultControlBar::onPreviousFrameClicked() void nmWxPebiResultControlBar::onNextFrameClicked() { + if(m_pVolumeSceneController != nullptr) return; if(m_pPlaybackController == nullptr || m_bExportInProgress || m_bExternalInteractionLocked || m_pPlaybackController->isPlaying()) { @@ -266,6 +314,7 @@ void nmWxPebiResultControlBar::onNextFrameClicked() void nmWxPebiResultControlBar::onLastFrameClicked() { + if(m_pVolumeSceneController != nullptr) return; if(m_pPlaybackController == nullptr || m_bExportInProgress || m_bExternalInteractionLocked || m_pPlaybackController->isPlaying()) { @@ -278,6 +327,7 @@ void nmWxPebiResultControlBar::onLastFrameClicked() void nmWxPebiResultControlBar::onPlayPauseClicked() { + if(m_pVolumeSceneController != nullptr) return; if(m_pPlaybackController == nullptr || m_bExportInProgress || m_bExternalInteractionLocked) { return; @@ -289,6 +339,7 @@ void nmWxPebiResultControlBar::onPlayPauseClicked() void nmWxPebiResultControlBar::onAnimationSettingsClicked() { + if(m_pVolumeSceneController != nullptr) return; if(m_pPlaybackController == nullptr || m_bExportInProgress || m_bExternalInteractionLocked || m_pPlaybackController->isPlaying()) { @@ -308,6 +359,7 @@ void nmWxPebiResultControlBar::onAnimationSettingsClicked() void nmWxPebiResultControlBar::onExportAnimationTriggered() { + if(m_pVolumeSceneController != nullptr) return; if(m_pPlaybackController == nullptr || m_bExportInProgress || m_bExternalInteractionLocked || m_pPlaybackController->isPlaying() || @@ -320,6 +372,11 @@ void nmWxPebiResultControlBar::onExportAnimationTriggered() void nmWxPebiResultControlBar::refreshFromController() { + // 即使旧回放控制器发来状态通知,也只能刷新当前正在查看的数据源。 + if(m_pVolumeSceneController != nullptr) { + refreshFromVolumeController(); + return; + } if(m_pDisplayModeCombo != nullptr && m_pSceneController != nullptr) { const int nDisplayModeIndex = findItemByData( m_pDisplayModeCombo, @@ -458,6 +515,51 @@ void nmWxPebiResultControlBar::refreshFromController() updatePlayPauseButton(bPlaying); } +void nmWxPebiResultControlBar::refreshFromVolumeController() +{ + if(m_pVolumeSceneController == nullptr) return; + const QSharedPointer pData = m_pVolumeSceneController->getData(); + const QString sDataId = pData.isNull() ? QString() : pData->getDataId(); + const bool bPropertyBlocked = m_pPropertyCombo->blockSignals(true); + if(pData.isNull() || m_sDisplayedVolumeDataId != sDataId) { + m_pPropertyCombo->clear(); + if(!pData.isNull()) { + const QVector& vecProperties = pData->getProperties(); + for(int nIndex = 0; nIndex < vecProperties.size(); ++nIndex) { + const nmVolumePropertyDescriptor& oProperty = vecProperties.at(nIndex); + // 仅显示原始属性名;同名单元、节点属性仍由不同 ID 区分,不合并条目。 + m_pPropertyCombo->addItem(oProperty.m_sArrayName, oProperty.m_sPropertyId); + m_pPropertyCombo->setItemData(nIndex, oProperty.m_sArrayName, Qt::ToolTipRole); + } + if(vecProperties.isEmpty()) m_pPropertyCombo->addItem(tr("Solid Color"), QString()); + } + m_sDisplayedVolumeDataId = sDataId; + } + m_pPropertyCombo->setCurrentIndex(findItemByData(m_pPropertyCombo, + m_pVolumeSceneController->getSliceSettings().m_sPropertyId)); + m_pPropertyCombo->setToolTip(m_pPropertyCombo->currentText()); + m_pPropertyCombo->blockSignals(bPropertyBlocked); + const bool bModeBlocked = m_pDisplayModeCombo->blockSignals(true); + m_pDisplayModeCombo->setCurrentIndex(findItemByData(m_pDisplayModeCombo, + static_cast(m_pVolumeSceneController->getMeshDisplayMode()))); + m_pDisplayModeCombo->blockSignals(bModeBlocked); + + const bool bAvailable = !pData.isNull() && !m_bExportInProgress && !m_bExternalInteractionLocked; + m_pDisplayModeCombo->setEnabled(bAvailable); + m_pPropertyCombo->setEnabled(bAvailable && !pData->getProperties().isEmpty()); + // 外部数据没有计算时间轴;所有旧按钮保留原位置,仅禁用不适用的操作。 + clearTimeItems(); + m_pColorSettingsButton->setEnabled(false); + m_pFirstButton->setEnabled(false); + m_pPreviousButton->setEnabled(false); + m_pNextButton->setEnabled(false); + m_pLastButton->setEnabled(false); + m_pPlayPauseButton->setEnabled(false); + m_pSettingsButton->setEnabled(false); + m_pExportAction->setEnabled(false); + updatePlayPauseButton(false); +} + void nmWxPebiResultControlBar::updatePlayPauseButton(bool bPlaying) { if(m_bDisplayedPlaying == bPlaying) { @@ -484,6 +586,7 @@ void nmWxPebiResultControlBar::createUi() QLabel* pDisplayLabel = new QLabel(tr("Display"), this); pDisplayLabel->setAlignment(Qt::AlignLeft | Qt::AlignVCenter); m_pDisplayModeCombo = new QComboBox(this); + m_pDisplayModeCombo->setObjectName(QLatin1String("resultDisplayModeCombo")); m_pDisplayModeCombo->addItem( tr("Point"), QVariant(static_cast( @@ -516,6 +619,7 @@ void nmWxPebiResultControlBar::createUi() pPropertyLabel->setAlignment(Qt::AlignLeft | Qt::AlignVCenter); m_pPropertyCombo = new QComboBox(this); + m_pPropertyCombo->setObjectName(QLatin1String("resultPropertyCombo")); m_pPropertyCombo->setMinimumWidth(PROPERTY_COMBO_MINIMUM_WIDTH); m_pPropertyCombo->setMaximumWidth(PROPERTY_COMBO_MAXIMUM_WIDTH); m_pPropertyCombo->setMinimumHeight(24); @@ -534,6 +638,7 @@ void nmWxPebiResultControlBar::createUi() this, QString::fromLatin1("NmPreviousStep.png"), tr("Previous frame")); m_pTimeCombo = new QComboBox(this); + m_pTimeCombo->setObjectName(QLatin1String("resultTimeCombo")); m_pTimeCombo->setMinimumWidth(TIME_COMBO_MINIMUM_WIDTH); m_pTimeCombo->setMaximumWidth(TIME_COMBO_MAXIMUM_WIDTH); m_pTimeCombo->setMinimumContentsLength( diff --git a/Src/nmNum/nmSubWxs/nmWxPebiResultViewWidget.cpp b/Src/nmNum/nmSubWxs/nmWxPebiResultViewWidget.cpp index a56d060d..560c4b28 100644 --- a/Src/nmNum/nmSubWxs/nmWxPebiResultViewWidget.cpp +++ b/Src/nmNum/nmSubWxs/nmWxPebiResultViewWidget.cpp @@ -1,6 +1,8 @@ #include "nmWxPebiResultViewWidget.h" #include "nmVolumeImportTask.h" #include "nmVolumeSceneController.h" +#include "nmVolumeSliceInteraction.h" +#include "nmWxVolumeSliceSettingsDlg.h" #include "nmVTKViewState.h" #include #include @@ -820,6 +822,8 @@ public: nmWxPebiVolumeState() : m_pController(nullptr), m_pTask(nullptr), m_pProgress(nullptr), m_pImportAction(nullptr), m_pReturnAction(nullptr), m_pButton(nullptr), + m_pSliceAction(nullptr), m_pSliceSettingsAction(nullptr), + m_pSliceDialog(nullptr), m_pSliceInteraction(nullptr), m_bImported(false), m_bPendingSync(false), m_bRegionVisible(false), m_bRegionDialogVisible(false) {} nmVolumeSceneController* m_pController; ///< 窗口独占,必须在渲染容器之前析构。 @@ -828,6 +832,10 @@ public: QAction* m_pImportAction; ///< 按钮菜单和右键菜单共享的导入动作。 QAction* m_pReturnAction; ///< 仅外部模型模式启用的返回动作。 QToolButton* m_pButton; ///< 唯一新增的常驻切片工具按钮。 + QAction* m_pSliceAction; ///< 按钮菜单和右键菜单共享的切片显隐动作。 + QAction* m_pSliceSettingsAction; ///< 打开浮动切片参数窗口的共享动作。 + nmWxVolumeSliceSettingsDlg* m_pSliceDialog; ///< 按需创建并复用的非模态参数窗口。 + nmVolumeSliceInteraction* m_pSliceInteraction; ///< 窗口独占,负责辅助平面及安全事件转发。 nmVTKViewState m_oSavedView; ///< 第一次进入外部模式前捕获的计算结果视图。 nmVTKViewCommandController::ViewCommands m_eSavedCommands; ///< 原结果提供的视图命令能力。 bool m_bImported; ///< 当前画面是否为外部三维模型。 @@ -870,6 +878,12 @@ nmWxPebiResultViewWidget::~nmWxPebiResultViewWidget() m_pVolumeState->m_pTask->wait(); delete m_pVolumeState->m_pTask; } + closeVolumeSliceSettings(true); + delete m_pVolumeState->m_pSliceDialog; + m_pVolumeState->m_pSliceDialog = nullptr; + delete m_pVolumeState->m_pSliceInteraction; + m_pVolumeState->m_pSliceInteraction = nullptr; + if(m_pControlBar != nullptr) m_pControlBar->setVolumeSceneController(nullptr); delete m_pVolumeState->m_pController; delete m_pVolumeState; m_pVolumeState = nullptr; @@ -992,6 +1006,10 @@ void nmWxPebiResultViewWidget::appendContextMenuActions(QMenu* pMenu) return; } pMenu->addAction(m_pVolumeState->m_pImportAction); + pMenu->addSeparator(); + pMenu->addAction(m_pVolumeState->m_pSliceAction); + pMenu->addAction(m_pVolumeState->m_pSliceSettingsAction); + pMenu->addSeparator(); pMenu->addAction(m_pVolumeState->m_pReturnAction); pMenu->addSeparator(); pMenu->addAction(m_pFilterAction); @@ -1011,7 +1029,7 @@ void nmWxPebiResultViewWidget::onControlOperationFailed( const QString& sError) { // 绑定失败优先显示;输入修改不再提示,也不屏蔽后续真实操作错误。 - if(!m_sBindingError.isEmpty()) { + if(!isImportedVolumeMode() && !m_sBindingError.isEmpty()) { return; } m_sOperationError = sError; @@ -1020,6 +1038,17 @@ void nmWxPebiResultViewWidget::onControlOperationFailed( void nmWxPebiResultViewWidget::onControlOperationSucceeded() { + if(isImportedVolumeMode()) { + // 底栏的属性切换只同步已应用属性,不覆盖浮动窗口里尚未提交的平面坐标。 + if(m_pVolumeState->m_pSliceDialog != nullptr) { + m_pVolumeState->m_pSliceDialog->setAppliedProperty( + m_pVolumeState->m_pController->getSliceSettings().m_sPropertyId); + } + m_sOperationError.clear(); + synchronizeVolumeActions(); + refreshStatusBar(); + return; + } if(m_sOperationError.isEmpty()) { return; } @@ -1511,10 +1540,8 @@ void nmWxPebiResultViewWidget::onScalarBarToggled(bool bVisible) void nmWxPebiResultViewWidget::onScaleGridToggled(bool bVisible) { - if(isImportedVolumeMode()) { - m_pVolumeState->m_pController->setScaleGridVisible(bVisible); - return; - } + // 比例网格只属于计算结果;外部模型模式不修改挂起结果的显示偏好。 + if(isImportedVolumeMode()) return; if(m_pControlBar != nullptr && m_pControlBar->isExternalInteractionLocked()) { synchronizeBusinessActions(); @@ -1568,6 +1595,13 @@ void nmWxPebiResultViewWidget::createUi() nmVTKViewCommandController::ViewCommand_PrintPreview); m_pSceneController = new nmPebiResultSceneController(m_pRenderContainer); m_pVolumeState->m_pController = new nmVolumeSceneController(m_pRenderContainer); + m_pVolumeState->m_pSliceInteraction = new nmVolumeSliceInteraction(m_pRenderContainer, this); + connect(m_pVolumeState->m_pSliceInteraction, SIGNAL(previewChanged(QString,nmVolumeSliceSettings)), + this, SLOT(onVolumeSlicePreview(QString,nmVolumeSliceSettings))); + connect(m_pVolumeState->m_pSliceInteraction, SIGNAL(applyRequested(QString,nmVolumeSliceSettings)), + this, SLOT(onVolumeSliceDragApplied(QString,nmVolumeSliceSettings))); + connect(m_pRenderContainer, SIGNAL(rotationCenterPickingChanged(bool)), + this, SLOT(synchronizeSliceInteraction())); connect(m_pRenderContainer, SIGNAL(viewportSizeChanged(int,int)), this, SLOT(onViewportSizeChanged(int,int))); const QSize oInitialViewportSize = @@ -1672,12 +1706,23 @@ void nmWxPebiResultViewWidget::createUi() m_pVolumeState->m_pImportAction->setObjectName(QLatin1String("importVolumeAction")); m_pVolumeState->m_pReturnAction = new QAction(tr("Return to Calculation Result"), this); m_pVolumeState->m_pReturnAction->setObjectName(QLatin1String("returnCalculationAction")); + m_pVolumeState->m_pSliceAction = new QAction(tr("Show Slice"), this); + m_pVolumeState->m_pSliceAction->setObjectName(QLatin1String("showVolumeSliceAction")); + m_pVolumeState->m_pSliceAction->setCheckable(true); + m_pVolumeState->m_pSliceSettingsAction = new QAction(tr("Slice Settings..."), this); + m_pVolumeState->m_pSliceSettingsAction->setObjectName(QLatin1String("volumeSliceSettingsAction")); pSliceMenu->addAction(m_pVolumeState->m_pImportAction); + pSliceMenu->addSeparator(); + pSliceMenu->addAction(m_pVolumeState->m_pSliceAction); + pSliceMenu->addAction(m_pVolumeState->m_pSliceSettingsAction); + pSliceMenu->addSeparator(); pSliceMenu->addAction(m_pVolumeState->m_pReturnAction); m_pVolumeState->m_pButton->setMenu(pSliceMenu); m_pToolBar->addWidget(m_pVolumeState->m_pButton); connect(m_pVolumeState->m_pImportAction, SIGNAL(triggered()), this, SLOT(onImportVolumeRequested())); connect(m_pVolumeState->m_pReturnAction, SIGNAL(triggered()), this, SLOT(onReturnToCalculationResult())); + connect(m_pVolumeState->m_pSliceAction, SIGNAL(toggled(bool)), this, SLOT(onVolumeSliceToggled(bool))); + connect(m_pVolumeState->m_pSliceSettingsAction, SIGNAL(triggered()), this, SLOT(onShowVolumeSliceSettings())); connect(m_pScalarBarAction, SIGNAL(toggled(bool)), this, SLOT(onScalarBarToggled(bool))); @@ -2394,9 +2439,13 @@ void nmWxPebiResultViewWidget::synchronizeBusinessActions() void nmWxPebiResultViewWidget::refreshStatusBar() { if(isImportedVolumeMode()) { - m_pStatusLabel->setStyleSheet("QLabel { color: #a32626; background: #fdecec; }"); - m_pStatusLabel->setText(m_sOperationError); - m_pStatusLabel->setVisible(!m_sOperationError.isEmpty()); + const bool bError = !m_sOperationError.isEmpty(); + const QString sStatus = bError ? m_sOperationError : + (m_pVolumeState->m_pController->isSliceEmpty() ? tr("The slice plane does not intersect the model.") : QString()); + m_pStatusLabel->setStyleSheet(bError ? "QLabel { color: #a32626; background: #fdecec; }" + : "QLabel { color: #725514; background: #fff7df; }"); + m_pStatusLabel->setText(sStatus); + m_pStatusLabel->setVisible(!sStatus.isEmpty()); return; } if(m_pStatusLabel == nullptr || m_pSceneController == nullptr || @@ -2458,12 +2507,21 @@ void nmWxPebiResultViewWidget::synchronizeVolumeActions() m_pVolumeState->m_pImportAction->setText(isImportedVolumeMode() ? tr("Replace 3D Model...") : tr("Import 3D Model...")); m_pVolumeState->m_pReturnAction->setEnabled(isImportedVolumeMode() && m_pVolumeState->m_pTask == nullptr); + const bool bSliceAvailable = isImportedVolumeMode() && m_pVolumeState->m_pTask == nullptr && m_pVolumeState->m_pController->hasData(); + m_pVolumeState->m_pSliceAction->setEnabled(bSliceAvailable); + m_pVolumeState->m_pSliceSettingsAction->setEnabled(bSliceAvailable); + setActionCheckedWithoutSignal(m_pVolumeState->m_pSliceAction, isImportedVolumeMode() && m_pVolumeState->m_pController->isSliceEnabled()); + if(m_pVolumeState->m_pSliceDialog != nullptr) m_pVolumeState->m_pSliceDialog->setEnabled(bSliceAvailable); + synchronizeSliceInteraction(); if(!isImportedVolumeMode()) return; nmVolumeSceneController* pVolume = m_pVolumeState->m_pController; + // 导入任务期间冻结底栏;平时只启用当前三维场景支持的显示及属性选择。 + m_pControlBar->setExternalInteractionLocked(m_pVolumeState->m_pTask != nullptr); + m_pControlBar->synchronizeFromScene(); setActionCheckedWithoutSignal(m_pScalarBarAction, pVolume->isScalarBarVisible()); - setActionCheckedWithoutSignal(m_pScaleGridAction, pVolume->isScaleGridVisible()); m_pScalarBarAction->setEnabled(pVolume->hasScalarProperty()); - m_pScaleGridAction->setEnabled(pVolume->hasData()); + // 与筛选等结果专用功能一致,保留比例网格入口,仅在外部三维模式下禁用。 + m_pScaleGridAction->setEnabled(false); m_pWellNamesAction->setEnabled(false); m_pFilterAction->setEnabled(false); m_pRegionPressureAction->setEnabled(false); @@ -2535,6 +2593,8 @@ void nmWxPebiResultViewWidget::onVolumeImportFinished() m_pStatusLabel->show(); return; } + // 候选模型绑定成功后才释放旧切片交互;失败、取消路径均保留原截面及草稿。 + closeVolumeSliceSettings(true); if(!isImportedVolumeMode()) { m_pVolumeState->m_oSavedView = oPreviousView; m_pVolumeState->m_eSavedCommands = m_pRenderContainer->getViewCommandController()->getCapabilities(); @@ -2554,8 +2614,8 @@ void nmWxPebiResultViewWidget::onVolumeImportFinished() nmVTKViewCommandController::ViewCommand_Right | nmVTKViewCommandController::ViewCommand_Isometric); m_pVolumeState->m_bImported = true; - m_pControlBar->setExternalInteractionLocked(true); - m_pControlBar->hide(); + m_pControlBar->setVolumeSceneController(m_pVolumeState->m_pController); + m_pControlBar->show(); m_pRenderContainer->restoreDefaultInteraction(); m_pVolumeState->m_pController->activate(true); m_sOperationError.clear(); @@ -2580,6 +2640,7 @@ void nmWxPebiResultViewWidget::onReturnToCalculationResult() return; } } + closeVolumeSliceSettings(true); m_pVolumeState->m_pController->hide(); m_pRenderContainer->setRotationCenterPickTarget(nullptr); m_pVolumeState->m_bImported = false; @@ -2613,9 +2674,165 @@ void nmWxPebiResultViewWidget::onReturnToCalculationResult() } m_pVolumeState->m_bPendingSync = false; m_pVolumeState->m_oSavedView = nmVTKViewState(); + m_pControlBar->setVolumeSceneController(nullptr); m_pControlBar->setExternalInteractionLocked(false); m_pControlBar->show(); m_pControlBar->synchronizeFromScene(); synchronizeBusinessActions(); refreshStatusBar(); } + +void nmWxPebiResultViewWidget::closeVolumeSliceSettings(bool bReleaseData) +{ + if(m_pVolumeState == nullptr) return; + if(m_pVolumeState->m_pSliceInteraction != nullptr) m_pVolumeState->m_pSliceInteraction->unbind(); + nmWxVolumeSliceSettingsDlg* pDialog = m_pVolumeState->m_pSliceDialog; + if(pDialog != nullptr) { + // 场景切换由外层统一 Render,不能由 hideEvent 在事务中途触发一次空白画面。 + const bool bBlocked = pDialog->blockSignals(true); + pDialog->hide(); + if(bReleaseData) pDialog->setData(QSharedPointer(), nmVolumeSliceSettings()); + pDialog->blockSignals(bBlocked); + } +} + +void nmWxPebiResultViewWidget::synchronizeSliceInteraction() +{ + if(m_pVolumeState == nullptr || m_pVolumeState->m_pSliceInteraction == nullptr) return; + nmWxVolumeSliceSettingsDlg* pDialog = m_pVolumeState->m_pSliceDialog; + const bool bEnabled = isImportedVolumeMode() && m_pVolumeState->m_pTask == nullptr && + m_pVolumeState->m_pController->isSliceEnabled() && pDialog != nullptr && pDialog->isVisible() && + m_pVolumeState->m_pController->getSliceSettings().m_bShowPlane && !m_pRenderContainer->isRotationCenterPicking(); + const bool bWasEnabled = m_pVolumeState->m_pSliceInteraction->isEnabled(); + m_pVolumeState->m_pSliceInteraction->setEnabled(bEnabled); + if(bWasEnabled != m_pVolumeState->m_pSliceInteraction->isEnabled()) { + // 拾取信号也会在场景绑定过程中发出,不能在该调用栈内立即 Render。 + QMetaObject::invokeMethod(this, "refreshVolumeSliceView", Qt::QueuedConnection); + } +} + +void nmWxPebiResultViewWidget::refreshVolumeSliceView() +{ + if(isImportedVolumeMode()) m_pRenderContainer->renderView(); +} + +void nmWxPebiResultViewWidget::onVolumeSliceToggled(bool bEnabled) +{ + if(!isImportedVolumeMode() || m_pVolumeState->m_pTask != nullptr) { synchronizeVolumeActions(); return; } + QString sError; + if(!m_pVolumeState->m_pController->setSliceEnabled(bEnabled, &sError)) { + m_sOperationError = sError.isEmpty() ? tr("Unable to display the slice.") : sError; + } else { + m_sOperationError.clear(); + if(!bEnabled) { + closeVolumeSliceSettings(false); + m_pRenderContainer->renderView(); + } + } + synchronizeVolumeActions(); + refreshStatusBar(); +} + +void nmWxPebiResultViewWidget::onShowVolumeSliceSettings() +{ + if(!isImportedVolumeMode() || m_pVolumeState->m_pTask != nullptr) return; + nmVolumeSceneController* pController = m_pVolumeState->m_pController; + QString sError; + if(!pController->setSliceEnabled(true, &sError)) { + m_sOperationError = sError.isEmpty() ? tr("Unable to display the slice.") : sError; + refreshStatusBar(); + return; + } + if(m_pVolumeState->m_pSliceDialog == nullptr) { + nmWxVolumeSliceSettingsDlg* pDialog = new nmWxVolumeSliceSettingsDlg(this); + m_pVolumeState->m_pSliceDialog = pDialog; + connect(pDialog, SIGNAL(applyRequested()), this, SLOT(onApplyVolumeSlice())); + connect(pDialog, SIGNAL(previewChanged()), this, SLOT(onVolumeSliceDraftChanged())); + connect(pDialog, SIGNAL(displayOptionsChanged(bool,bool)), this, SLOT(onVolumeSliceDisplayChanged(bool,bool))); + connect(pDialog, SIGNAL(dialogHidden()), this, SLOT(onVolumeSliceDialogHidden())); + } + nmWxVolumeSliceSettingsDlg* pDialog = m_pVolumeState->m_pSliceDialog; + if(!pDialog->isVisible()) { + const QSharedPointer pData = pController->getData(); + if(pData.isNull()) return; + const nmVolumeSliceSettings oSettings = pController->getSliceSettings(); + pDialog->setData(pData, oSettings); + double dBounds[6]; + pData->getBounds(dBounds); + m_pVolumeState->m_pSliceInteraction->bind(pData->getDataId(), dBounds, oSettings); + } + pDialog->show(); + pDialog->raise(); + pDialog->activateWindow(); + m_sOperationError.clear(); + synchronizeVolumeActions(); + refreshStatusBar(); + m_pRenderContainer->renderView(); +} + +void nmWxPebiResultViewWidget::onApplyVolumeSlice() +{ + if(!isImportedVolumeMode() || m_pVolumeState->m_pTask != nullptr || + m_pVolumeState->m_pSliceDialog == nullptr || !m_pVolumeState->m_pSliceDialog->isVisible()) return; + nmVolumeSliceSettings oSettings; + QString sError; + if(!m_pVolumeState->m_pSliceDialog->getDraft(oSettings, &sError)) { + m_pVolumeState->m_pSliceDialog->setError(sError); + return; + } + if(!m_pVolumeState->m_pController->applySliceSettings(oSettings, &sError)) { + m_sOperationError = sError.isEmpty() ? tr("Unable to display the slice.") : sError; + m_pVolumeState->m_pSliceDialog->setError(m_sOperationError); + } else { + m_sOperationError.clear(); + const nmVolumeSliceSettings oApplied = m_pVolumeState->m_pController->getSliceSettings(); + m_pVolumeState->m_pSliceDialog->setAppliedSettings(oApplied); + m_pVolumeState->m_pSliceInteraction->setPreview(oApplied); + } + synchronizeVolumeActions(); + refreshStatusBar(); +} + +void nmWxPebiResultViewWidget::onVolumeSliceDraftChanged() +{ + if(!isImportedVolumeMode() || m_pVolumeState->m_pTask != nullptr || m_pVolumeState->m_pSliceDialog == nullptr) return; + nmVolumeSliceSettings oDraft; + if(m_pVolumeState->m_pSliceDialog->getDraft(oDraft)) { + m_pVolumeState->m_pSliceInteraction->setPreview(oDraft); + m_pRenderContainer->renderView(); + } +} + +void nmWxPebiResultViewWidget::onVolumeSliceDisplayChanged(bool bShowPlane, bool bShowSource) +{ + if(!isImportedVolumeMode() || m_pVolumeState->m_pTask != nullptr) return; + m_pVolumeState->m_pController->setSliceDisplayOptions(bShowPlane, bShowSource); + synchronizeSliceInteraction(); + m_pRenderContainer->renderView(); +} + +void nmWxPebiResultViewWidget::onVolumeSliceDialogHidden() +{ + if(m_pVolumeState == nullptr || m_pVolumeState->m_pSliceInteraction == nullptr) return; + m_pVolumeState->m_pSliceInteraction->unbind(); + m_pRenderContainer->renderView(); +} + +void nmWxPebiResultViewWidget::onVolumeSlicePreview(const QString& sDataId, const nmVolumeSliceSettings& oSettings) +{ + if(!isImportedVolumeMode() || m_pVolumeState->m_pTask != nullptr || + !m_pVolumeState->m_pController->isSliceEnabled() || m_pVolumeState->m_pSliceDialog == nullptr || + !m_pVolumeState->m_pSliceDialog->isVisible()) return; + const QSharedPointer pData = m_pVolumeState->m_pController->getData(); + if(pData.isNull() || pData->getDataId() != sDataId) return; + m_pVolumeState->m_pSliceDialog->setPlanePreview(oSettings); +} + +void nmWxPebiResultViewWidget::onVolumeSliceDragApplied(const QString& sDataId, const nmVolumeSliceSettings& oSettings) +{ + Q_UNUSED(oSettings); + if(!isImportedVolumeMode() || m_pVolumeState->m_pTask != nullptr || !m_pVolumeState->m_pController->isSliceEnabled()) return; + const QSharedPointer pData = m_pVolumeState->m_pController->getData(); + if(pData.isNull() || pData->getDataId() != sDataId) return; + onApplyVolumeSlice(); +} diff --git a/Src/nmNum/nmSubWxs/nmWxVolumeSliceSettingsDlg.cpp b/Src/nmNum/nmSubWxs/nmWxVolumeSliceSettingsDlg.cpp new file mode 100644 index 00000000..fc2e9818 --- /dev/null +++ b/Src/nmNum/nmSubWxs/nmWxVolumeSliceSettingsDlg.cpp @@ -0,0 +1,317 @@ +#include "nmWxVolumeSliceSettingsDlg.h" +#include "nmParameterField.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +nmWxVolumeSliceSettingsDlg::nmWxVolumeSliceSettingsDlg(QWidget* pParent) + : iDlgBase(pParent), m_pShowPlaneCheck(nullptr), + m_pShowSourceCheck(nullptr), m_pErrorLabel(nullptr), m_pApplyButton(nullptr), m_bUpdating(false) +{ + createUi(); + writeSettings(m_oApplied); + resize(minimumWidth(), sizeHint().height()); +} + +void nmWxVolumeSliceSettingsDlg::createUi() +{ + setWindowTitle(tr("Slice Settings")); + setObjectName(QLatin1String("volumeSliceSettingsDialog")); + setModal(false); + setAttribute(Qt::WA_DeleteOnClose, false); + // 复用编辑井、储层特性的基类主题,同时保留工具窗口的非模态交互及 Esc 关闭行为。 + setWindowFlags((windowFlags() & ~Qt::WindowType_Mask & + ~Qt::WindowContextHelpButtonHint) | Qt::Tool); + setIgnoreEsc(false); + + // 六个分量各占一行,共用标签列和输入列;原点区与法向区分别放置对应快捷操作。 + const int nEditorHeight = nmParameterField::editorHeight(); + const int nEditorMinimumWidth = nmParameterField::editorMinimumWidth(); + QGroupBox* pPlaneGroup = new QGroupBox(tr("Plane Parameters"), this); + pPlaneGroup->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + QGridLayout* pPlaneLayout = new QGridLayout(pPlaneGroup); + pPlaneLayout->setContentsMargins(8, 15, 8, 10); + pPlaneLayout->setHorizontalSpacing(6); + pPlaneLayout->setVerticalSpacing(6); + pPlaneLayout->setColumnStretch(1, 1); + int nFieldLabelWidth = 0; + const char* pAxes[3] = { "X", "Y", "Z" }; + for(int nAxis = 0; nAxis < 3; ++nAxis) { + QLabel* pOriginLabel = new QLabel(tr("Origin %1").arg(QLatin1String(pAxes[nAxis])), pPlaneGroup); + QLabel* pNormalLabel = new QLabel(tr("Normal %1").arg(QLatin1String(pAxes[nAxis])), pPlaneGroup); + pOriginLabel->setAlignment(Qt::AlignRight | Qt::AlignVCenter); + pNormalLabel->setAlignment(Qt::AlignRight | Qt::AlignVCenter); + nFieldLabelWidth = qMax(nFieldLabelWidth, + qMax(pOriginLabel->sizeHint().width(), pNormalLabel->sizeHint().width())); + pPlaneLayout->addWidget(pOriginLabel, nAxis, 0); + pPlaneLayout->addWidget(pNormalLabel, nAxis + 5, 0); + m_pOriginEdits[nAxis] = new QLineEdit(pPlaneGroup); + m_pNormalEdits[nAxis] = new QLineEdit(pPlaneGroup); + m_pOriginEdits[nAxis]->setObjectName(QString::fromLatin1("sliceOrigin") + QLatin1String(pAxes[nAxis])); + m_pNormalEdits[nAxis]->setObjectName(QString::fromLatin1("sliceNormal") + QLatin1String(pAxes[nAxis])); + m_pOriginEdits[nAxis]->setAlignment(Qt::AlignLeft | Qt::AlignVCenter); + m_pNormalEdits[nAxis]->setAlignment(Qt::AlignLeft | Qt::AlignVCenter); + m_pOriginEdits[nAxis]->setMinimumWidth(nEditorMinimumWidth); + m_pNormalEdits[nAxis]->setMinimumWidth(nEditorMinimumWidth); + m_pOriginEdits[nAxis]->setFixedHeight(nEditorHeight); + m_pNormalEdits[nAxis]->setFixedHeight(nEditorHeight); + m_pOriginEdits[nAxis]->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + m_pNormalEdits[nAxis]->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + pPlaneLayout->addWidget(m_pOriginEdits[nAxis], nAxis, 1); + pPlaneLayout->addWidget(m_pNormalEdits[nAxis], nAxis + 5, 1); + connect(m_pOriginEdits[nAxis], SIGNAL(textChanged(QString)), this, SLOT(onDraftChanged())); + connect(m_pNormalEdits[nAxis], SIGNAL(textChanged(QString)), this, SLOT(onDraftChanged())); + } + + // 模型中心仅调整原点,放在原点 Z 下方,保持按钮紧凑并与输入框右边界对齐。 + QToolButton* pCenterButton = new QToolButton(pPlaneGroup); + pCenterButton->setText(tr("Model Center")); + pCenterButton->setAutoRaise(true); + pCenterButton->setMinimumWidth(72); + pCenterButton->setFixedHeight(nEditorHeight); + pCenterButton->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed); + pPlaneLayout->addWidget(pCenterButton, 3, 1, Qt::AlignRight); + pPlaneLayout->setRowMinimumHeight(4, 6); + connect(pCenterButton, SIGNAL(clicked()), this, SLOT(onCenterRequested())); + + // 控件按轴成对创建,显式调整 Tab 顺序,使键盘输入顺序与从上到下的布局一致。 + setTabOrder(m_pOriginEdits[0], m_pOriginEdits[1]); + setTabOrder(m_pOriginEdits[1], m_pOriginEdits[2]); + setTabOrder(m_pOriginEdits[2], pCenterButton); + setTabOrder(pCenterButton, m_pNormalEdits[0]); + setTabOrder(m_pNormalEdits[0], m_pNormalEdits[1]); + setTabOrder(m_pNormalEdits[1], m_pNormalEdits[2]); + + // 三个方向按钮放在法向 Z 下方,等宽分配输入列空间,整体左右边界与输入框一致。 + QHBoxLayout* pShortcutLayout = new QHBoxLayout; + pShortcutLayout->setContentsMargins(0, 0, 0, 0); + pShortcutLayout->setSpacing(6); + const char* pPlanes[3] = { "XY", "XZ", "YZ" }; + QWidget* pPreviousShortcutWidget = m_pNormalEdits[2]; + for(int nPlane = 0; nPlane < 3; ++nPlane) { + QToolButton* pButton = new QToolButton(pPlaneGroup); + pButton->setText(QString::fromLatin1(pPlanes[nPlane])); + pButton->setProperty("normalAxis", 2 - nPlane); + pButton->setAutoRaise(true); + pButton->setMinimumWidth(44); + pButton->setFixedHeight(nEditorHeight); + pButton->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + pShortcutLayout->addWidget(pButton, 1); + setTabOrder(pPreviousShortcutWidget, pButton); + pPreviousShortcutWidget = pButton; + connect(pButton, SIGNAL(clicked()), this, SLOT(onAxisRequested())); + } + pPlaneLayout->addLayout(pShortcutLayout, 8, 1); + + // 着色属性及渲染模式统一使用旧底栏;浮动窗口只保留平面相关的显示选项。 + QGroupBox* pDisplayGroup = new QGroupBox(tr("Display Options"), this); + pDisplayGroup->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + QGridLayout* pDisplayLayout = new QGridLayout(pDisplayGroup); + pDisplayLayout->setContentsMargins(8, 15, 8, 10); + pDisplayLayout->setHorizontalSpacing(6); + pDisplayLayout->setVerticalSpacing(6); + pPlaneLayout->setColumnMinimumWidth(0, nFieldLabelWidth); + pDisplayLayout->setColumnMinimumWidth(0, nFieldLabelWidth); + pDisplayLayout->setColumnStretch(1, 1); + m_pShowPlaneCheck = new QCheckBox(tr("Show Plane"), pDisplayGroup); + m_pShowSourceCheck = new QCheckBox(tr("Show Original Model"), pDisplayGroup); + m_pShowPlaneCheck->setObjectName(QLatin1String("sliceShowPlane")); + m_pShowSourceCheck->setObjectName(QLatin1String("sliceShowSource")); + // 拖动说明放在显示平面的悬停提示中,减少常驻文字对参数布局的干扰。 + m_pShowPlaneCheck->setToolTip(tr("Drag the center or outline to move the plane; drag arrows to rotate.")); + QHBoxLayout* pDisplayOptionsLayout = new QHBoxLayout; + pDisplayOptionsLayout->setContentsMargins(0, 0, 0, 0); + pDisplayOptionsLayout->setSpacing(12); + pDisplayOptionsLayout->addWidget(m_pShowPlaneCheck); + pDisplayOptionsLayout->addWidget(m_pShowSourceCheck); + pDisplayOptionsLayout->addStretch(); + pDisplayLayout->addLayout(pDisplayOptionsLayout, 0, 1); + setTabOrder(pPreviousShortcutWidget, m_pShowPlaneCheck); + setTabOrder(m_pShowPlaneCheck, m_pShowSourceCheck); + connect(m_pShowPlaneCheck, SIGNAL(toggled(bool)), this, SLOT(onDisplayOptionsChanged())); + connect(m_pShowSourceCheck, SIGNAL(toggled(bool)), this, SLOT(onDisplayOptionsChanged())); + + m_pErrorLabel = new QLabel(this); + m_pErrorLabel->setWordWrap(true); + QPalette oErrorPalette = m_pErrorLabel->palette(); + oErrorPalette.setColor(QPalette::WindowText, QColor(Qt::red)); + m_pErrorLabel->setPalette(oErrorPalette); + m_pErrorLabel->hide(); + + // 底部沿用编辑井的边距及框架图标,应用型窗口的三个操作集中靠右排列。 + QHBoxLayout* pFooterLayout = new QHBoxLayout; + pFooterLayout->setContentsMargins(4, 5, 4, 5); + pFooterLayout->setSpacing(8); + pFooterLayout->addStretch(); + QPushButton* pResetButton = new QPushButton(tr("Reset"), this); + m_pApplyButton = new QPushButton(tr("Apply"), this); + QPushButton* pCloseButton = new QPushButton(tr("Close"), this); + m_pApplyButton->setObjectName(QLatin1String("sliceApplyButton")); + m_pApplyButton->setDefault(true); + pResetButton->setAutoDefault(false); + pCloseButton->setAutoDefault(false); + pResetButton->setMinimumWidth(72); + m_pApplyButton->setMinimumWidth(72); + pCloseButton->setMinimumWidth(72); + pCloseButton->setIcon(zxLoadIcon("Cancel")); + pCloseButton->setIconSize(QSize(16, 16)); + pFooterLayout->addWidget(pResetButton); + pFooterLayout->addWidget(m_pApplyButton); + pFooterLayout->addWidget(pCloseButton); + connect(m_pApplyButton, SIGNAL(clicked()), this, SLOT(onApplyRequested())); + connect(pResetButton, SIGNAL(clicked()), this, SLOT(onResetRequested())); + connect(pCloseButton, SIGNAL(clicked()), this, SLOT(close())); + + QVBoxLayout* pMainLayout = new QVBoxLayout(this); + pMainLayout->setContentsMargins(12, 10, 12, 0); + pMainLayout->setSpacing(10); + pMainLayout->addWidget(pPlaneGroup); + pMainLayout->addWidget(pDisplayGroup); + pMainLayout->addWidget(m_pErrorLabel); + pMainLayout->addStretch(); + pMainLayout->addLayout(pFooterLayout); + setMinimumWidth(400); +} + +void nmWxVolumeSliceSettingsDlg::setData(const QSharedPointer& pData, const nmVolumeSliceSettings& oApplied) +{ + m_pData = pData; + setAppliedSettings(oApplied); +} + +void nmWxVolumeSliceSettingsDlg::setAppliedSettings(const nmVolumeSliceSettings& oSettings) +{ + m_oApplied = oSettings; + writeSettings(oSettings); +} + +void nmWxVolumeSliceSettingsDlg::setAppliedProperty(const QString& sPropertyId) +{ + // 底栏属性立即生效;重置、关闭窗口及再次应用平面时也应继续使用此属性。 + m_oApplied.m_sPropertyId = sPropertyId; +} + +bool nmWxVolumeSliceSettingsDlg::getDraft(nmVolumeSliceSettings& oSettings, QString* pError) const +{ + if(pError != nullptr) pError->clear(); + if(m_pData.isNull()) return false; + nmVolumeSliceSettings oDraft = m_oApplied; + for(int nAxis = 0; nAxis < 3; ++nAxis) { + bool bOriginOk = false; + bool bNormalOk = false; + oDraft.m_dOrigin[nAxis] = m_pOriginEdits[nAxis]->text().trimmed().toDouble(&bOriginOk); + oDraft.m_dNormal[nAxis] = m_pNormalEdits[nAxis]->text().trimmed().toDouble(&bNormalOk); + if(!bOriginOk || !bNormalOk) { + if(pError != nullptr) *pError = tr("Enter valid numbers for origin and normal."); + return false; + } + } + if(!oDraft.normalize(pError)) return false; + oDraft.m_bShowPlane = m_pShowPlaneCheck->isChecked(); + oDraft.m_bShowSource = m_pShowSourceCheck->isChecked(); + oSettings = oDraft; + return true; +} + +void nmWxVolumeSliceSettingsDlg::writeSettings(const nmVolumeSliceSettings& oSettings) +{ + m_bUpdating = true; + for(int nAxis = 0; nAxis < 3; ++nAxis) { + m_pOriginEdits[nAxis]->setText(QString::number(oSettings.m_dOrigin[nAxis], 'g', 17)); + m_pNormalEdits[nAxis]->setText(QString::number(oSettings.m_dNormal[nAxis], 'g', 17)); + } + m_pShowPlaneCheck->setChecked(oSettings.m_bShowPlane); + m_pShowSourceCheck->setChecked(oSettings.m_bShowSource); + m_bUpdating = false; + updateValidation(); +} + +void nmWxVolumeSliceSettingsDlg::setPlanePreview(const nmVolumeSliceSettings& oSettings) +{ + m_bUpdating = true; + for(int nAxis = 0; nAxis < 3; ++nAxis) { + m_pOriginEdits[nAxis]->setText(QString::number(oSettings.m_dOrigin[nAxis], 'g', 17)); + m_pNormalEdits[nAxis]->setText(QString::number(oSettings.m_dNormal[nAxis], 'g', 17)); + } + m_bUpdating = false; + updateValidation(); +} + +void nmWxVolumeSliceSettingsDlg::setError(const QString& sError) +{ + m_pErrorLabel->setText(sError); + m_pErrorLabel->setVisible(!sError.isEmpty()); +} + +void nmWxVolumeSliceSettingsDlg::updateValidation() +{ + nmVolumeSliceSettings oDraft; + QString sError; + m_pApplyButton->setEnabled(getDraft(oDraft, &sError)); + setError(sError); +} + +void nmWxVolumeSliceSettingsDlg::onDraftChanged() +{ + if(m_bUpdating) return; + updateValidation(); + if(m_pApplyButton->isEnabled()) emit previewChanged(); +} + +void nmWxVolumeSliceSettingsDlg::onAxisRequested() +{ + const int nNormalAxis = sender()->property("normalAxis").toInt(); + m_bUpdating = true; + for(int nAxis = 0; nAxis < 3; ++nAxis) m_pNormalEdits[nAxis]->setText(nAxis == nNormalAxis ? "1" : "0"); + m_bUpdating = false; + onDraftChanged(); +} + +void nmWxVolumeSliceSettingsDlg::onCenterRequested() +{ + if(m_pData.isNull()) return; + const nmVolumeSliceSettings oCenter = nmVolumeSliceSettings::fromData(*m_pData); + m_bUpdating = true; + for(int nAxis = 0; nAxis < 3; ++nAxis) m_pOriginEdits[nAxis]->setText(QString::number(oCenter.m_dOrigin[nAxis], 'g', 17)); + m_bUpdating = false; + onDraftChanged(); +} + +void nmWxVolumeSliceSettingsDlg::onResetRequested() +{ + writeSettings(m_oApplied); + emit previewChanged(); +} + +void nmWxVolumeSliceSettingsDlg::onApplyRequested() +{ + updateValidation(); + if(m_pApplyButton->isEnabled()) emit applyRequested(); +} + +void nmWxVolumeSliceSettingsDlg::onDisplayOptionsChanged() +{ + if(m_bUpdating) return; + m_oApplied.m_bShowPlane = m_pShowPlaneCheck->isChecked(); + m_oApplied.m_bShowSource = m_pShowSourceCheck->isChecked(); + emit displayOptionsChanged(m_oApplied.m_bShowPlane, m_oApplied.m_bShowSource); +} + +void nmWxVolumeSliceSettingsDlg::hideEvent(QHideEvent* pEvent) +{ + // 最小化产生的系统隐藏并非用户关闭,保留草稿与绑定以便还原窗口后继续操作。 + if(pEvent->spontaneous()) { iDlgBase::hideEvent(pEvent); return; } + writeSettings(m_oApplied); + iDlgBase::hideEvent(pEvent); + emit dialogHidden(); +}