feat(vtk): 切片右键菜单(VTK视图)+切片持久化mock+导出图片(切片本身,高清)/dat (#3a)

- VTK视图切片右键菜单(设计§2.3):正视图/翻转/关闭(接现有交互)、导出图片/导出dat、保存、创建异常(占位→#4)
  - 右键经 InteractionManager 高优先级(1.0)交互器观察者实现:先于 vtkImagePlaneWidget 消费右键
    (widget 会抢右键并 abort,故 InteractorStyle::OnRightButtonDown 不触发);命中切片或回退选中切片→弹菜单
- 切片持久化 mock:Api3dRepository createSlice/saveSlice/deleteSlice 内存态 + sliceRows()
  (ddCode=dd_slice,parentId=所属体→三维分析栏树中挂父体下,符合需求 R19 对象/三维体/切片结构)
- 导出为图片=导出切片本身(需求 R52):切片重采样 2D → 上采样至最长边2048(保长宽比) → 切片色阶LUT上色 → PNG
  (非整窗截图);导出dat=切片重采样标量网格
- 三维分析栏接线(#5部分):显示/隐藏(VtkSceneView::toggleDatasetVisibility)、切片删除、色阶占位
- main.cpp refreshAnalysis 合并 volumeRows+sliceRows 注入三维分析栏

编译链接绿(build.bat app exit 0);右键菜单/保存/导出图片(切片·高清)已用户实测通过。
3b 待做:已保存切片在分析栏勾选后的重渲染(从 spec 重建)、分析栏保存/另存/导出;#4 异常接真实后端。
This commit is contained in:
gaozheng 2026-06-18 08:09:15 +08:00
parent b261374cc9
commit afdd98f416
13 changed files with 353 additions and 16 deletions

View File

@ -50,10 +50,11 @@
**实现拆解(设计文档 §6按依赖排序** **实现拆解(设计文档 §6按依赖排序**
1. ~~三维体 mock 渲染~~ **✅ 已实现(编译绿,待 GUI 实测)**——见 §3 与计划 `2026-06-17-vtk-3d-volume-create-flow.md`。`Api3dRepository::loadVolume` 已接通(多源复用 loadSection → IDW → VolumeGrid + 色阶交付);`VolumeBuildParams` 必存参数、values 惰性重算+缓存(**不冻结 gridSpec**,改用源 ds 锁定不变式,留校验 TODO 1. ~~三维体 mock 渲染~~ **✅ 已实现(编译绿,待 GUI 实测)**——见 §3 与计划 `2026-06-17-vtk-3d-volume-create-flow.md`。`Api3dRepository::loadVolume` 已接通(多源复用 loadSection → IDW → VolumeGrid + 色阶交付);`VolumeBuildParams` 必存参数、values 惰性重算+缓存(**不冻结 gridSpec**,改用源 ds 锁定不变式,留校验 TODO
2. 切片交互接通三维体(现有 `SliceTool`/`InteractionManager` 已能切;补滚轮推进、双击正视)。 2. ~~切片交互接通三维体~~ **✅ 已有**`SliceTool`/`InteractionManager`:四向创建/滚轮推进/双击正视/翻转/关闭/选中高亮全在)。
3. 切片保存/另存/导出/删除(保存删除 mock 内存;导出图片/dat 客户端做)+ VTK 视图切片右键菜单接线。 3. **✅ 已实现3a编译绿未提交待 GUI 实测)**——VTK 视图切片右键菜单(`PickInteractorStyle` 右键→`InteractionManager::onSliceContextMenuRequested`→main.cpp 弹 QMenu正视图/翻转/关闭(接现有)、导出图片(PNG)/导出dat(`SliceExport.{hpp,cpp}`)、**保存**(`Api3dRepository::createSlice` 内存 mock→dd_slice 行进三维分析栏、挂父体下,`sliceRows()`+`refreshAnalysis` 合并)、创建异常(占位→#4)。`deleteSlice` 亦改 mock。
4. 异常:切片右键创建异常(圈定+保存对话框含截图)→ **接真实端点** - **3b 待做(拆出)**:已保存切片在三维分析栏勾选后的**重渲染**(切片现仅为交互 widget从 spec 重建需重构面内两轴 + 与父体加载排序 + 跨层编排);分析栏的保存/另存/导出(依赖重渲染)。
5. 分析栏右键菜单接线:色阶/显示隐藏(客户端)+ 切片增删(接 #3)。`Column3DAnalysis` 信号已定义main.cpp 目前**只接了 `sliceRequested`+`detailRequested`**,其余未连。 4. 异常:切片右键创建异常(圈定+保存对话框含截图)→ **接真实端点**。VTK 切片右键菜单的「创建异常」入口已占位。
5. 分析栏右键接线:**部分完成**——`visibilityToggled`(显隐,`VtkSceneView::toggleDatasetVisibility`)、`sliceDeleteRequested`(删 mock 切片+刷新) 已接;`colorScaleRequested` 占位;`sliceSave/SaveAs/Export`(分析栏入口) 待 3b。
6. 三维体/切片/异常详情面板(源数据/插值参数/色阶/测量点数体积/异常列表)。 6. 三维体/切片/异常详情面板(源数据/插值参数/色阶/测量点数体积/异常列表)。
**其它小项**坐标轴「O点位置」「字体」弹框仍是 stubmain.cpp:382 TODO P4 **其它小项**坐标轴「O点位置」「字体」弹框仍是 stubmain.cpp:382 TODO P4

View File

@ -9,6 +9,7 @@ find_package(VTK REQUIRED COMPONENTS
InteractionWidgets InteractionWidgets
FiltersGeometry FiltersGeometry
FiltersModeling FiltersModeling
IOImage # vtkPNGWriter
) )
find_package(nlohmann_json CONFIG REQUIRED) find_package(nlohmann_json CONFIG REQUIRED)
find_package(Qt6 REQUIRED COMPONENTS Svg) find_package(Qt6 REQUIRED COMPONENTS Svg)
@ -64,6 +65,7 @@ add_executable(geopro_desktop WIN32
ImportDatasetDialog.cpp ImportDatasetDialog.cpp
ExportDatasetDialog.cpp ExportDatasetDialog.cpp
SettingsDialog.cpp SettingsDialog.cpp
SliceExport.cpp
VolumeParamsDialog.cpp VolumeParamsDialog.cpp
Logging.cpp Logging.cpp
DatasetDimension.cpp DatasetDimension.cpp

43
src/app/SliceExport.cpp Normal file
View File

@ -0,0 +1,43 @@
#include "SliceExport.hpp"
#include <fstream>
#include <vtkDataArray.h>
#include <vtkImageData.h>
#include <vtkNew.h>
#include <vtkPNGWriter.h>
#include <vtkPointData.h>
namespace geopro::app {
bool exportSliceImagePng(vtkImageData* colorImage, const std::string& path) {
if (colorImage == nullptr || path.empty()) return false;
vtkNew<vtkPNGWriter> writer;
writer->SetFileName(path.c_str());
writer->SetInputData(colorImage); // 已上色 RGB 的切片 2D 图(非整窗截图)
writer->Write();
return writer->GetErrorCode() == 0;
}
bool exportSliceDat(vtkImageData* slice, const std::string& path) {
if (slice == nullptr || path.empty()) return false;
vtkDataArray* arr = slice->GetPointData() ? slice->GetPointData()->GetScalars() : nullptr;
if (arr == nullptr) return false;
int dims[3];
slice->GetDimensions(dims);
const int nx = dims[0], ny = dims[1];
if (nx < 1 || ny < 1) return false;
std::ofstream out(path);
if (!out) return false;
// 切片重采样为 2Ddims[2]=1写成行=j、列=i 的标量网格,每格取首分量。
for (int j = 0; j < ny; ++j) {
for (int i = 0; i < nx; ++i) {
const vtkIdType id = static_cast<vtkIdType>(j) * nx + i;
out << arr->GetComponent(id, 0) << (i + 1 < nx ? ' ' : '\n');
}
}
return static_cast<bool>(out);
}
} // namespace geopro::app

14
src/app/SliceExport.hpp Normal file
View File

@ -0,0 +1,14 @@
#pragma once
#include <string>
class vtkImageData;
namespace geopro::app {
// 把切片"上色后"的 2D RGB 影像写为 PNG切片右键「导出为图片」= 导出切片本身,非整窗截图)。
bool exportSliceImagePng(vtkImageData* colorImage, const std::string& path);
// 把切片重采样 2D 标量影像写为 .dat 文本网格(行=j、列=i空格分隔每格取标量首分量成功返回 true。
bool exportSliceDat(vtkImageData* slice, const std::string& path);
} // namespace geopro::app

View File

@ -189,6 +189,15 @@ void VtkSceneView::removeDataset(const std::string& dsId) {
} }
} }
void VtkSceneView::toggleDatasetVisibility(const std::string& dsId) {
auto it = dsProps_.find(dsId);
if (it == dsProps_.end() || it->second.empty()) return; // 切片(非 dsProps_)等无图元 → 忽略
// 以首个 prop 当前可见性取反,统一作用于该 ds 全部 prop。
const bool show = it->second.front()->GetVisibility() == 0;
for (auto& p : it->second) p->SetVisibility(show ? 1 : 0);
if (renderWindow_) renderWindow_->Render();
}
void VtkSceneView::setAxes(geopro::controller::AxesMode mode, geopro::controller::AxesUnit unit, void VtkSceneView::setAxes(geopro::controller::AxesMode mode, geopro::controller::AxesUnit unit,
int fontSize) { int fontSize) {
axesMode_ = mode; axesMode_ = mode;

View File

@ -39,6 +39,8 @@ public:
const geopro::core::ColorScale& cs) override; const geopro::core::ColorScale& cs) override;
void addTerrain(const geopro::data::TerrainPaths& paths) override; void addTerrain(const geopro::data::TerrainPaths& paths) override;
void removeDataset(const std::string& dsId) override; void removeDataset(const std::string& dsId) override;
// 切换某数据集图元可见性(三维分析栏「显示/隐藏」;切片等非 dsProps_ 图元忽略)。
void toggleDatasetVisibility(const std::string& dsId);
void setAxes(geopro::controller::AxesMode mode, geopro::controller::AxesUnit unit, void setAxes(geopro::controller::AxesMode mode, geopro::controller::AxesUnit unit,
int fontSize) override; int fontSize) override;
void applyCameraView(geopro::controller::ViewDir dir) override; void applyCameraView(geopro::controller::ViewDir dir) override;
@ -54,6 +56,7 @@ public:
double currentVmin() const { return currentVmin_; } double currentVmin() const { return currentVmin_; }
double currentVmax() const { return currentVmax_; } double currentVmax() const { return currentVmax_; }
bool hasVolume() const { return currentVolumeImage_ != nullptr; } bool hasVolume() const { return currentVolumeImage_ != nullptr; }
const std::string& currentVolumeDsId() const { return volumeOwnerDs_; } // 当前体归属 ds保存切片用
// 体素 image 变化addVolume 附着新 image / clear 置空)时回调,供上层把新 image 推给 // 体素 image 变化addVolume 附着新 image / clear 置空)时回调,供上层把新 image 推给
// InteractionManager重附着或关闭切片。clear 时以 nullptr 触发。 // InteractionManager重附着或关闭切片。clear 时以 nullptr 触发。

View File

@ -41,7 +41,12 @@
#include <QSlider> #include <QSlider>
#include <QGraphicsOpacityEffect> #include <QGraphicsOpacityEffect>
#include <QDate> #include <QDate>
#include <QAction>
#include <QCursor>
#include <QFileDialog>
#include <QInputDialog>
#include <QLabel> #include <QLabel>
#include <QLineEdit>
#include <QListWidget> #include <QListWidget>
#include <QListWidgetItem> #include <QListWidgetItem>
#include <QJsonObject> #include <QJsonObject>
@ -92,6 +97,7 @@
#include "PanelHeader.hpp" #include "PanelHeader.hpp"
#include "Theme.hpp" #include "Theme.hpp"
#include "SettingsDialog.hpp" #include "SettingsDialog.hpp"
#include "SliceExport.hpp"
#include "TopBar.hpp" #include "TopBar.hpp"
#include "VolumeParamsDialog.hpp" #include "VolumeParamsDialog.hpp"
#include "ProjectListDialog.hpp" #include "ProjectListDialog.hpp"
@ -155,6 +161,7 @@
#include <vtkGenericOpenGLRenderWindow.h> #include <vtkGenericOpenGLRenderWindow.h>
#include <vtkLookupTable.h> #include <vtkLookupTable.h>
#include <vtkProperty.h> #include <vtkProperty.h>
#include <vtkImageData.h>
#include <vtkRenderWindowInteractor.h> #include <vtkRenderWindowInteractor.h>
#include <vtkRenderer.h> #include <vtkRenderer.h>
#include <vtkSmartPointer.h> #include <vtkSmartPointer.h>
@ -369,7 +376,8 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
auto lastAnalysisRows = std::make_shared<std::vector<geopro::data::DsRow>>(); auto lastAnalysisRows = std::make_shared<std::vector<geopro::data::DsRow>>();
auto refreshAnalysis = [drawer, scene3dRepo, lastAnalysisRows]() { auto refreshAnalysis = [drawer, scene3dRepo, lastAnalysisRows]() {
std::vector<geopro::data::DsRow> rows = *lastAnalysisRows; std::vector<geopro::data::DsRow> rows = *lastAnalysisRows;
for (auto& vr : scene3dRepo->volumeRows()) rows.push_back(std::move(vr)); for (auto& vr : scene3dRepo->volumeRows()) rows.push_back(std::move(vr)); // 客户端三维体
for (auto& sr : scene3dRepo->sliceRows()) rows.push_back(std::move(sr)); // 已保存切片(挂父体下)
drawer->colAnalysis()->setDatasets(rows); drawer->colAnalysis()->setDatasets(rows);
}; };
@ -383,6 +391,90 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
sceneCtrl->setCheckedDatasets(all); sceneCtrl->setCheckedDatasets(all);
}; };
// ── VTK 视图切片右键菜单(设计 §2.3)──────────────────────────────────────
// 右键命中切片 → InteractionManager 选中并回调本 lambda → 弹菜单QCursor 处定位)。
// 正视/翻转/关闭=接现有交互;保存=持久化为 dd_slice 进三维分析栏;导出图片/dat=客户端;
// 创建异常=占位(#4 接真实后端)。
interactionMgr->onSliceContextMenuRequested =
[&window, interactionMgr, sceneView, scene3dRepo, refreshAnalysis]() {
QMenu menu(&window);
QAction* aAnomaly = menu.addAction(QStringLiteral("创建异常"));
QAction* aSave = menu.addAction(QStringLiteral("保存"));
QAction* aImg = menu.addAction(QStringLiteral("导出为图片"));
QAction* aDat = menu.addAction(QStringLiteral("导出到 dat"));
menu.addSeparator();
QAction* aFace = menu.addAction(QStringLiteral("正视图"));
QAction* aFlip = menu.addAction(QStringLiteral("视图翻转"));
QAction* aClose = menu.addAction(QStringLiteral("关闭"));
QAction* chosen = menu.exec(QCursor::pos());
if (chosen == nullptr) return;
if (chosen == aFace) { interactionMgr->faceSelected(); return; }
if (chosen == aFlip) { interactionMgr->flipView(); return; }
if (chosen == aClose) { interactionMgr->closeSelected(); return; }
if (chosen == aAnomaly) {
QMessageBox::information(&window, QStringLiteral("创建异常"),
QStringLiteral("异常功能开发中(#4切片圈定 + 接真实后端端点)。"));
return;
}
if (chosen == aSave) {
geopro::render::interact::Vec3 c{}, n{};
if (!interactionMgr->selectedSliceSpec(c, n)) return;
const std::string volId = sceneView->currentVolumeDsId();
if (volId.empty()) {
QMessageBox::warning(&window, QStringLiteral("保存切片"),
QStringLiteral("当前切片无所属三维体,无法保存。"));
return;
}
bool ok = false;
const QString name = QInputDialog::getText(&window, QStringLiteral("保存切片"),
QStringLiteral("切片名称"),
QLineEdit::Normal,
QStringLiteral("切片"), &ok);
if (!ok) return;
geopro::data::I3dSceneRepository::SliceSpec spec;
spec.volumeDsId = volId;
spec.origin = c;
spec.normal = n;
scene3dRepo->createSlice(
spec, name.isEmpty() ? std::string("切片") : name.toStdString(),
[refreshAnalysis](std::string) { refreshAnalysis(); }, // 新切片进三维分析栏
[&window](const std::string& m) {
QMessageBox::warning(&window, QStringLiteral("保存切片"),
QString::fromStdString(m));
});
return;
}
if (chosen == aImg) {
vtkSmartPointer<vtkImageData> colorImg = interactionMgr->selectedSliceColorImage();
if (colorImg == nullptr) {
QMessageBox::warning(&window, QStringLiteral("导出为图片"),
QStringLiteral("无选中切片或切片无数据。"));
return;
}
const QString path = QFileDialog::getSaveFileName(
&window, QStringLiteral("导出为图片"), QStringLiteral("slice.png"),
QStringLiteral("PNG 图片 (*.png)"));
if (!path.isEmpty() &&
!geopro::app::exportSliceImagePng(colorImg, path.toStdString()))
QMessageBox::warning(&window, QStringLiteral("导出为图片"),
QStringLiteral("导出失败。"));
return;
}
if (chosen == aDat) {
vtkImageData* img = interactionMgr->selectedSliceImage();
if (img == nullptr) return;
const QString path = QFileDialog::getSaveFileName(
&window, QStringLiteral("导出到 dat"), QStringLiteral("slice.dat"),
QStringLiteral("数据文件 (*.dat)"));
if (!path.isEmpty() &&
!geopro::app::exportSliceDat(img, path.toStdString()))
QMessageBox::warning(&window, QStringLiteral("导出到 dat"),
QStringLiteral("导出失败。"));
return;
}
};
QObject::connect(c3, &geopro::app::Column3DDataset::axesModeChanged, sceneCtrl, QObject::connect(c3, &geopro::app::Column3DDataset::axesModeChanged, sceneCtrl,
&geopro::controller::VtkSceneController::setAxesMode); &geopro::controller::VtkSceneController::setAxesMode);
QObject::connect(c3, &geopro::app::Column3DDataset::axesUnitChanged, sceneCtrl, QObject::connect(c3, &geopro::app::Column3DDataset::axesUnitChanged, sceneCtrl,
@ -438,6 +530,24 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
[&detailCtrl](const QString& dsId, const QString& ddCode, const QString& name) { [&detailCtrl](const QString& dsId, const QString& ddCode, const QString& name) {
detailCtrl.openDataset(dsId, ddCode, name); detailCtrl.openDataset(dsId, ddCode, name);
}); });
// 三维分析栏「显示/隐藏」(三维体/帘面图元;切片等非 dsProps_ 图元忽略)。
QObject::connect(ca, &geopro::app::Column3DAnalysis::visibilityToggled, vtkWidget,
[sceneView](const QString& dsId) {
sceneView->toggleDatasetVisibility(dsId.toStdString());
});
// 三维分析栏切片右键「删除」→ 删除 mock 切片 + 刷新列表。
QObject::connect(ca, &geopro::app::Column3DAnalysis::sliceDeleteRequested, &window,
[scene3dRepo, refreshAnalysis](const QString& dsId) {
scene3dRepo->deleteSlice(
dsId.toStdString(), [refreshAnalysis]() { refreshAnalysis(); },
[](const std::string&) {});
});
// 色阶 / 切片保存·另存·导出(分析栏入口):本期占位(色阶编辑、已存切片重渲染见 3b/后续)。
QObject::connect(ca, &geopro::app::Column3DAnalysis::colorScaleRequested, &window,
[&window](const QString&) {
QMessageBox::information(&window, QStringLiteral("色阶"),
QStringLiteral("色阶设置开发中。"));
});
// ── 二维数据集栏:天地图底图开关(③,复用轨迹图 token经同一共享 GeoLocalFrame 配准)── // ── 二维数据集栏:天地图底图开关(③,复用轨迹图 token经同一共享 GeoLocalFrame 配准)──
auto* basemap = new geopro::app::TileBasemap(*scene, renderWindowPtr, frame, &window); auto* basemap = new geopro::app::TileBasemap(*scene, renderWindowPtr, frame, &window);

View File

@ -204,21 +204,41 @@ void Api3dRepository::loadTerrainPaths(std::function<void(TerrainPaths)> /*onOk*
onErr(kNotReady); // 后端地形 DEM/影像端点未就绪 onErr(kNotReady); // 后端地形 DEM/影像端点未就绪
} }
// ── 切片 CRUD后端未就绪 → 变更走 onErr给用户明确"未实现"────────────── // ── 切片 CRUD后端无切片端点 → 内存 mock端点就绪后换实现────────────────
void Api3dRepository::createSlice(const SliceSpec& /*spec*/, const std::string& /*name*/, std::vector<DsRow> Api3dRepository::sliceRows() const {
std::function<void(std::string)> /*onOk*/, OnError onErr) { std::vector<DsRow> rows;
onErr(kNotReady); rows.reserve(slices_.size());
for (const auto& [id, ss] : slices_) {
DsRow r;
r.id = id;
r.dsName = ss.name;
r.ddCode = "dd_slice";
r.typeName = "切片";
r.parentId = ss.spec.volumeDsId; // 树中挂在所属三维体下
rows.push_back(std::move(r));
}
return rows;
} }
void Api3dRepository::saveSlice(const std::string& /*dsId*/, const SliceSpec& /*spec*/, void Api3dRepository::createSlice(const SliceSpec& spec, const std::string& name,
std::function<void()> /*onOk*/, OnError onErr) { std::function<void(std::string)> onOk, OnError /*onErr*/) {
onErr(kNotReady); const std::string id = "slice-" + std::to_string(++sliceCounter_);
slices_[id] = StoredSlice{spec, name};
onOk(id);
} }
void Api3dRepository::deleteSlice(const std::string& /*dsId*/, std::function<void()> /*onOk*/, void Api3dRepository::saveSlice(const std::string& dsId, const SliceSpec& spec,
OnError onErr) { std::function<void()> onOk, OnError /*onErr*/) {
onErr(kNotReady); auto it = slices_.find(dsId);
if (it != slices_.end()) it->second.spec = spec; // 覆盖位姿
onOk();
}
void Api3dRepository::deleteSlice(const std::string& dsId, std::function<void()> onOk,
OnError /*onErr*/) {
slices_.erase(dsId);
onOk();
} }
// ── 异常 / 异常体load 回空树避免 UI 崩;变更走 onErr───────────────────── // ── 异常 / 异常体load 回空树避免 UI 崩;变更走 onErr─────────────────────

View File

@ -38,8 +38,10 @@ public:
// ── 客户端创建三维体mock 持久化:内存;端点就绪后换实现)────────────────── // ── 客户端创建三维体mock 持久化:内存;端点就绪后换实现)──────────────────
// 登记新三维体(仅存参数,不立即插值)→ 返回新 dsId"vol-N")。插值在首次 loadVolume 惰性做并缓存。 // 登记新三维体(仅存参数,不立即插值)→ 返回新 dsId"vol-N")。插值在首次 loadVolume 惰性做并缓存。
std::string createVolume(VolumeBuildParams params, const std::string& name); std::string createVolume(VolumeBuildParams params, const std::string& name);
// 已创建三维体的列表行ddCode="dd_voxel"),供三维数据集栏合并注入(每次 setDatasets 追加)。 // 已创建三维体的列表行ddCode="dd_voxel"),供三维分析栏合并注入(每次 setDatasets 追加)。
std::vector<DsRow> volumeRows() const; std::vector<DsRow> volumeRows() const;
// 已保存切片的列表行ddCode="dd_slice"parentId=所属体 dsId → 树中挂父体下),供三维分析栏合并。
std::vector<DsRow> sliceRows() const;
void loadVolume(const std::string& dsId, void loadVolume(const std::string& dsId,
std::function<void(VolumeGrid, geopro::core::ColorScale)> onOk, std::function<void(VolumeGrid, geopro::core::ColorScale)> onOk,
@ -95,6 +97,14 @@ private:
}; };
std::map<std::string, StoredVolume> volumes_; // dsId → 体 std::map<std::string, StoredVolume> volumes_; // dsId → 体
int volumeCounter_ = 0; int volumeCounter_ = 0;
// 内存态切片存储mock重启清空。切片保存后成 dd_slice 数据集,进三维分析栏。
struct StoredSlice {
SliceSpec spec;
std::string name;
};
std::map<std::string, StoredSlice> slices_; // dsId → 切片
int sliceCounter_ = 0;
}; };
} // namespace geopro::data } // namespace geopro::data

View File

@ -5,12 +5,20 @@
#include <cmath> #include <cmath>
#include <cstddef> #include <cstddef>
#include <vtkCallbackCommand.h>
#include <vtkCamera.h> #include <vtkCamera.h>
#include <vtkCellPicker.h>
#include <vtkCommand.h>
#include <vtkImageData.h> #include <vtkImageData.h>
#include <vtkImageMapToColors.h>
#include <vtkImageResize.h>
#include <vtkLookupTable.h>
#include <vtkNew.h>
#include <vtkRenderWindow.h> #include <vtkRenderWindow.h>
#include <vtkRenderWindowInteractor.h> #include <vtkRenderWindowInteractor.h>
#include <vtkRenderer.h> #include <vtkRenderer.h>
#include "ColorLutBuilder.hpp"
#include "interact/PickInteractorStyle.hpp" #include "interact/PickInteractorStyle.hpp"
namespace geopro::render::interact { namespace geopro::render::interact {
@ -48,6 +56,15 @@ void InteractionManager::installStyle() {
return true; return true;
}; };
interactor_->SetInteractorStyle(style_); interactor_->SetInteractorStyle(style_);
// 右键菜单观察者:高优先级(1.0)直接挂交互器,先于 vtkImagePlaneWidget(默认 0.0)消费右键。
// 命中切片 → handleRightButton 内 abort + 弹菜单;未命中 → 不 abort事件继续走默认。
rightBtnCmd_ = vtkSmartPointer<vtkCallbackCommand>::New();
rightBtnCmd_->SetClientData(this);
rightBtnCmd_->SetCallback([](vtkObject*, unsigned long, void* client, void*) {
static_cast<InteractionManager*>(client)->handleRightButton();
});
rightBtnTag_ = interactor_->AddObserver(vtkCommand::RightButtonPressEvent, rightBtnCmd_, 1.0);
} }
void InteractionManager::uninstallStyle() { void InteractionManager::uninstallStyle() {
@ -58,6 +75,12 @@ void InteractionManager::uninstallStyle() {
style_->onWheelStep = nullptr; style_->onWheelStep = nullptr;
style_->getRotateCenter = nullptr; style_->getRotateCenter = nullptr;
} }
// 摘除右键观察者this 即将析构)。
if (interactor_ && rightBtnTag_ != 0) {
interactor_->RemoveObserver(rightBtnTag_);
rightBtnTag_ = 0;
}
rightBtnCmd_ = nullptr;
// 从 interactor 上彻底摘除自定义 style避免 interactor 仍持空回调 style评审 H2 // 从 interactor 上彻底摘除自定义 style避免 interactor 仍持空回调 style评审 H2
if (interactor_) interactor_->SetInteractorStyle(nullptr); if (interactor_) interactor_->SetInteractorStyle(nullptr);
style_ = nullptr; style_ = nullptr;
@ -145,6 +168,80 @@ void InteractionManager::flipView() {
safeRender(); safeRender();
} }
void InteractionManager::faceSelected() { faceSlice(selected_); }
bool InteractionManager::selectedSliceSpec(Vec3& center, Vec3& normal) const {
if (selected_ < 0 || selected_ >= static_cast<int>(slices_.size())) return false;
const auto& s = slices_[static_cast<std::size_t>(selected_)];
center = s->center();
normal = s->normal();
return true;
}
vtkImageData* InteractionManager::selectedSliceImage() const {
if (selected_ < 0 || selected_ >= static_cast<int>(slices_.size())) return nullptr;
return slices_[static_cast<std::size_t>(selected_)]->reslicedOutput();
}
vtkSmartPointer<vtkImageData> InteractionManager::selectedSliceColorImage() const {
vtkImageData* scalar = selectedSliceImage();
if (scalar == nullptr) return nullptr;
// 高清导出切片重采样像素维度受体素网格分辨率限制常仅几十px→ 先上采样到目标分辨率
// (最长边 kExportLongSide保持长宽比、插值再上色得到清晰大图。
constexpr int kExportLongSide = 2048;
int dims[3];
scalar->GetDimensions(dims);
const int nx = dims[0], ny = dims[1];
const int longest = std::max(nx, ny);
double f = (longest > 0) ? static_cast<double>(kExportLongSide) / longest : 1.0;
if (f < 1.0) f = 1.0; // 不缩小(已够大则原样)
vtkNew<vtkImageResize> resize;
resize->SetInputData(scalar);
resize->SetResizeMethodToOutputDimensions();
resize->SetOutputDimensions(std::max(1, static_cast<int>(nx * f)),
std::max(1, static_cast<int>(ny * f)), 1);
resize->Update();
// 用与切片显示同一色阶 LUT 上色colorScale_/vmin_/vmax_ 即当前体/切片着色区间)。
auto lut = buildLut(colorScale_, vmin_, vmax_);
vtkNew<vtkImageMapToColors> map;
map->SetInputConnection(resize->GetOutputPort());
map->SetLookupTable(lut);
map->SetOutputFormatToRGB();
map->Update();
auto out = vtkSmartPointer<vtkImageData>::New();
out->DeepCopy(map->GetOutput()); // 深拷贝脱离 filter 生命周期
return out;
}
void InteractionManager::handleRightButton() {
// 高优先级右键观察者(先于 vtkImagePlaneWidget 消费右键)。
// 选中目标 = 拾取命中的切片;拾取没命中切片平面(实测常因拾到体/其它面而落在阈值外)则
// 回退到"当前选中切片"(左键交互/新建已选中)。有可操作切片 → abort 右键 + 弹菜单;否则放行。
if (!interactor_) return;
int idx = -1;
const int* pos = interactor_->GetEventPosition();
auto* ren = interactor_->FindPokedRenderer(pos[0], pos[1]);
if (ren) {
vtkNew<vtkCellPicker> picker;
picker->SetTolerance(0.005);
if (picker->Pick(pos[0], pos[1], 0.0, ren)) {
double w[3];
picker->GetPickPosition(w);
idx = nearestSlice({w[0], w[1], w[2]});
}
}
if (idx < 0) idx = selected_; // 回退到当前选中切片
if (idx < 0 || idx >= static_cast<int>(slices_.size())) return; // 无切片可操作 → 放行默认右键
selected_ = idx;
updateSelectionVisual();
safeRender();
if (rightBtnCmd_) rightBtnCmd_->SetAbortFlag(1); // 消费右键,阻止 widget/style 默认行为
if (onSliceContextMenuRequested) onSliceContextMenuRequested();
}
int InteractionManager::nearestSlice(const Vec3& worldPoint) const { int InteractionManager::nearestSlice(const Vec3& worldPoint) const {
if (slices_.empty()) return -1; if (slices_.empty()) return -1;
std::vector<Vec3> centers, normals; std::vector<Vec3> centers, normals;

View File

@ -1,4 +1,5 @@
#pragma once #pragma once
#include <functional>
#include <memory> #include <memory>
#include <vector> #include <vector>
@ -12,6 +13,7 @@ class vtkImageData;
class vtkRenderWindow; class vtkRenderWindow;
class vtkRenderWindowInteractor; class vtkRenderWindowInteractor;
class vtkRenderer; class vtkRenderer;
class vtkCallbackCommand;
namespace geopro::render::interact { namespace geopro::render::interact {
@ -54,6 +56,18 @@ public:
// 视图翻转:水平旋转 180°E55 // 视图翻转:水平旋转 180°E55
void flipView(); void flipView();
// 正视当前选中切片(菜单「正视图」入口;无选中则忽略)。
void faceSelected();
// 选中切片位姿(保存切片用):有选中→填 center/normal(世界系) 返回 true否则 false。
bool selectedSliceSpec(Vec3& center, Vec3& normal) const;
// 选中切片的重采样 2D 标量影像(导出 dat 用);无选中返回 nullptr。
vtkImageData* selectedSliceImage() const;
// 选中切片"上色后"的 2D 影像(导出图片用):重采样标量经切片色阶 LUT → RGB 图;无选中返回 nullptr。
vtkSmartPointer<vtkImageData> selectedSliceColorImage() const;
// 右键命中切片时回调manager 已选中所在切片)→ 上层据此弹切片右键菜单(用 QCursor::pos 定位)。
std::function<void()> onSliceContextMenuRequested;
// 安装/卸载自定义交互样式(构造时安装;析构卸载恢复原样式)。 // 安装/卸载自定义交互样式(构造时安装;析构卸载恢复原样式)。
void installStyle(); void installStyle();
@ -65,6 +79,10 @@ private:
void onDoubleClicked(const Vec3& worldPoint); // 正视所在切片 void onDoubleClicked(const Vec3& worldPoint); // 正视所在切片
bool onWheel(int dir); // 推进选中切片;无选中返回 false bool onWheel(int dir); // 推进选中切片;无选中返回 false
// 右键命中切片 → 选中 + 请求弹菜单 + abort高优先级交互器观察者先于 vtkImagePlaneWidget
// 消费右键,否则 widget 抢走事件、InteractorStyle 永不触发)。未命中切片则不 abort、放行默认。
void handleRightButton();
// 找离世界点最近的切片索引;无切片返回 -1。 // 找离世界点最近的切片索引;无切片返回 -1。
int nearestSlice(const Vec3& worldPoint) const; int nearestSlice(const Vec3& worldPoint) const;
// 按 SliceTool 指针设为选中widget 交互回调用:触碰即选中)。 // 按 SliceTool 指针设为选中widget 交互回调用:触碰即选中)。
@ -90,6 +108,9 @@ private:
int selected_ = -1; // 选中切片索引(-1=无) int selected_ = -1; // 选中切片索引(-1=无)
vtkSmartPointer<PickInteractorStyle> style_; vtkSmartPointer<PickInteractorStyle> style_;
// 右键菜单:高优先级交互器观察者(先于 widget 抢右键。tag 供 uninstall 时摘除。
vtkSmartPointer<vtkCallbackCommand> rightBtnCmd_;
unsigned long rightBtnTag_ = 0;
// 析构进行中closeAll() 跳过 renderWindow_->Render()Qt 拆台时窗口可能已半析构, // 析构进行中closeAll() 跳过 renderWindow_->Render()Qt 拆台时窗口可能已半析构,
// 析构期再 Render 易崩,评审 M3 // 析构期再 Render 易崩,评审 M3

View File

@ -141,6 +141,10 @@ void SliceTool::advance(double step) {
widget_->UpdatePlacement(); widget_->UpdatePlacement();
} }
vtkImageData* SliceTool::reslicedOutput() const {
return widget_ ? widget_->GetResliceOutput() : nullptr;
}
double SliceTool::distanceToPlane(const Vec3& p) const { double SliceTool::distanceToPlane(const Vec3& p) const {
const Vec3 c = center(); const Vec3 c = center();
const Vec3 n = normal(); const Vec3 n = normal();

View File

@ -57,6 +57,9 @@ public:
// 世界点到本切面(无限平面)的垂直距离绝对值。供 picker 命中判定"点在哪张切片上"。 // 世界点到本切面(无限平面)的垂直距离绝对值。供 picker 命中判定"点在哪张切片上"。
double distanceToPlane(const Vec3& worldPoint) const; double distanceToPlane(const Vec3& worldPoint) const;
// 当前切面重采样得到的 2D 标量影像(导出 dat 用widget 已释放则 nullptr。
vtkImageData* reslicedOutput() const;
// 关闭Off() 并解除 interactor 绑定(幂等)。 // 关闭Off() 并解除 interactor 绑定(幂等)。
void close(); void close();