diff --git a/docs/superpowers/HANDOFF-vtk-3d.md b/docs/superpowers/HANDOFF-vtk-3d.md index 284fb0e..88f611a 100644 --- a/docs/superpowers/HANDOFF-vtk-3d.md +++ b/docs/superpowers/HANDOFF-vtk-3d.md @@ -50,10 +50,11 @@ **实现拆解(设计文档 §6,按依赖排序)**: 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` 已能切;补滚轮推进、双击正视)。 -3. 切片保存/另存/导出/删除(保存删除 mock 内存;导出图片/dat 客户端做)+ VTK 视图切片右键菜单接线。 -4. 异常:切片右键创建异常(圈定+保存对话框含截图)→ **接真实端点**。 -5. 分析栏右键菜单接线:色阶/显示隐藏(客户端)+ 切片增删(接 #3)。`Column3DAnalysis` 信号已定义,main.cpp 目前**只接了 `sliceRequested`+`detailRequested`**,其余未连。 +2. ~~切片交互接通三维体~~ **✅ 已有**(`SliceTool`/`InteractionManager`:四向创建/滚轮推进/双击正视/翻转/关闭/选中高亮全在)。 +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。 + - **3b 待做(拆出)**:已保存切片在三维分析栏勾选后的**重渲染**(切片现仅为交互 widget,从 spec 重建需重构面内两轴 + 与父体加载排序 + 跨层编排);分析栏的保存/另存/导出(依赖重渲染)。 +4. 异常:切片右键创建异常(圈定+保存对话框含截图)→ **接真实端点**。VTK 切片右键菜单的「创建异常」入口已占位。 +5. 分析栏右键接线:**部分完成**——`visibilityToggled`(显隐,`VtkSceneView::toggleDatasetVisibility`)、`sliceDeleteRequested`(删 mock 切片+刷新) 已接;`colorScaleRequested` 占位;`sliceSave/SaveAs/Export`(分析栏入口) 待 3b。 6. 三维体/切片/异常详情面板(源数据/插值参数/色阶/测量点数体积/异常列表)。 **其它小项**:坐标轴「O点位置」「字体」弹框仍是 stub(main.cpp:382 TODO P4)。 diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index 4cae2f4..5649f48 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -9,6 +9,7 @@ find_package(VTK REQUIRED COMPONENTS InteractionWidgets FiltersGeometry FiltersModeling + IOImage # vtkPNGWriter(切片导出图片) ) find_package(nlohmann_json CONFIG REQUIRED) find_package(Qt6 REQUIRED COMPONENTS Svg) @@ -64,6 +65,7 @@ add_executable(geopro_desktop WIN32 ImportDatasetDialog.cpp ExportDatasetDialog.cpp SettingsDialog.cpp + SliceExport.cpp VolumeParamsDialog.cpp Logging.cpp DatasetDimension.cpp diff --git a/src/app/SliceExport.cpp b/src/app/SliceExport.cpp new file mode 100644 index 0000000..3210301 --- /dev/null +++ b/src/app/SliceExport.cpp @@ -0,0 +1,43 @@ +#include "SliceExport.hpp" + +#include + +#include +#include +#include +#include +#include + +namespace geopro::app { + +bool exportSliceImagePng(vtkImageData* colorImage, const std::string& path) { + if (colorImage == nullptr || path.empty()) return false; + vtkNew 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; + // 切片重采样为 2D(dims[2]=1):写成行=j、列=i 的标量网格,每格取首分量。 + for (int j = 0; j < ny; ++j) { + for (int i = 0; i < nx; ++i) { + const vtkIdType id = static_cast(j) * nx + i; + out << arr->GetComponent(id, 0) << (i + 1 < nx ? ' ' : '\n'); + } + } + return static_cast(out); +} + +} // namespace geopro::app diff --git a/src/app/SliceExport.hpp b/src/app/SliceExport.hpp new file mode 100644 index 0000000..5046759 --- /dev/null +++ b/src/app/SliceExport.hpp @@ -0,0 +1,14 @@ +#pragma once +#include + +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 diff --git a/src/app/VtkSceneView.cpp b/src/app/VtkSceneView.cpp index d5bb518..05716a8 100644 --- a/src/app/VtkSceneView.cpp +++ b/src/app/VtkSceneView.cpp @@ -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, int fontSize) { axesMode_ = mode; diff --git a/src/app/VtkSceneView.hpp b/src/app/VtkSceneView.hpp index 7bffe6c..0ac46c4 100644 --- a/src/app/VtkSceneView.hpp +++ b/src/app/VtkSceneView.hpp @@ -39,6 +39,8 @@ public: const geopro::core::ColorScale& cs) override; void addTerrain(const geopro::data::TerrainPaths& paths) 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, int fontSize) override; void applyCameraView(geopro::controller::ViewDir dir) override; @@ -54,6 +56,7 @@ public: double currentVmin() const { return currentVmin_; } double currentVmax() const { return currentVmax_; } bool hasVolume() const { return currentVolumeImage_ != nullptr; } + const std::string& currentVolumeDsId() const { return volumeOwnerDs_; } // 当前体归属 ds(保存切片用) // 体素 image 变化(addVolume 附着新 image / clear 置空)时回调,供上层把新 image 推给 // InteractionManager(重附着或关闭切片)。clear 时以 nullptr 触发。 diff --git a/src/app/main.cpp b/src/app/main.cpp index 522fb08..c30e561 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -41,7 +41,12 @@ #include #include #include +#include +#include +#include +#include #include +#include #include #include #include @@ -92,6 +97,7 @@ #include "PanelHeader.hpp" #include "Theme.hpp" #include "SettingsDialog.hpp" +#include "SliceExport.hpp" #include "TopBar.hpp" #include "VolumeParamsDialog.hpp" #include "ProjectListDialog.hpp" @@ -155,6 +161,7 @@ #include #include #include +#include #include #include #include @@ -369,7 +376,8 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re auto lastAnalysisRows = std::make_shared>(); auto refreshAnalysis = [drawer, scene3dRepo, lastAnalysisRows]() { std::vector 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); }; @@ -383,6 +391,90 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re 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 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, &geopro::controller::VtkSceneController::setAxesMode); 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.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 配准)── auto* basemap = new geopro::app::TileBasemap(*scene, renderWindowPtr, frame, &window); diff --git a/src/data/api/Api3dRepository.cpp b/src/data/api/Api3dRepository.cpp index ab0c6b8..876af17 100644 --- a/src/data/api/Api3dRepository.cpp +++ b/src/data/api/Api3dRepository.cpp @@ -204,21 +204,41 @@ void Api3dRepository::loadTerrainPaths(std::function /*onOk* onErr(kNotReady); // 后端地形 DEM/影像端点未就绪 } -// ── 切片 CRUD(后端未就绪 → 变更走 onErr,给用户明确"未实现")────────────── +// ── 切片 CRUD(后端无切片端点 → 内存 mock;端点就绪后换实现)──────────────── -void Api3dRepository::createSlice(const SliceSpec& /*spec*/, const std::string& /*name*/, - std::function /*onOk*/, OnError onErr) { - onErr(kNotReady); +std::vector Api3dRepository::sliceRows() const { + std::vector rows; + 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*/, - std::function /*onOk*/, OnError onErr) { - onErr(kNotReady); +void Api3dRepository::createSlice(const SliceSpec& spec, const std::string& name, + std::function onOk, OnError /*onErr*/) { + 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 /*onOk*/, - OnError onErr) { - onErr(kNotReady); +void Api3dRepository::saveSlice(const std::string& dsId, const SliceSpec& spec, + std::function onOk, OnError /*onErr*/) { + auto it = slices_.find(dsId); + if (it != slices_.end()) it->second.spec = spec; // 覆盖位姿 + onOk(); +} + +void Api3dRepository::deleteSlice(const std::string& dsId, std::function onOk, + OnError /*onErr*/) { + slices_.erase(dsId); + onOk(); } // ── 异常 / 异常体(load 回空树避免 UI 崩;变更走 onErr)───────────────────── diff --git a/src/data/api/Api3dRepository.hpp b/src/data/api/Api3dRepository.hpp index 06651f1..4c84e00 100644 --- a/src/data/api/Api3dRepository.hpp +++ b/src/data/api/Api3dRepository.hpp @@ -38,8 +38,10 @@ public: // ── 客户端创建三维体(mock 持久化:内存;端点就绪后换实现)────────────────── // 登记新三维体(仅存参数,不立即插值)→ 返回新 dsId("vol-N")。插值在首次 loadVolume 惰性做并缓存。 std::string createVolume(VolumeBuildParams params, const std::string& name); - // 已创建三维体的列表行(ddCode="dd_voxel"),供三维数据集栏合并注入(每次 setDatasets 追加)。 + // 已创建三维体的列表行(ddCode="dd_voxel"),供三维分析栏合并注入(每次 setDatasets 追加)。 std::vector volumeRows() const; + // 已保存切片的列表行(ddCode="dd_slice",parentId=所属体 dsId → 树中挂父体下),供三维分析栏合并。 + std::vector sliceRows() const; void loadVolume(const std::string& dsId, std::function onOk, @@ -95,6 +97,14 @@ private: }; std::map volumes_; // dsId → 体 int volumeCounter_ = 0; + + // 内存态切片存储(mock;重启清空)。切片保存后成 dd_slice 数据集,进三维分析栏。 + struct StoredSlice { + SliceSpec spec; + std::string name; + }; + std::map slices_; // dsId → 切片 + int sliceCounter_ = 0; }; } // namespace geopro::data diff --git a/src/render/interact/InteractionManager.cpp b/src/render/interact/InteractionManager.cpp index 9120546..0fdfe13 100644 --- a/src/render/interact/InteractionManager.cpp +++ b/src/render/interact/InteractionManager.cpp @@ -5,12 +5,20 @@ #include #include +#include #include +#include +#include #include +#include +#include +#include +#include #include #include #include +#include "ColorLutBuilder.hpp" #include "interact/PickInteractorStyle.hpp" namespace geopro::render::interact { @@ -48,6 +56,15 @@ void InteractionManager::installStyle() { return true; }; interactor_->SetInteractorStyle(style_); + + // 右键菜单观察者:高优先级(1.0)直接挂交互器,先于 vtkImagePlaneWidget(默认 0.0)消费右键。 + // 命中切片 → handleRightButton 内 abort + 弹菜单;未命中 → 不 abort,事件继续走默认。 + rightBtnCmd_ = vtkSmartPointer::New(); + rightBtnCmd_->SetClientData(this); + rightBtnCmd_->SetCallback([](vtkObject*, unsigned long, void* client, void*) { + static_cast(client)->handleRightButton(); + }); + rightBtnTag_ = interactor_->AddObserver(vtkCommand::RightButtonPressEvent, rightBtnCmd_, 1.0); } void InteractionManager::uninstallStyle() { @@ -58,6 +75,12 @@ void InteractionManager::uninstallStyle() { style_->onWheelStep = nullptr; style_->getRotateCenter = nullptr; } + // 摘除右键观察者(this 即将析构)。 + if (interactor_ && rightBtnTag_ != 0) { + interactor_->RemoveObserver(rightBtnTag_); + rightBtnTag_ = 0; + } + rightBtnCmd_ = nullptr; // 从 interactor 上彻底摘除自定义 style,避免 interactor 仍持空回调 style(评审 H2)。 if (interactor_) interactor_->SetInteractorStyle(nullptr); style_ = nullptr; @@ -145,6 +168,80 @@ void InteractionManager::flipView() { safeRender(); } +void InteractionManager::faceSelected() { faceSlice(selected_); } + +bool InteractionManager::selectedSliceSpec(Vec3& center, Vec3& normal) const { + if (selected_ < 0 || selected_ >= static_cast(slices_.size())) return false; + const auto& s = slices_[static_cast(selected_)]; + center = s->center(); + normal = s->normal(); + return true; +} + +vtkImageData* InteractionManager::selectedSliceImage() const { + if (selected_ < 0 || selected_ >= static_cast(slices_.size())) return nullptr; + return slices_[static_cast(selected_)]->reslicedOutput(); +} + +vtkSmartPointer 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(kExportLongSide) / longest : 1.0; + if (f < 1.0) f = 1.0; // 不缩小(已够大则原样) + vtkNew resize; + resize->SetInputData(scalar); + resize->SetResizeMethodToOutputDimensions(); + resize->SetOutputDimensions(std::max(1, static_cast(nx * f)), + std::max(1, static_cast(ny * f)), 1); + resize->Update(); + + // 用与切片显示同一色阶 LUT 上色(colorScale_/vmin_/vmax_ 即当前体/切片着色区间)。 + auto lut = buildLut(colorScale_, vmin_, vmax_); + vtkNew map; + map->SetInputConnection(resize->GetOutputPort()); + map->SetLookupTable(lut); + map->SetOutputFormatToRGB(); + map->Update(); + auto out = vtkSmartPointer::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 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(slices_.size())) return; // 无切片可操作 → 放行默认右键 + selected_ = idx; + updateSelectionVisual(); + safeRender(); + if (rightBtnCmd_) rightBtnCmd_->SetAbortFlag(1); // 消费右键,阻止 widget/style 默认行为 + if (onSliceContextMenuRequested) onSliceContextMenuRequested(); +} + int InteractionManager::nearestSlice(const Vec3& worldPoint) const { if (slices_.empty()) return -1; std::vector centers, normals; diff --git a/src/render/interact/InteractionManager.hpp b/src/render/interact/InteractionManager.hpp index f2c42c0..fed33a1 100644 --- a/src/render/interact/InteractionManager.hpp +++ b/src/render/interact/InteractionManager.hpp @@ -1,4 +1,5 @@ #pragma once +#include #include #include @@ -12,6 +13,7 @@ class vtkImageData; class vtkRenderWindow; class vtkRenderWindowInteractor; class vtkRenderer; +class vtkCallbackCommand; namespace geopro::render::interact { @@ -54,6 +56,18 @@ public: // 视图翻转:水平旋转 180°(E55)。 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 selectedSliceColorImage() const; + + // 右键命中切片时回调(manager 已选中所在切片)→ 上层据此弹切片右键菜单(用 QCursor::pos 定位)。 + std::function onSliceContextMenuRequested; // 安装/卸载自定义交互样式(构造时安装;析构卸载恢复原样式)。 void installStyle(); @@ -65,6 +79,10 @@ private: void onDoubleClicked(const Vec3& worldPoint); // 正视所在切片 bool onWheel(int dir); // 推进选中切片;无选中返回 false + // 右键命中切片 → 选中 + 请求弹菜单 + abort(高优先级交互器观察者,先于 vtkImagePlaneWidget + // 消费右键,否则 widget 抢走事件、InteractorStyle 永不触发)。未命中切片则不 abort、放行默认。 + void handleRightButton(); + // 找离世界点最近的切片索引;无切片返回 -1。 int nearestSlice(const Vec3& worldPoint) const; // 按 SliceTool 指针设为选中(widget 交互回调用:触碰即选中)。 @@ -90,6 +108,9 @@ private: int selected_ = -1; // 选中切片索引(-1=无) vtkSmartPointer style_; + // 右键菜单:高优先级交互器观察者(先于 widget 抢右键)。tag 供 uninstall 时摘除。 + vtkSmartPointer rightBtnCmd_; + unsigned long rightBtnTag_ = 0; // 析构进行中:closeAll() 跳过 renderWindow_->Render()(Qt 拆台时窗口可能已半析构, // 析构期再 Render 易崩,评审 M3)。 diff --git a/src/render/interact/SliceTool.cpp b/src/render/interact/SliceTool.cpp index 72d133d..2c6b182 100644 --- a/src/render/interact/SliceTool.cpp +++ b/src/render/interact/SliceTool.cpp @@ -141,6 +141,10 @@ void SliceTool::advance(double step) { widget_->UpdatePlacement(); } +vtkImageData* SliceTool::reslicedOutput() const { + return widget_ ? widget_->GetResliceOutput() : nullptr; +} + double SliceTool::distanceToPlane(const Vec3& p) const { const Vec3 c = center(); const Vec3 n = normal(); diff --git a/src/render/interact/SliceTool.hpp b/src/render/interact/SliceTool.hpp index 98cabbe..6849d29 100644 --- a/src/render/interact/SliceTool.hpp +++ b/src/render/interact/SliceTool.hpp @@ -57,6 +57,9 @@ public: // 世界点到本切面(无限平面)的垂直距离绝对值。供 picker 命中判定"点在哪张切片上"。 double distanceToPlane(const Vec3& worldPoint) const; + // 当前切面重采样得到的 2D 标量影像(导出 dat 用);widget 已释放则 nullptr。 + vtkImageData* reslicedOutput() const; + // 关闭:Off() 并解除 interactor 绑定(幂等)。 void close();