diff --git a/Bin/Config/Lang/cn/nmNum_cn.qm b/Bin/Config/Lang/cn/nmNum_cn.qm index a2b8c22b..40d091fa 100644 Binary files a/Bin/Config/Lang/cn/nmNum_cn.qm and b/Bin/Config/Lang/cn/nmNum_cn.qm differ 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(); +}