diff --git a/src/app/VtkSceneView.cpp b/src/app/VtkSceneView.cpp index 079bfc3..9afc54f 100644 --- a/src/app/VtkSceneView.cpp +++ b/src/app/VtkSceneView.cpp @@ -62,6 +62,9 @@ VtkSceneView::VtkSceneView(geopro::render::Scene& scene, vtkRenderWindow* render void VtkSceneView::clear() { scene_.clear(); // RemoveAllViewProps:连同坐标轴一并移除 currentAxes_ = nullptr; // 旧坐标轴已随 clear 移除,置空避免悬留引用 + // 体素 image 失效:置空并通知上层关闭切片(防切片附着到已移除的 image)。 + currentVolumeImage_ = nullptr; + if (onVolumeChanged) onVolumeChanged(); } void VtkSceneView::setVerticalExaggeration(double ve) { verticalExaggeration_ = ve; } @@ -81,10 +84,20 @@ void VtkSceneView::addCurtain(const geopro::core::Grid& grid, const geopro::core void VtkSceneView::addVolume(const geopro::data::VolumeGrid& vol, const geopro::core::ColorScale& cs) { // 纵向夸张烤进 image 的 z 原点/间距(与帘面 SetScale 同倍,保证纵向一致)。 + // 用暴露 image 的 buildVoxel 重载:保留 currentVolumeImage_ 供 P3 切片附着(几何含 VE)。 + vtkSmartPointer image; auto volume = geopro::render::buildVoxel( vol.vol, cs, vol.origin[0], vol.origin[1], vol.origin[2] * verticalExaggeration_, - vol.spacing[0], vol.spacing[1], vol.spacing[2] * verticalExaggeration_, vol.vmin, vol.vmax); - if (volume) scene_.addViewProp(volume); + vol.spacing[0], vol.spacing[1], vol.spacing[2] * verticalExaggeration_, vol.vmin, vol.vmax, + image); + if (volume) { + scene_.addViewProp(volume); + currentVolumeImage_ = image; + currentColorScale_ = cs; + currentVmin_ = vol.vmin; + currentVmax_ = vol.vmax; + if (onVolumeChanged) onVolumeChanged(); + } } void VtkSceneView::addTerrain(const geopro::data::TerrainPaths& paths) { diff --git a/src/app/VtkSceneView.hpp b/src/app/VtkSceneView.hpp index a06b406..360f11e 100644 --- a/src/app/VtkSceneView.hpp +++ b/src/app/VtkSceneView.hpp @@ -1,10 +1,13 @@ #pragma once +#include #include #include +#include #include #include "I3dSceneView.hpp" +#include "model/ColorScale.hpp" namespace geopro::core { class GeoLocalFrame; } namespace geopro::render { class Scene; } @@ -36,6 +39,18 @@ public: void fitView() override; void render(bool is2D) override; + // ── P3 切片交互:暴露当前体素 image(含 VE 烤入的 origin/spacing)供切片附着 ── + // addVolume 用暴露 image 的 buildVoxel 重载保留;clear/无体素时置空。 + vtkImageData* currentVolumeImage() const { return currentVolumeImage_.Get(); } + const geopro::core::ColorScale& currentColorScale() const { return currentColorScale_; } + double currentVmin() const { return currentVmin_; } + double currentVmax() const { return currentVmax_; } + bool hasVolume() const { return currentVolumeImage_ != nullptr; } + + // 体素 image 变化(addVolume 附着新 image / clear 置空)时回调,供上层把新 image 推给 + // InteractionManager(重附着或关闭切片)。clear 时以 nullptr 触发。 + std::function onVolumeChanged; + private: // 按当前坐标轴设置 + 场景包围盒重建坐标轴 prop(render 末尾调)。 void rebuildAxes(); @@ -53,6 +68,12 @@ private: // 当前坐标轴 prop:render 可能多次调用 rebuildAxes(rebuild 末尾 + 异步回灌), // 持引用以便重建前移除旧 prop,避免叠加(评审 HIGH)。 vtkSmartPointer currentAxes_; + + // 当前体素 image + 色阶(P3 切片附着源);无体素时为空。 + vtkSmartPointer currentVolumeImage_; + geopro::core::ColorScale currentColorScale_; + double currentVmin_ = 0.0; + double currentVmax_ = 0.0; }; } // namespace geopro::app diff --git a/src/app/main.cpp b/src/app/main.cpp index e51a2b1..08c11c3 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -121,6 +121,8 @@ #include "ColorLutBuilder.hpp" #include "Scene.hpp" #include "VoxelFromScatters.hpp" +#include "interact/InteractionManager.hpp" +#include "interact/SlicePlaneMath.hpp" #include "actors/AnomalyActor.hpp" #include "actors/CurtainActor.hpp" #include "actors/ElectrodeActor.hpp" @@ -212,6 +214,31 @@ private: QWidget* header_; }; +// 浮层左下角锚定:随 host 尺寸变化贴左下(切片工具条用,评审 M4)。 +class BottomLeftAnchor : public QObject { +public: + BottomLeftAnchor(QWidget* overlay, QWidget* host) : QObject(host), overlay_(overlay), host_(host) + { + host_->installEventFilter(this); + } + +protected: + bool eventFilter(QObject* obj, QEvent* e) override + { + if (obj == host_ && (e->type() == QEvent::Resize || e->type() == QEvent::Show) && + overlay_->isVisible()) { + overlay_->adjustSize(); + overlay_->move(14, host_->height() - overlay_->height() - 14); + overlay_->raise(); + } + return QObject::eventFilter(obj, e); + } + +private: + QWidget* overlay_; + QWidget* host_; +}; + // 读取 RSA 公钥 PEM 全文(登录时密码加密用)。读不到返回空串,登录将报错。 std::string readPem(const std::string& path) { @@ -275,13 +302,23 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re auto* sceneCtrl = new geopro::controller::VtkSceneController(repo, *scene3dRepo, *sceneView, vtkWidget); sceneCtrl->setVerticalExaggeration(kVerticalExaggeration); - // 非 QObject 堆对象统一在此清理,按构造逆序:sceneView(持 scene&) → scene3dRepo → scene。 - // (sceneCtrl 是 vtkWidget 的 QObject 子对象,由 Qt 在 destroyed 前先析构,不再触发信号回灌。) - QObject::connect(vtkWidget, &QObject::destroyed, [scene, scene3dRepo, sceneView]() { - delete sceneView; - delete scene3dRepo; - delete scene; - }); + + // ── P3 切片交互编排(InteractionManager)───────────────────────────────── + // interactor 由 QVTK 在 setRenderWindow 后提供(renderWindow->GetInteractor())。 + // 安装自定义拾取样式 + 持活动切片。仅三维 + 有体素可用;切到二维 closeAll。 + auto* interactionMgr = new geopro::render::interact::InteractionManager( + renderWindowPtr->GetInteractor(), renderWindowPtr, scene->renderer()); + // sceneView->onVolumeChanged 在切片 UI 接线处统一设置(需 updateSliceButtons 闭包,见下)。 + // 非 QObject 堆对象统一在此清理,按构造逆序: + // interactionMgr(持 interactor/切片观察者) → sceneView(持 scene&) → scene3dRepo → scene。 + // interactionMgr 先析构:closeAll() 解绑所有切片观察者,再拆 scene/interactor,防悬挂崩溃。 + QObject::connect(vtkWidget, &QObject::destroyed, + [scene, scene3dRepo, sceneView, interactionMgr]() { + delete interactionMgr; + delete sceneView; + delete scene3dRepo; + delete scene; + }); // PROJ 可用性(体素/地形/切片层都需配准):失败则浮层相应勾选禁用并提示。 bool crsAvailable = false; @@ -487,6 +524,86 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re // 锚定器 parent=centerWidget,随其销毁;不需保留指针。 new RightTopAnchor(axisBar, centerWidget, viewHeader); + // ──「切片」工具条浮层(P3,spec §9):浮于 QVTK 左下,仅三维 + 有体素时可用。 + // 上下/前后/左右/任意 → 创建对应切片;关闭 → 关当前选中切片;翻转 → 水平 180°。 + // 深色主题复用 P2 工具条同款样式(canvas/* token,不设 border-radius,GL 上四角露浅底)。 + auto* sliceBar = new QFrame(centerWidget); + sliceBar->setFrameShape(QFrame::StyledPanel); + geopro::app::applyTokenizedStyleSheet( + sliceBar, + QStringLiteral( + "QFrame{background:{{canvas/bg-soft}};border:1px solid {{canvas/grid}};}" + "QLabel{color:{{canvas/text}};border:none;background:transparent;}" + "QPushButton{color:{{canvas/text}};background:{{canvas/bg}};border:1px solid {{canvas/grid}};" + "border-radius:4px;padding:2px 8px;}" + "QPushButton:hover{background:{{bg/hover}};border-color:{{accent/primary}};}" + "QPushButton:pressed{background:{{bg/selected}};}" + "QPushButton:disabled{color:{{canvas/text-dim}};border-color:{{canvas/grid}};}")); + auto* sliceLayout = new QHBoxLayout(sliceBar); + sliceLayout->setContentsMargins(geopro::app::space::kMd, geopro::app::space::kSm, + geopro::app::space::kMd, geopro::app::space::kSm); + sliceLayout->setSpacing(geopro::app::space::kSm); + auto* sliceLabel = new QLabel(QStringLiteral("切片")); + auto* btnSliceUpDown = new QPushButton(QStringLiteral("上下")); + auto* btnSliceFrontBack = new QPushButton(QStringLiteral("前后")); + auto* btnSliceLeftRight = new QPushButton(QStringLiteral("左右")); + auto* btnSliceOblique = new QPushButton(QStringLiteral("任意")); + auto* btnSliceFlip = new QPushButton(QStringLiteral("翻转")); + auto* btnSliceClose = new QPushButton(QStringLiteral("关闭")); + sliceLayout->addWidget(sliceLabel); + sliceLayout->addWidget(btnSliceUpDown); + sliceLayout->addWidget(btnSliceFrontBack); + sliceLayout->addWidget(btnSliceLeftRight); + sliceLayout->addWidget(btnSliceOblique); + sliceLayout->addWidget(btnSliceFlip); + sliceLayout->addWidget(btnSliceClose); + sliceBar->setVisible(false); // 默认二维,不显示 + new BottomLeftAnchor(sliceBar, centerWidget); // 随窗口 resize 贴左下(评审 M4) + + // 切片按钮可用性:仅三维 + 有体素时创建/翻转可用;关闭仅在有切片时可用。 + auto updateSliceButtons = [interactionMgr, btnSliceUpDown, btnSliceFrontBack, btnSliceLeftRight, + btnSliceOblique, btnSliceFlip, btnSliceClose, sceneView]() { + const bool canSlice = sceneView->hasVolume() && interactionMgr->hasVolume(); + btnSliceUpDown->setEnabled(canSlice); + btnSliceFrontBack->setEnabled(canSlice); + btnSliceLeftRight->setEnabled(canSlice); + btnSliceOblique->setEnabled(canSlice); + btnSliceFlip->setEnabled(canSlice); + btnSliceClose->setEnabled(interactionMgr->hasSlices()); + }; + updateSliceButtons(); + + using SliceAxis = geopro::render::interact::SliceAxis; + auto addSlice = [interactionMgr, updateSliceButtons](SliceAxis axis) { + interactionMgr->addSlice(axis); + updateSliceButtons(); + }; + QObject::connect(btnSliceUpDown, &QPushButton::clicked, vtkWidget, + [addSlice]() { addSlice(SliceAxis::UpDown); }); + QObject::connect(btnSliceFrontBack, &QPushButton::clicked, vtkWidget, + [addSlice]() { addSlice(SliceAxis::FrontBack); }); + QObject::connect(btnSliceLeftRight, &QPushButton::clicked, vtkWidget, + [addSlice]() { addSlice(SliceAxis::LeftRight); }); + QObject::connect(btnSliceOblique, &QPushButton::clicked, vtkWidget, + [addSlice]() { addSlice(SliceAxis::Oblique); }); + QObject::connect(btnSliceFlip, &QPushButton::clicked, vtkWidget, + [interactionMgr]() { interactionMgr->flipView(); }); + QObject::connect(btnSliceClose, &QPushButton::clicked, vtkWidget, + [interactionMgr, updateSliceButtons]() { + interactionMgr->closeSelected(); + updateSliceButtons(); + }); + // 体素变化(重建/清场)后刷新按钮可用性(切片可能已被 closeAll 清空)。 + sceneView->onVolumeChanged = [interactionMgr, sceneView, updateSliceButtons]() { + if (sceneView->hasVolume()) + interactionMgr->setVolumeImage(sceneView->currentVolumeImage(), + sceneView->currentColorScale(), sceneView->currentVmin(), + sceneView->currentVmax()); + else + interactionMgr->setVolumeImage(nullptr, sceneView->currentColorScale(), 0.0, 0.0); + updateSliceButtons(); + }; + // ── 中央“空状态”引导浮层:未接入真实 sections 时,引导首次使用者从左侧入手。── // 透明背景 + 鼠标穿透(不挡 QVTK 交互);CenterOverlay 随视口尺寸保持居中; // 接入真实中央数据后改成依 sections 是否为空调 setVisible 即可。 @@ -689,7 +806,8 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re // 「视图详情」浮层 + 「三维数据集栏」工具条显隐:仅三维显示。 // 视图详情浮层置左上;P2 工具条置右上(工具条下方),二者均随相机/数据变化保持位置。 - auto showLayerPanel = [layerPanel, axisBar, viewHeader, centerWidget](bool show3D) { + auto showLayerPanel = [layerPanel, axisBar, sliceBar, viewHeader, centerWidget, + updateSliceButtons](bool show3D) { if (show3D) { layerPanel->move(14, viewHeader->height() + 12); layerPanel->adjustSize(); @@ -700,9 +818,16 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re axisBar->move(centerWidget->width() - axisBar->width() - 14, viewHeader->height() + 12); axisBar->setVisible(true); axisBar->raise(); + // 切片工具条:左下角(视图详情浮层下方)。 + sliceBar->adjustSize(); + sliceBar->move(14, centerWidget->height() - sliceBar->height() - 14); + sliceBar->setVisible(true); + sliceBar->raise(); + updateSliceButtons(); } else { layerPanel->setVisible(false); axisBar->setVisible(false); + sliceBar->setVisible(false); } }; @@ -710,7 +835,9 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re using geopro::controller::SceneLayer; using CtrlViewMode = geopro::controller::ViewMode; QObject::connect(act2D, &QAbstractButton::clicked, vtkWidget, - [sceneCtrl, showLayerPanel]() { + [sceneCtrl, showLayerPanel, interactionMgr, updateSliceButtons]() { + interactionMgr->closeAll(); // 切到二维:关闭所有切片(仅三维有切片) + updateSliceButtons(); showLayerPanel(false); sceneCtrl->setViewMode(CtrlViewMode::Map2D); }); diff --git a/src/render/CMakeLists.txt b/src/render/CMakeLists.txt index 81a4066..330356f 100644 --- a/src/render/CMakeLists.txt +++ b/src/render/CMakeLists.txt @@ -1,7 +1,8 @@ -find_package(VTK REQUIRED COMPONENTS CommonCore CommonDataModel FiltersGeometry FiltersModeling RenderingCore RenderingOpenGL2 RenderingVolumeOpenGL2 RenderingAnnotation InteractionStyle InteractionWidgets IOImage) +find_package(VTK REQUIRED COMPONENTS CommonCore CommonDataModel FiltersGeometry FiltersModeling RenderingCore RenderingOpenGL2 RenderingVolumeOpenGL2 RenderingAnnotation InteractionStyle InteractionWidgets ImagingCore IOImage) find_package(GDAL CONFIG REQUIRED) add_library(geopro_render STATIC - Scene.cpp ColorLutBuilder.cpp CameraPreset.cpp VoxelFromScatters.cpp ContourBands.cpp actors/GridContourActor.cpp actors/VoxelActor.cpp actors/CurtainActor.cpp actors/MapLineActor.cpp actors/ScatterActor.cpp actors/AnomalyActor.cpp actors/ElectrodeActor.cpp actors/TerrainActor.cpp actors/AxesActor.cpp) + Scene.cpp ColorLutBuilder.cpp CameraPreset.cpp VoxelFromScatters.cpp ContourBands.cpp actors/GridContourActor.cpp actors/VoxelActor.cpp actors/CurtainActor.cpp actors/MapLineActor.cpp actors/ScatterActor.cpp actors/AnomalyActor.cpp actors/ElectrodeActor.cpp actors/TerrainActor.cpp actors/AxesActor.cpp + interact/SlicePlaneMath.cpp interact/SliceTool.cpp interact/PickInteractorStyle.cpp interact/InteractionManager.cpp) target_include_directories(geopro_render PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) target_link_libraries(geopro_render PUBLIC geopro_core ${VTK_LIBRARIES} GDAL::GDAL) target_compile_features(geopro_render PUBLIC cxx_std_17) diff --git a/src/render/README.md b/src/render/README.md index 4ec49f7..7abef38 100644 --- a/src/render/README.md +++ b/src/render/README.md @@ -7,7 +7,7 @@ - `actors/` — ScatterActor, GridContourActor, VoxelVolumeActor, AnomalyActor, TerrainActor - `color/` — ColorLutBuilder(colorBar → 离散 vtkLookupTable), ScalarBar - `camera/` — CameraPreset(Top2D / Free3D) -- `interact/` — InteractionManager + InteractionTool(Measure/Slice/PickSelect);切片用 vtkResliceCursorWidget +- `interact/` — SlicePlaneMath(纯几何,可测)+ SliceTool(vtkImagePlaneWidget:轴向 + 任意 45° reslice 着色剖面)+ PickInteractorStyle(拾取/双击正视/滚轮)+ InteractionManager(持切片/选中态/分发)。切片走 vtkImageReslice 路线(vtkImagePlaneWidget 内部 reslice + 纹理),非 vtkCutter(spec §9.1) - `ground/` — IGroundLayer + DemImageGroundLayer(M1);TileGroundLayer(M1.5) 网格管线:`vtkImageData(+vtkWarpScalar) → vtkDataSetSurfaceFilter → vtkBandedPolyDataContourFilter(GenerateContourEdgesOn)`(设计 §4.3)。 diff --git a/src/render/interact/InteractionManager.cpp b/src/render/interact/InteractionManager.cpp new file mode 100644 index 0000000..2cebadb --- /dev/null +++ b/src/render/interact/InteractionManager.cpp @@ -0,0 +1,164 @@ +#include "interact/InteractionManager.hpp" + +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "interact/PickInteractorStyle.hpp" + +namespace geopro::render::interact { + +namespace { +std::array imageBounds(vtkImageData* img) { + std::array b{{0, 0, 0, 0, 0, 0}}; + if (img) img->GetBounds(b.data()); + return b; +} +} // namespace + +InteractionManager::InteractionManager(vtkRenderWindowInteractor* interactor, + vtkRenderWindow* renderWindow, vtkRenderer* renderer) + : interactor_(interactor), renderWindow_(renderWindow), renderer_(renderer) { + installStyle(); +} + +InteractionManager::~InteractionManager() { + destroying_ = true; // closeAll 跳过 Render(Qt 拆台时窗口可能已半析构) + closeAll(); + uninstallStyle(); +} + +void InteractionManager::installStyle() { + if (!interactor_ || style_) return; + style_ = vtkSmartPointer::New(); + style_->onPick = [this](const Vec3& w) { onPicked(w); }; + style_->onDoubleClick = [this](const Vec3& w) { onDoubleClicked(w); }; + style_->onWheelStep = [this](int dir) { return onWheel(dir); }; + interactor_->SetInteractorStyle(style_); +} + +void InteractionManager::uninstallStyle() { + if (style_) { + // 断开回调(this 即将析构),避免迟到事件回调悬垂。 + style_->onPick = nullptr; + style_->onDoubleClick = nullptr; + style_->onWheelStep = nullptr; + } + // 从 interactor 上彻底摘除自定义 style,避免 interactor 仍持空回调 style(评审 H2)。 + if (interactor_) interactor_->SetInteractorStyle(nullptr); + style_ = nullptr; +} + +void InteractionManager::safeRender() { + if (renderWindow_ && !destroying_) renderWindow_->Render(); +} + +void InteractionManager::setVolumeImage(vtkImageData* image, const geopro::core::ColorScale& cs, + double vmin, double vmax) { + // 体素重建/变更:先释放旧切片(旧 image 即将失效),再附着新 image。 + closeAll(); + image_ = image; + colorScale_ = cs; + vmin_ = vmin; + vmax_ = vmax; +} + +void InteractionManager::addSlice(SliceAxis axis) { + if (!image_ || !interactor_) return; + auto tool = std::make_unique(image_, interactor_, axis, colorScale_, vmin_, vmax_); + slices_.push_back(std::move(tool)); + selected_ = static_cast(slices_.size()) - 1; // 新切片选中 + safeRender(); +} + +void InteractionManager::closeSelected() { + if (selected_ < 0 || selected_ >= static_cast(slices_.size())) return; + slices_[static_cast(selected_)]->close(); + slices_.erase(slices_.begin() + selected_); + // 选中停在原位就近(删后该位变成下一张;删的是末张则退一张),不跳回 0(评审 M2)。 + selected_ = slices_.empty() ? -1 + : std::min(selected_, static_cast(slices_.size()) - 1); + safeRender(); +} + +void InteractionManager::closeAll() { + for (auto& s : slices_) s->close(); // 显式 Off + 解绑(析构亦会,双保险幂等) + slices_.clear(); + selected_ = -1; + safeRender(); +} + +void InteractionManager::flipView() { + if (!renderer_) return; + auto* cam = renderer_->GetActiveCamera(); + if (!cam) return; + cam->Azimuth(180.0); // 水平旋转 180°(E55) + cam->OrthogonalizeViewUp(); + safeRender(); +} + +int InteractionManager::nearestSlice(const Vec3& worldPoint) const { + if (slices_.empty()) return -1; + std::vector centers, normals; + centers.reserve(slices_.size()); + normals.reserve(slices_.size()); + for (const auto& s : slices_) { + centers.push_back(s->center()); + normals.push_back(s->normal()); + } + const int idx = nearestPlane(centers, normals, worldPoint); + if (idx < 0) return -1; + // 阈值:命中点离最近切面太远(> 体对角线 5%)视为"没点在切片上",不改选中(评审 M2)。 + const std::array b = imageBounds(image_); + const double dx = b[1] - b[0], dy = b[3] - b[2], dz = b[5] - b[4]; + const double diag = std::sqrt(dx * dx + dy * dy + dz * dz); + const double dist = slices_[static_cast(idx)]->distanceToPlane(worldPoint); + if (diag > 0.0 && dist > diag * 0.05) return -1; + return idx; +} + +void InteractionManager::onPicked(const Vec3& worldPoint) { + // 焦点设到命中点 → 拖动绕其旋转(spec C38/D39)。 + if (renderer_) { + if (auto* cam = renderer_->GetActiveCamera()) + cam->SetFocalPoint(worldPoint[0], worldPoint[1], worldPoint[2]); + } + // 若命中点落在某切片上(阈值内),选中之(供滚轮推进/关闭)。 + const int idx = nearestSlice(worldPoint); + if (idx >= 0) selected_ = idx; + safeRender(); +} + +void InteractionManager::onDoubleClicked(const Vec3& worldPoint) { + const int idx = nearestSlice(worldPoint); + if (idx < 0 || !renderer_) return; + auto* cam = renderer_->GetActiveCamera(); + if (!cam) return; + selected_ = idx; + const Vec3 focal = slices_[static_cast(idx)]->center(); + const Vec3 normal = slices_[static_cast(idx)]->normal(); + const double dist = cam->GetDistance(); // 保持当前观察距离 + const FaceOnCamera face = faceOnCamera(focal, normal, dist); + cam->SetFocalPoint(focal[0], focal[1], focal[2]); + cam->SetPosition(face.position[0], face.position[1], face.position[2]); + cam->SetViewUp(face.viewUp[0], face.viewUp[1], face.viewUp[2]); + cam->OrthogonalizeViewUp(); + renderer_->ResetCameraClippingRange(); + safeRender(); +} + +bool InteractionManager::onWheel(int dir) { + if (selected_ < 0 || selected_ >= static_cast(slices_.size())) return false; + const double step = wheelStep(imageBounds(image_), dir); + slices_[static_cast(selected_)]->advance(step); + safeRender(); + return true; // 消费滚轮(不缩放) +} + +} // namespace geopro::render::interact diff --git a/src/render/interact/InteractionManager.hpp b/src/render/interact/InteractionManager.hpp new file mode 100644 index 0000000..3fa3f8e --- /dev/null +++ b/src/render/interact/InteractionManager.hpp @@ -0,0 +1,92 @@ +#pragma once +#include +#include + +#include + +#include "interact/SlicePlaneMath.hpp" +#include "interact/SliceTool.hpp" +#include "model/ColorScale.hpp" + +class vtkImageData; +class vtkRenderWindow; +class vtkRenderWindowInteractor; +class vtkRenderer; + +namespace geopro::render::interact { + +class PickInteractorStyle; + +// 三维切片交互编排(spec §9):持 interactor + 活动切片列表 + 选中态。 +// · 创建/关闭切片(轴向/任意),附着到当前体素 image(含 VE 烤入的几何)。 +// · 安装自定义 PickInteractorStyle:拾取选中→绕命中点旋转;双击切片→正视;滚轮→沿法向推进选中切片。 +// · 视图翻转(水平 Azimuth 180°,E55)。 +// · 切到二维 / 体素重建 / 清场:closeAll 安全释放所有切片(Off + 解绑,防悬挂观察者崩溃)。 +// +// render 层:只碰 VTK widget/相机,不认仓储;产物经回调/上层处理(本期切片仅在视图内交互)。 +class InteractionManager { +public: + // interactor:QVTK 提供的活 interactor(renderWindow->GetInteractor())。 + // renderWindow:用于推进/翻转后重绘。 + InteractionManager(vtkRenderWindowInteractor* interactor, vtkRenderWindow* renderWindow, + vtkRenderer* renderer); + ~InteractionManager(); + + InteractionManager(const InteractionManager&) = delete; + InteractionManager& operator=(const InteractionManager&) = delete; + + // 设置当前体素 image + 色阶(体素重建后调;image 变更先 closeAll 再附着新 image)。 + // image=nullptr → 清空附着,切片创建无效。 + void setVolumeImage(vtkImageData* image, const geopro::core::ColorScale& cs, double vmin, + double vmax); + + // 创建一张切片(轴向/任意)。无体素 image 则忽略。新切片自动设为选中。 + void addSlice(SliceAxis axis); + + // 关闭选中切片(E56)。无选中则忽略。 + void closeSelected(); + // 关闭并释放所有切片(切到二维 / 清场 / 体素重建前调)。 + void closeAll(); + + bool hasVolume() const { return image_ != nullptr; } + bool hasSlices() const { return !slices_.empty(); } + int sliceCount() const { return static_cast(slices_.size()); } + + // 视图翻转:水平旋转 180°(E55)。 + void flipView(); + + // 安装/卸载自定义交互样式(构造时安装;析构卸载恢复原样式)。 + void installStyle(); + void uninstallStyle(); + +private: + // 拾取回调实现(PickInteractorStyle 注入)。 + void onPicked(const Vec3& worldPoint); // 选中所在切片 + 焦点 + void onDoubleClicked(const Vec3& worldPoint); // 正视所在切片 + bool onWheel(int dir); // 推进选中切片;无选中返回 false + + // 找离世界点最近的切片索引;无切片返回 -1。 + int nearestSlice(const Vec3& worldPoint) const; + + // 统一重绘:析构进行中(destroying_)跳过,避免 Qt 拆台时对半析构窗口 Render 崩溃(评审 H3)。 + void safeRender(); + + vtkRenderWindowInteractor* interactor_; + vtkRenderWindow* renderWindow_; + vtkRenderer* renderer_; + + vtkImageData* image_ = nullptr; // 非拥有;当前体素 image + geopro::core::ColorScale colorScale_; + double vmin_ = 0.0, vmax_ = 0.0; + + std::vector> slices_; + int selected_ = -1; // 选中切片索引(-1=无) + + vtkSmartPointer style_; + + // 析构进行中:closeAll() 跳过 renderWindow_->Render()(Qt 拆台时窗口可能已半析构, + // 析构期再 Render 易崩,评审 M3)。 + bool destroying_ = false; +}; + +} // namespace geopro::render::interact diff --git a/src/render/interact/PickInteractorStyle.cpp b/src/render/interact/PickInteractorStyle.cpp new file mode 100644 index 0000000..d07ee7d --- /dev/null +++ b/src/render/interact/PickInteractorStyle.cpp @@ -0,0 +1,58 @@ +#include "interact/PickInteractorStyle.hpp" + +#include +#include +#include +#include +#include + +namespace geopro::render::interact { + +vtkStandardNewMacro(PickInteractorStyle); + +bool PickInteractorStyle::pickWorld(Vec3& out) { + auto* iren = this->GetInteractor(); + if (!iren) return false; + const int* pos = iren->GetEventPosition(); + // 用交互器解析被点中的 renderer(基类 FindPokedRenderer 仅设 CurrentRenderer、返回 void)。 + auto* ren = iren->FindPokedRenderer(pos[0], pos[1]); + if (!ren) return false; + // CellPicker:返回表面交点世界坐标(命中切片纹理面/帘面等)。 + vtkNew picker; + picker->SetTolerance(0.005); + if (!picker->Pick(pos[0], pos[1], 0.0, ren)) return false; + double w[3]; + picker->GetPickPosition(w); + out = {w[0], w[1], w[2]}; + return true; +} + +void PickInteractorStyle::OnLeftButtonDown() { + auto* iren = this->GetInteractor(); + Vec3 world; + const bool hit = pickWorld(world); + + if (hit && iren && iren->GetRepeatCount() > 0) { + // 双击命中 → 正视所在切片(交给 manager 找最近切片 + 算相机)。 + if (onDoubleClick) onDoubleClick(world); + return; // 不进入拖动旋转 + } + if (hit) { + // 单击命中 → 选中 + 以命中点为焦点(拖动绕其旋转)。 + if (onPick) onPick(world); + } + // 始终保留 TrackballCamera 默认拖动(旋转/平移)。 + Superclass::OnLeftButtonDown(); +} + +void PickInteractorStyle::OnMouseWheelForward() { + if (onWheelStep && onWheelStep(+1)) return; // 有选中切片 → 推进,消费滚轮 + Superclass::OnMouseWheelForward(); // 否则默认缩放 +} + +void PickInteractorStyle::OnMouseWheelBackward() { + if (onWheelStep && onWheelStep(-1)) return; + Superclass::OnMouseWheelBackward(); +} + +} // namespace geopro::render::interact diff --git a/src/render/interact/PickInteractorStyle.hpp b/src/render/interact/PickInteractorStyle.hpp new file mode 100644 index 0000000..41fd586 --- /dev/null +++ b/src/render/interact/PickInteractorStyle.hpp @@ -0,0 +1,43 @@ +#pragma once +#include + +#include + +#include "interact/SlicePlaneMath.hpp" + +namespace geopro::render::interact { + +// 自定义交互样式:在 TrackballCamera 基础上加拾取与切片交互(spec §9.3)。 +// 左键按下 → vtkPropPicker 拾取 → 命中则相机 focalPoint=命中点(拖动绕其旋转), +// 并把命中世界点回调出去(InteractionManager 据此选中所在切片)。 +// 左键双击 → 回调双击世界点(InteractionManager 找最近切片 → 相机正视其法向)。 +// 滚轮前/后 → 回调步进方向(±1),由 manager 推进选中切片;无选中则回退默认缩放。 +// 保留 TrackballCamera 的相机拖动/缩放等基础交互(仅在命中/有选中切片时改写行为)。 +// +// 回调由 InteractionManager 注入(render 层不认业务,只发"命中点/双击/滚轮"事件)。 +class PickInteractorStyle : public vtkInteractorStyleTrackballCamera { +public: + static PickInteractorStyle* New(); + vtkTypeMacro(PickInteractorStyle, vtkInteractorStyleTrackballCamera); + + // 单击命中世界点(已命中某 prop)。用于设焦点+选中切片。 + std::function onPick; + // 双击世界点。用于正视所在切片。 + std::function onDoubleClick; + // 滚轮步进:dir=+1 前/-1 后。返回 true 表示已被消费(有选中切片推进), + // false 则执行默认相机缩放。 + std::function onWheelStep; + + void OnLeftButtonDown() override; + void OnMouseWheelForward() override; + void OnMouseWheelBackward() override; + +protected: + PickInteractorStyle() = default; + +private: + // 在当前鼠标位置拾取世界点;命中返回 true 并填 out。 + bool pickWorld(Vec3& out); +}; + +} // namespace geopro::render::interact diff --git a/src/render/interact/SlicePlaneMath.cpp b/src/render/interact/SlicePlaneMath.cpp new file mode 100644 index 0000000..9def0c2 --- /dev/null +++ b/src/render/interact/SlicePlaneMath.cpp @@ -0,0 +1,96 @@ +#include "interact/SlicePlaneMath.hpp" + +#include +#include +#include + +namespace geopro::render::interact { + +namespace { +// 法向接近竖直(±Z)时 viewUp 不能再取"向上",退备用 up。 +constexpr double kVerticalThreshold = 0.999; +constexpr double kSqrt2Inv = 0.70710678118654752440; // sin/cos 45° +} // namespace + +double dot(const Vec3& a, const Vec3& b) { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; } + +double norm(const Vec3& a) { return std::sqrt(dot(a, a)); } + +Vec3 normalize(const Vec3& a) { + const double n = norm(a); + if (n <= 0.0) return {0.0, 0.0, 1.0}; // 零向量兜底 + return {a[0] / n, a[1] / n, a[2] / n}; +} + +Vec3 cross(const Vec3& a, const Vec3& b) { + return {a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]}; +} + +Vec3 axisNormal(SliceAxis axis) { + switch (axis) { + case SliceAxis::UpDown: return {0.0, 0.0, 1.0}; + case SliceAxis::FrontBack: return {0.0, 1.0, 0.0}; + case SliceAxis::LeftRight: return {1.0, 0.0, 0.0}; + case SliceAxis::Oblique: return {kSqrt2Inv, 0.0, kSqrt2Inv}; + } + return {0.0, 0.0, 1.0}; +} + +Vec3 boundsCenter(const std::array& b) { + return {0.5 * (b[0] + b[1]), 0.5 * (b[2] + b[3]), 0.5 * (b[4] + b[5])}; +} + +Vec3 advanceOrigin(const Vec3& origin, const Vec3& normal, double step) { + const Vec3 n = normalize(normal); + return {origin[0] + n[0] * step, origin[1] + n[1] * step, origin[2] + n[2] * step}; +} + +Vec3 clampToBounds(const Vec3& origin, const std::array& b) { + auto clamp1 = [](double v, double lo, double hi) { + if (lo > hi) std::swap(lo, hi); // 容错:bounds 反序 + if (v < lo) return lo; + if (v > hi) return hi; + return v; + }; + return {clamp1(origin[0], b[0], b[1]), clamp1(origin[1], b[2], b[3]), + clamp1(origin[2], b[4], b[5])}; +} + +double wheelStep(const std::array& b, int dir) { + const double dx = b[1] - b[0], dy = b[3] - b[2], dz = b[5] - b[4]; + const double diag = std::sqrt(dx * dx + dy * dy + dz * dz); + const double mag = diag * 0.02; // 一次滚轮 ≈ 1/50 对角线 + return (dir >= 0 ? mag : -mag); +} + +int nearestPlane(const std::vector& centers, const std::vector& normals, + const Vec3& p) { + int best = -1; + double bestDist = 0.0; + for (std::size_t i = 0; i < centers.size() && i < normals.size(); ++i) { + const Vec3 n = normalize(normals[i]); + const Vec3 d{p[0] - centers[i][0], p[1] - centers[i][1], p[2] - centers[i][2]}; + const double dist = std::abs(dot(d, n)); + if (best < 0 || dist < bestDist) { + best = static_cast(i); + bestDist = dist; + } + } + return best; +} + +FaceOnCamera faceOnCamera(const Vec3& focal, const Vec3& normal, double dist) { + const Vec3 n = normalize(normal); + // 相机沿法向退 dist:视线 = focal - position = -n(正对切面)。 + const Vec3 position{focal[0] + n[0] * dist, focal[1] + n[1] * dist, focal[2] + n[2] * dist}; + + // viewUp:取与法向正交、尽量指向 +Z 的向量。 + // worldUp×n 得右向量,再 n×right 得位于切面内且偏上的 up。 + // 法向接近竖直(±Z)时 worldUp 与 n 共线 → 退备用 up=+Y。 + Vec3 worldUp = (std::abs(n[2]) > kVerticalThreshold) ? Vec3{0.0, 1.0, 0.0} : Vec3{0.0, 0.0, 1.0}; + const Vec3 right = normalize(cross(worldUp, n)); + const Vec3 up = normalize(cross(n, right)); + return {position, up}; +} + +} // namespace geopro::render::interact diff --git a/src/render/interact/SlicePlaneMath.hpp b/src/render/interact/SlicePlaneMath.hpp new file mode 100644 index 0000000..2beac40 --- /dev/null +++ b/src/render/interact/SlicePlaneMath.hpp @@ -0,0 +1,61 @@ +#pragma once +#include +#include + +namespace geopro::render::interact { + +// 三维向量别名(世界系;x=East,y=North,z=-depth*VE)。 +using Vec3 = std::array; + +// 轴向切片方向(spec §4 F22–F24): +// UpDown 上下 = 水平面,法向沿 Z((0,0,1))—— 切出"水平剖面"。 +// FrontBack 前后 = 法向沿 Y((0,1,0))。 +// LeftRight 左右 = 法向沿 X((1,0,0))。 +// Oblique 任意(F25)= 初始 45°,可旋转。 +enum class SliceAxis { UpDown, FrontBack, LeftRight, Oblique }; + +// ── 纯几何函数(无 VTK 依赖,可单测)──────────────────────────────────── + +// 轴向/任意切片的初始法向(单位向量)。 +// UpDown→(0,0,1);FrontBack→(0,1,0);LeftRight→(1,0,0); +// Oblique→ XZ 平面内 45°((sin45,0,cos45)),即斜插体的对角面。 +Vec3 axisNormal(SliceAxis axis); + +// 包围盒 [xmin,xmax,ymin,ymax,zmin,zmax] 的中心点。 +Vec3 boundsCenter(const std::array& bounds); + +// 滚轮推进:origin' = origin + normal * step(沿法向平移切面一点)。 +// step>0 正向(沿法向),step<0 反向。 +Vec3 advanceOrigin(const Vec3& origin, const Vec3& normal, double step); + +// 把 origin 夹在包围盒内(沿法向推进时防切面跑出体外)。 +// 逐分量 clamp 到 [min,max];退化轴(min==max)取该值。 +Vec3 clampToBounds(const Vec3& origin, const std::array& bounds); + +// 双击正视:给定切面中心 focal、法向 normal、相机到焦点距离 dist, +// 求相机 position 与 viewUp,使相机正对切面(视线 = -normal)。 +// position = focal + normalize(normal) * dist。 +// viewUp 取与法向正交的"尽量向上(+Z)"向量;当法向接近竖直(±Z)时 +// 退到备用 up=+Y 兜底(避免 viewUp 与视线共线导致相机退化)。 +struct FaceOnCamera { + Vec3 position; + Vec3 viewUp; +}; +FaceOnCamera faceOnCamera(const Vec3& focal, const Vec3& normal, double dist); + +// 滚轮推进步长:取包围盒对角线长度的固定比例 × 方向(±1)。 +// 使一次滚轮在体内移动适中(约 1/50 对角线);dir>0 沿法向、dir<0 反向。 +double wheelStep(const std::array& bounds, int dir); + +// 在切片中心列表中找离世界点最近的索引(按到平面的距离最小)。 +// centers/normals 等长;空列表返回 -1。worldPoint 在哪张切片上→该索引。 +int nearestPlane(const std::vector& centers, const std::vector& normals, + const Vec3& worldPoint); + +// 向量工具(暴露供测试/复用)。 +double dot(const Vec3& a, const Vec3& b); +double norm(const Vec3& a); +Vec3 normalize(const Vec3& a); // 零向量返回 (0,0,1) 兜底 +Vec3 cross(const Vec3& a, const Vec3& b); + +} // namespace geopro::render::interact diff --git a/src/render/interact/SliceTool.cpp b/src/render/interact/SliceTool.cpp new file mode 100644 index 0000000..b40a537 --- /dev/null +++ b/src/render/interact/SliceTool.cpp @@ -0,0 +1,135 @@ +#include "interact/SliceTool.hpp" + +#include +#include + +#include +#include +#include +#include +#include + +#include "ColorLutBuilder.hpp" + +namespace geopro::render::interact { + +namespace { +// 任意切片初始法向(45°,XZ 面内);轴向用 SetPlaneOrientationTo*。 +constexpr double kSqrt2Inv = 0.70710678118654752440; +} // namespace + +SliceTool::SliceTool(vtkImageData* image, vtkRenderWindowInteractor* interactor, SliceAxis axis, + const geopro::core::ColorScale& cs, double vmin, double vmax) + : axis_(axis), image_(image), widget_(vtkSmartPointer::New()) { + // 经 trivial producer 把已存在的 vtkImageData 接入 widget(widget 只暴露 SetInputConnection)。 + // producer_ 为成员,随 SliceTool 保活(局部变量会构造后即析构→管线断裂,评审 H1)。 + producer_ = vtkSmartPointer::New(); + producer_->SetOutput(image_); + widget_->SetInputConnection(producer_->GetOutputPort()); + + widget_->SetInteractor(interactor); + widget_->RestrictPlaneToVolumeOn(); // 切面限制在体内,滚轮推进不跑飞 + widget_->SetResliceInterpolateToLinear(); // reslice 线性插值出连续剖面(非 cutter 交线) + widget_->TextureInterpolateOn(); + widget_->DisplayTextOff(); + + // 色阶 LUT 套用:用户自管 LUT(不让 widget 用默认灰度窗位)。 + auto lut = buildLut(cs, vmin, vmax); + widget_->SetLookupTable(lut); + + // 轴向:固定到 X/Y/Z(角度不可调,符合 G22–G24)。 + // 上下=水平面=Z 法向;前后=Y 法向;左右=X 法向。 + switch (axis_) { + case SliceAxis::UpDown: + widget_->SetPlaneOrientationToZAxes(); + break; + case SliceAxis::FrontBack: + widget_->SetPlaneOrientationToYAxes(); + break; + case SliceAxis::LeftRight: + widget_->SetPlaneOrientationToXAxes(); + break; + case SliceAxis::Oblique: { + // 任意 45°(F25):vtkImagePlaneWidget 用 Origin/Point1/Point2 三角点定义平面 + // (无 SetNormal)。法向 = (Point1-Origin)×(Point2-Origin)。 + // 取法向 (sin45,0,cos45):in-plane 轴1 = Y(0,1,0),轴2 = XZ 内与法向正交方向 (cos45,0,-sin45)。 + // 以体中心为面心,沿两轴各展半个体范围,得一张斜插体的对角面(可继续交互旋转)。 + const auto b = imageBounds(); + const double cx = 0.5 * (b[0] + b[1]); + const double cy = 0.5 * (b[2] + b[3]); + const double cz = 0.5 * (b[4] + b[5]); + const double hy = 0.5 * (b[3] - b[2]); + // 轴2 半长取 X/Z 范围的较大者,保证面铺满体对角。 + const double hxz = 0.5 * std::max(b[1] - b[0], b[5] - b[4]); + // 轴1 = +Y;轴2 = (cos45,0,-sin45)。 + const double a2x = kSqrt2Inv, a2z = -kSqrt2Inv; + // Origin = center - 0.5*axis1 - 0.5*axis2(使 center 为面心)。 + const double ox = cx - 0.0 - a2x * hxz; + const double oy = cy - hy - 0.0; + const double oz = cz - 0.0 - a2z * hxz; + widget_->SetOrigin(ox, oy, oz); + widget_->SetPoint1(ox + 0.0, oy + 2.0 * hy, oz + 0.0); // 沿 +Y + widget_->SetPoint2(ox + a2x * 2.0 * hxz, oy, oz + a2z * 2.0 * hxz); // 沿 (cos45,0,-sin45) + widget_->UpdatePlacement(); + break; + } + } + + widget_->On(); +} + +SliceTool::~SliceTool() { close(); } + +std::array SliceTool::imageBounds() const { + std::array b{{0, 0, 0, 0, 0, 0}}; + if (image_) image_->GetBounds(b.data()); + return b; +} + +Vec3 SliceTool::normal() const { + double n[3] = {0, 0, 1}; + if (widget_) widget_->GetNormal(n); + return normalize({n[0], n[1], n[2]}); +} + +Vec3 SliceTool::center() const { + double c[3] = {0, 0, 0}; + if (widget_) widget_->GetCenter(c); + return {c[0], c[1], c[2]}; +} + +void SliceTool::advance(double step) { + if (!widget_) return; + // 沿法向刚性平移整张切面:origin/point1/point2 同步加 normal*step。只移 origin 会让 + // 面内两端点不动→平面变形/脱轴(评审 M1)。RestrictPlaneToVolumeOn 负责夹在体内。 + const Vec3 n = normal(); + const double d[3] = {n[0] * step, n[1] * step, n[2] * step}; + double o[3], p1[3], p2[3]; + widget_->GetOrigin(o); + widget_->GetPoint1(p1); + widget_->GetPoint2(p2); + for (int i = 0; i < 3; ++i) { + o[i] += d[i]; + p1[i] += d[i]; + p2[i] += d[i]; + } + widget_->SetOrigin(o); + widget_->SetPoint1(p1); + widget_->SetPoint2(p2); + widget_->UpdatePlacement(); +} + +double SliceTool::distanceToPlane(const Vec3& p) const { + const Vec3 c = center(); + const Vec3 n = normal(); + return std::abs(dot({p[0] - c[0], p[1] - c[1], p[2] - c[2]}, n)); +} + +void SliceTool::close() { + if (!widget_) return; + widget_->Off(); + widget_->SetInteractor(nullptr); // 解除观察者,防悬挂崩溃 + widget_ = nullptr; // 置空 → 二次 close()/析构真正幂等(不再 Off 已解绑 widget) +} + +} // namespace geopro::render::interact diff --git a/src/render/interact/SliceTool.hpp b/src/render/interact/SliceTool.hpp new file mode 100644 index 0000000..9f057d4 --- /dev/null +++ b/src/render/interact/SliceTool.hpp @@ -0,0 +1,64 @@ +#pragma once +#include + +#include + +#include "interact/SlicePlaneMath.hpp" +#include "model/ColorScale.hpp" + +class vtkImageData; +class vtkImagePlaneWidget; +class vtkRenderWindowInteractor; +class vtkTrivialProducer; + +namespace geopro::render::interact { + +// 单个切片工具:封装 vtkImagePlaneWidget。 +// 内部对体素 vtkImageData 做 reslice + 纹理着色(spec §9.1 钉死 reslice 路线,非 cutter)。 +// 轴向(UpDown/FrontBack/LeftRight):SetPlaneOrientationToX/Y/Z,角度固定。 +// 任意(Oblique):设初始 45° 法向,允许旋转。 +// 套上调用方提供的色阶 LUT(ColorLutBuilder)。 +// +// 生命周期:构造即 SetInteractor + On()(须传活的 interactor)。 +// 析构(或 close())时 Off(),由 vtkSmartPointer 释放,避免悬挂观察者崩溃。 +// 仅三维视图使用;切到二维由 InteractionManager 统一 close。 +class SliceTool { +public: + // image:体素管线产物(含 VE 烤入的 origin/spacing)。interactor:QVTK 的活 interactor。 + // axis:切面方向。vmin/vmax:色阶区间。 + SliceTool(vtkImageData* image, vtkRenderWindowInteractor* interactor, SliceAxis axis, + const geopro::core::ColorScale& cs, double vmin, double vmax); + ~SliceTool(); + + SliceTool(const SliceTool&) = delete; + SliceTool& operator=(const SliceTool&) = delete; + SliceTool(SliceTool&&) = delete; // 持 VTK widget 观察者,禁移动(仅经 unique_ptr 间接持有) + SliceTool& operator=(SliceTool&&) = delete; + + SliceAxis axis() const { return axis_; } + + // 当前切面法向(世界系单位向量)。 + Vec3 normal() const; + // 当前切面中心(origin)。 + Vec3 center() const; + + // 沿法向推进切面(滚轮,D46):origin += normal*step,夹在 image 包围盒内。 + void advance(double step); + + // 世界点到本切面(无限平面)的垂直距离绝对值。供 picker 命中判定"点在哪张切片上"。 + double distanceToPlane(const Vec3& worldPoint) const; + + // 关闭:Off() 并解除 interactor 绑定(幂等)。 + void close(); + +private: + SliceAxis axis_; + vtkImageData* image_; // 非拥有;生命周期由调用方(VtkSceneView 的 currentVolumeImage_)保证 + // 把已存在的 image 接入 widget 的 producer:须随 SliceTool 保活(否则构造后析构→管线断裂崩溃,评审 H1)。 + vtkSmartPointer producer_; + vtkSmartPointer widget_; + + std::array imageBounds() const; +}; + +} // namespace geopro::render::interact diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 308a9b1..4204ba8 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -100,6 +100,8 @@ target_sources(geopro_tests PRIVATE render/test_terrain.cpp) target_sources(geopro_tests PRIVATE render/test_camera_preset.cpp) # AxesActor(P2):buildAxes(bounds+unit/mode→vtkCubeAxesActor) 单位换算(英尺/经纬度)/不显示返回空。 target_sources(geopro_tests PRIVATE render/test_axes.cpp) +# SlicePlaneMath(P3):切面法向/滚轮平移+夹限/双击正视相机(含竖直兜底)/滚轮步长/最近切片——纯几何。 +target_sources(geopro_tests PRIVATE render/test_slice_plane_math.cpp) target_link_libraries(geopro_tests PRIVATE geopro_render ${VTK_LIBRARIES}) vtk_module_autoinit(TARGETS geopro_tests MODULES ${VTK_LIBRARIES}) diff --git a/tests/render/test_slice_plane_math.cpp b/tests/render/test_slice_plane_math.cpp new file mode 100644 index 0000000..44dd2b2 --- /dev/null +++ b/tests/render/test_slice_plane_math.cpp @@ -0,0 +1,131 @@ +#include + +#include +#include + +#include "interact/SlicePlaneMath.hpp" + +using namespace geopro::render::interact; + +namespace { +void expectVec(const Vec3& a, double x, double y, double z, double eps = 1e-9) { + EXPECT_NEAR(a[0], x, eps); + EXPECT_NEAR(a[1], y, eps); + EXPECT_NEAR(a[2], z, eps); +} +} // namespace + +// ── axisNormal:轴向法向(spec F22–F24)+ 任意 45°(F25)── +TEST(SlicePlaneMath, AxisNormalUpDownIsZ) { expectVec(axisNormal(SliceAxis::UpDown), 0, 0, 1); } +TEST(SlicePlaneMath, AxisNormalFrontBackIsY) { expectVec(axisNormal(SliceAxis::FrontBack), 0, 1, 0); } +TEST(SlicePlaneMath, AxisNormalLeftRightIsX) { expectVec(axisNormal(SliceAxis::LeftRight), 1, 0, 0); } +TEST(SlicePlaneMath, AxisNormalObliqueIs45) { + const auto n = axisNormal(SliceAxis::Oblique); + const double s = std::sqrt(0.5); + expectVec(n, s, 0, s); + EXPECT_NEAR(norm(n), 1.0, 1e-9); // 单位向量 +} + +// ── boundsCenter ── +TEST(SlicePlaneMath, BoundsCenter) { + expectVec(boundsCenter({0, 10, -4, 4, 0, 6}), 5, 0, 3); +} + +// ── advanceOrigin:沿法向平移(滚轮推进,D46)── +TEST(SlicePlaneMath, AdvanceAlongZ) { + expectVec(advanceOrigin({1, 2, 3}, {0, 0, 1}, 5.0), 1, 2, 8); +} +TEST(SlicePlaneMath, AdvanceBackward) { + expectVec(advanceOrigin({1, 2, 3}, {0, 0, 1}, -2.0), 1, 2, 1); +} +TEST(SlicePlaneMath, AdvanceNormalizesDirection) { + // 非单位法向:先归一化再推进,步长为世界距离。 + expectVec(advanceOrigin({0, 0, 0}, {0, 0, 5}, 3.0), 0, 0, 3); +} +TEST(SlicePlaneMath, AdvanceObliqueMovesAlong45) { + const auto o = advanceOrigin({0, 0, 0}, {1, 0, 1}, std::sqrt(2.0)); + expectVec(o, 1, 0, 1); // 沿 45° 推进 √2 → (1,0,1) +} + +// ── clampToBounds:推进出体外被夹回(滚轮限位)── +TEST(SlicePlaneMath, ClampInsideUnchanged) { + expectVec(clampToBounds({5, 0, 3}, {0, 10, -4, 4, 0, 6}), 5, 0, 3); +} +TEST(SlicePlaneMath, ClampOutsideHigh) { + expectVec(clampToBounds({5, 0, 99}, {0, 10, -4, 4, 0, 6}), 5, 0, 6); +} +TEST(SlicePlaneMath, ClampOutsideLow) { + expectVec(clampToBounds({-5, 0, -1}, {0, 10, -4, 4, 0, 6}), 0, 0, 0); +} + +// ── faceOnCamera:双击正视(E54)── +// 法向 +Y:相机退到 focal+Y*dist,视线 = -Y,viewUp = +Z(切面内向上)。 +TEST(SlicePlaneMath, FaceOnFrontBackNormal) { + const auto cam = faceOnCamera({0, 0, 0}, {0, 1, 0}, 10.0); + expectVec(cam.position, 0, 10, 0); + // viewUp 与法向正交且偏 +Z。 + EXPECT_NEAR(dot(cam.viewUp, Vec3{0, 1, 0}), 0.0, 1e-9); + EXPECT_GT(cam.viewUp[2], 0.5); +} +// 法向 +X:position=focal+X*dist,viewUp 偏 +Z。 +TEST(SlicePlaneMath, FaceOnLeftRightNormal) { + const auto cam = faceOnCamera({1, 2, 3}, {1, 0, 0}, 5.0); + expectVec(cam.position, 6, 2, 3); + EXPECT_NEAR(dot(cam.viewUp, Vec3{1, 0, 0}), 0.0, 1e-9); + EXPECT_GT(cam.viewUp[2], 0.5); +} +// 法向竖直 +Z(上下切片):viewUp 不能再取 +Z(与法向共线),兜底取 +Y。 +TEST(SlicePlaneMath, FaceOnVerticalNormalFallsBackToY) { + const auto cam = faceOnCamera({0, 0, 0}, {0, 0, 1}, 8.0); + expectVec(cam.position, 0, 0, 8); + // viewUp 与法向(+Z)正交(z≈0),且非零。 + EXPECT_NEAR(cam.viewUp[2], 0.0, 1e-9); + EXPECT_NEAR(norm(cam.viewUp), 1.0, 1e-9); +} +// 法向竖直 -Z 同样兜底。 +TEST(SlicePlaneMath, FaceOnVerticalDownNormalFallsBack) { + const auto cam = faceOnCamera({0, 0, 0}, {0, 0, -1}, 4.0); + expectVec(cam.position, 0, 0, -4); + EXPECT_NEAR(cam.viewUp[2], 0.0, 1e-9); + EXPECT_NEAR(norm(cam.viewUp), 1.0, 1e-9); +} +// 非单位法向:position 用归一化法向 → 距焦点恰为 dist。 +TEST(SlicePlaneMath, FaceOnNormalizesNormal) { + const auto cam = faceOnCamera({0, 0, 0}, {0, 3, 0}, 6.0); + expectVec(cam.position, 0, 6, 0); +} + +// ── wheelStep:滚轮推进步长(按对角线比例 × 方向)── +TEST(SlicePlaneMath, WheelStepForwardPositive) { + EXPECT_GT(wheelStep({0, 10, 0, 0, 0, 0}, +1), 0.0); +} +TEST(SlicePlaneMath, WheelStepBackwardNegative) { + EXPECT_LT(wheelStep({0, 10, 0, 0, 0, 0}, -1), 0.0); +} +TEST(SlicePlaneMath, WheelStepScalesWithBounds) { + const double small = wheelStep({0, 10, 0, 0, 0, 0}, 1); + const double big = wheelStep({0, 100, 0, 0, 0, 0}, 1); + EXPECT_GT(big, small); // 体越大步长越大 +} + +// ── nearestPlane:找点所在切片(按到平面距离最小)── +TEST(SlicePlaneMath, NearestPlaneEmptyIsMinusOne) { + EXPECT_EQ(nearestPlane({}, {}, {0, 0, 0}), -1); +} +TEST(SlicePlaneMath, NearestPlanePicksClosest) { + // 两张水平切片 z=0 与 z=10(法向 +Z);点 z=8 → 更近 z=10(索引 1)。 + std::vector centers{{0, 0, 0}, {0, 0, 10}}; + std::vector normals{{0, 0, 1}, {0, 0, 1}}; + EXPECT_EQ(nearestPlane(centers, normals, {5, 5, 8}), 1); + EXPECT_EQ(nearestPlane(centers, normals, {5, 5, 2}), 0); +} +TEST(SlicePlaneMath, NearestPlaneIgnoresInPlaneOffset) { + // 单张 z=0 水平面:点无论 x/y 多远,只要 z=0 距离为 0 → 命中。 + std::vector centers{{0, 0, 0}}; + std::vector normals{{0, 0, 1}}; + EXPECT_EQ(nearestPlane(centers, normals, {999, -999, 0}), 0); +} + +// ── 向量工具 ── +TEST(SlicePlaneMath, NormalizeZeroFallsBack) { expectVec(normalize({0, 0, 0}), 0, 0, 1); } +TEST(SlicePlaneMath, CrossBasic) { expectVec(cross({1, 0, 0}, {0, 1, 0}), 0, 0, 1); }