feat(vtk): P3 三维分析切片交互(核心) — 轴向/任意切片+滚轮+拾取+正视

interact/ 交互层(README早规划,本期落地):
- SlicePlaneMath: 纯几何(法向/45°/滚轮平移/双击正视相机含竖直兜底/最近切片), 25 单测
- SliceTool: 封装 vtkImagePlaneWidget — 轴向(上下/前后/左右,角度固定)+任意45°(Origin/Pt1/Pt2),
  套色阶LUT, reslice着色(非cutter), close幂等
- PickInteractorStyle: 继承TrackballCamera+vtkCellPicker, 拾取/双击/滚轮回调
- InteractionManager: 活动切片/选中态/滚轮分发/拾取联动/翻转, 体素变更先closeAll再附着
- VtkSceneView 暴露 currentVolumeImage_(含VE) + onVolumeChanged; main.cpp 切片工具条(上下/前后/左右/任意/翻转/关闭)
- ctest 221/221

评审修复:
- H1 vtkTrivialProducer 提为成员(局部变量构造后析构→管线断裂崩溃)
- H2 uninstallStyle 向 interactor 注销 style
- H3 safeRender 统一守 destroying_ 跳过析构期 Render
- M1 advance 刚性平移 origin+point1+point2(只移origin致轴向切面变形)
- M2 closeSelected 选中位就近不跳0; M4 sliceBar 加 BottomLeftAnchor 随resize

范围外(P4): 切片保存/导出/删除为数据集/三维分析树/右键菜单/异常圈定/详情
This commit is contained in:
gaozheng 2026-06-16 08:23:16 +08:00
parent c44203d6ca
commit 85d4ff57df
15 changed files with 1022 additions and 14 deletions

View File

@ -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<vtkImageData> 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) {

View File

@ -1,10 +1,13 @@
#pragma once
#include <functional>
#include <memory>
#include <vtkCubeAxesActor.h>
#include <vtkImageData.h>
#include <vtkSmartPointer.h>
#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<void()> onVolumeChanged;
private:
// 按当前坐标轴设置 + 场景包围盒重建坐标轴 proprender 末尾调)。
void rebuildAxes();
@ -53,6 +68,12 @@ private:
// 当前坐标轴 proprender 可能多次调用 rebuildAxesrebuild 末尾 + 异步回灌),
// 持引用以便重建前移除旧 prop避免叠加评审 HIGH
vtkSmartPointer<vtkCubeAxesActor> currentAxes_;
// 当前体素 image + 色阶P3 切片附着源);无体素时为空。
vtkSmartPointer<vtkImageData> currentVolumeImage_;
geopro::core::ColorScale currentColorScale_;
double currentVmin_ = 0.0;
double currentVmax_ = 0.0;
};
} // namespace geopro::app

View File

@ -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);
// ──「切片」工具条浮层P3spec §9浮于 QVTK 左下,仅三维 + 有体素时可用。
// 上下/前后/左右/任意 → 创建对应切片;关闭 → 关当前选中切片;翻转 → 水平 180°。
// 深色主题复用 P2 工具条同款样式canvas/* token不设 border-radiusGL 上四角露浅底)。
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);
});

View File

@ -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)

View File

@ -7,7 +7,7 @@
- `actors/` — ScatterActor, GridContourActor, VoxelVolumeActor, AnomalyActor, TerrainActor
- `color/` — ColorLutBuildercolorBar → 离散 vtkLookupTable, ScalarBar
- `camera/` — CameraPresetTop2D / Free3D
- `interact/`InteractionManager + InteractionToolMeasure/Slice/PickSelect切片用 vtkResliceCursorWidget
- `interact/`SlicePlaneMath纯几何可测+ SliceToolvtkImagePlaneWidget轴向 + 任意 45° reslice 着色剖面)+ PickInteractorStyle拾取/双击正视/滚轮)+ InteractionManager持切片/选中态/分发)。切片走 vtkImageReslice 路线vtkImagePlaneWidget 内部 reslice + 纹理),非 vtkCutterspec §9.1
- `ground/` — IGroundLayer + DemImageGroundLayerM1TileGroundLayerM1.5
网格管线:`vtkImageData(+vtkWarpScalar) → vtkDataSetSurfaceFilter → vtkBandedPolyDataContourFilter(GenerateContourEdgesOn)`(设计 §4.3)。

View File

@ -0,0 +1,164 @@
#include "interact/InteractionManager.hpp"
#include <algorithm>
#include <cmath>
#include <cstddef>
#include <vtkCamera.h>
#include <vtkImageData.h>
#include <vtkRenderWindow.h>
#include <vtkRenderWindowInteractor.h>
#include <vtkRenderer.h>
#include "interact/PickInteractorStyle.hpp"
namespace geopro::render::interact {
namespace {
std::array<double, 6> imageBounds(vtkImageData* img) {
std::array<double, 6> 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 跳过 RenderQt 拆台时窗口可能已半析构)
closeAll();
uninstallStyle();
}
void InteractionManager::installStyle() {
if (!interactor_ || style_) return;
style_ = vtkSmartPointer<PickInteractorStyle>::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<SliceTool>(image_, interactor_, axis, colorScale_, vmin_, vmax_);
slices_.push_back(std::move(tool));
selected_ = static_cast<int>(slices_.size()) - 1; // 新切片选中
safeRender();
}
void InteractionManager::closeSelected() {
if (selected_ < 0 || selected_ >= static_cast<int>(slices_.size())) return;
slices_[static_cast<std::size_t>(selected_)]->close();
slices_.erase(slices_.begin() + selected_);
// 选中停在原位就近(删后该位变成下一张;删的是末张则退一张),不跳回 0评审 M2
selected_ = slices_.empty() ? -1
: std::min(selected_, static_cast<int>(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<Vec3> 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<double, 6> 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<std::size_t>(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<std::size_t>(idx)]->center();
const Vec3 normal = slices_[static_cast<std::size_t>(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<int>(slices_.size())) return false;
const double step = wheelStep(imageBounds(image_), dir);
slices_[static_cast<std::size_t>(selected_)]->advance(step);
safeRender();
return true; // 消费滚轮(不缩放)
}
} // namespace geopro::render::interact

View File

@ -0,0 +1,92 @@
#pragma once
#include <memory>
#include <vector>
#include <vtkSmartPointer.h>
#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:
// interactorQVTK 提供的活 interactorrenderWindow->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<int>(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<std::unique_ptr<SliceTool>> slices_;
int selected_ = -1; // 选中切片索引(-1=无)
vtkSmartPointer<PickInteractorStyle> style_;
// 析构进行中closeAll() 跳过 renderWindow_->Render()Qt 拆台时窗口可能已半析构,
// 析构期再 Render 易崩,评审 M3
bool destroying_ = false;
};
} // namespace geopro::render::interact

View File

@ -0,0 +1,58 @@
#include "interact/PickInteractorStyle.hpp"
#include <vtkCellPicker.h>
#include <vtkNew.h>
#include <vtkObjectFactory.h>
#include <vtkRenderWindowInteractor.h>
#include <vtkRenderer.h>
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<vtkCellPicker> 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

View File

@ -0,0 +1,43 @@
#pragma once
#include <functional>
#include <vtkInteractorStyleTrackballCamera.h>
#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<void(const Vec3& worldPoint)> onPick;
// 双击世界点。用于正视所在切片。
std::function<void(const Vec3& worldPoint)> onDoubleClick;
// 滚轮步进dir=+1 前/-1 后。返回 true 表示已被消费(有选中切片推进),
// false 则执行默认相机缩放。
std::function<bool(int dir)> onWheelStep;
void OnLeftButtonDown() override;
void OnMouseWheelForward() override;
void OnMouseWheelBackward() override;
protected:
PickInteractorStyle() = default;
private:
// 在当前鼠标位置拾取世界点;命中返回 true 并填 out。
bool pickWorld(Vec3& out);
};
} // namespace geopro::render::interact

View File

@ -0,0 +1,96 @@
#include "interact/SlicePlaneMath.hpp"
#include <algorithm>
#include <cmath>
#include <cstddef>
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<double, 6>& 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<double, 6>& 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<double, 6>& 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<Vec3>& centers, const std::vector<Vec3>& 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<int>(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

View File

@ -0,0 +1,61 @@
#pragma once
#include <array>
#include <vector>
namespace geopro::render::interact {
// 三维向量别名世界系x=East,y=North,z=-depth*VE
using Vec3 = std::array<double, 3>;
// 轴向切片方向spec §4 F22F24
// 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<double, 6>& 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<double, 6>& 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<double, 6>& bounds, int dir);
// 在切片中心列表中找离世界点最近的索引(按到平面的距离最小)。
// centers/normals 等长;空列表返回 -1。worldPoint 在哪张切片上→该索引。
int nearestPlane(const std::vector<Vec3>& centers, const std::vector<Vec3>& 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

View File

@ -0,0 +1,135 @@
#include "interact/SliceTool.hpp"
#include <algorithm>
#include <cmath>
#include <vtkImageData.h>
#include <vtkImagePlaneWidget.h>
#include <vtkLookupTable.h>
#include <vtkRenderWindowInteractor.h>
#include <vtkTrivialProducer.h>
#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<vtkImagePlaneWidget>::New()) {
// 经 trivial producer 把已存在的 vtkImageData 接入 widgetwidget 只暴露 SetInputConnection
// producer_ 为成员,随 SliceTool 保活(局部变量会构造后即析构→管线断裂,评审 H1
producer_ = vtkSmartPointer<vtkTrivialProducer>::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角度不可调符合 G22G24
// 上下=水平面=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°F25vtkImagePlaneWidget 用 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<double, 6> SliceTool::imageBounds() const {
std::array<double, 6> 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

View File

@ -0,0 +1,64 @@
#pragma once
#include <array>
#include <vtkSmartPointer.h>
#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° 法向,允许旋转。
// 套上调用方提供的色阶 LUTColorLutBuilder
//
// 生命周期:构造即 SetInteractor + On()(须传活的 interactor
// 析构(或 close())时 Off(),由 vtkSmartPointer 释放,避免悬挂观察者崩溃。
// 仅三维视图使用;切到二维由 InteractionManager 统一 close。
class SliceTool {
public:
// image体素管线产物含 VE 烤入的 origin/spacing。interactorQVTK 的活 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;
// 沿法向推进切面滚轮D46origin += 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<vtkTrivialProducer> producer_;
vtkSmartPointer<vtkImagePlaneWidget> widget_;
std::array<double, 6> imageBounds() const;
};
} // namespace geopro::render::interact

View File

@ -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})

View File

@ -0,0 +1,131 @@
#include <gtest/gtest.h>
#include <cmath>
#include <vector>
#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 F22F24+ 任意 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视线 = -YviewUp = +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);
}
// 法向 +Xposition=focal+X*distviewUp 偏 +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<Vec3> centers{{0, 0, 0}, {0, 0, 10}};
std::vector<Vec3> 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<Vec3> centers{{0, 0, 0}};
std::vector<Vec3> 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); }