diff --git a/docs/superpowers/HANDOFF-2026-06-26-vtk-anomaly-2d-analysis.md b/docs/superpowers/HANDOFF-2026-06-26-vtk-anomaly-2d-analysis.md index 95e5161..9a3792a 100644 --- a/docs/superpowers/HANDOFF-2026-06-26-vtk-anomaly-2d-analysis.md +++ b/docs/superpowers/HANDOFF-2026-06-26-vtk-anomaly-2d-analysis.md @@ -5,12 +5,13 @@ --- ## 0. 立刻要做的事(下个会话从这里开始) -**实现二维分析改造**,spec 已写好:`docs/superpowers/specs/2026-06-26-2d-analysis-topdown-elevation.md`(commit `227ee8f`)。 -- 设计已与用户**逐条确认**(见 §4 决策)。分期 A→B→C: - - **A(先做)**:一个场景两相机——切「二维分析」tab → 相机锁定**近俯视(75–80°,禁旋转,仅平移/缩放)**;切 tab 翻另一方数据集 actor 的 `SetVisibility`(**不清空**);地形+底图常驻。 - - **B**:二维里选中 2D 内容(单/多选)→ 竖向拖动只改**高程 Z**、锁 XY、实时高程读数。 - - **C**:dd_raster 纳入 2D 过滤 + 按 ddCode 分派渲染 + 栅格地理配准贴地形。**阻塞:dd_raster 数据端点未确认**。 -- 实现锚点:`view2DModeChanged` 信号(`Column2DDataset`→`sceneCtrl`)已存在,在其上扩展相机/可见标志切换。 +**二维分析改造 A 期已实现**(未提交,下个会话需用户实跑反馈手感/角度)。spec:`docs/superpowers/specs/2026-06-26-2d-analysis-topdown-elevation.md`(commit `227ee8f`)。分期 A→B→C: +- **A(已实现 ✅,build+439测试全绿,未提交)**:一场景两相机。切「二维分析」tab → 近俯视(下压12°≈78°俯角)+禁旋转(左键改平移、仅平移/缩放);按维度翻 actor `SetVisibility`(轨迹↔体/帘面/异常,**不清空**);切片 `SetEnabled` 显隐(不销毁);地形+底图常驻;切回三维还原相机快照。**待用户实跑**:①近俯视角度是否合适②切换是否瞬时③左键平移手感④切回三维视角还原是否自然。 + - 改动文件:`CameraPreset.{hpp,cpp}`(applyNearTop2D)、`PickInteractorStyle.{hpp,cpp}`(setLock2D)、`SliceTool.{hpp,cpp}`(setVisible)、`InteractionManager.{hpp,cpp}`(setMode2D)、`VtkSceneView.{hpp,cpp}`(setAnalysisMode2D+mapLineDs_+相机快照)、`ColumnDrawer.{hpp,cpp}`(analysisModeChanged 信号)、`main.cpp`(接信号)。 + - 已知小风险:2D 取景 `computeDataBounds` 含隐藏的 3D 体包围盒(地形主导,影响小);切片 `SetEnabled` 显隐属 GUI 不可自测项。 +- **B(下一步)**:二维里选中 2D 内容(单/多选)→ 竖向拖动只改**高程 Z**、锁 XY、实时高程读数。锚点:新增 2D 拾取-拖动交互(仅 Z 平移),可参考切片 widget;用 `PickInteractorStyle` 在 lock2D 下保留拾取(A 期为简化已禁拾取,B 期需放开 2D 内容拾取)。 + - **注意**:A 期 lock2D 下 `OnLeftButtonDown` 直接 StartPan、跳过拾取。B 期要支持选中 2D 内容拖动,需改为「命中 2D 足迹→进入 Z 拖动;否则平移」。 +- **C**:dd_raster 纳入 2D 过滤 + 按 ddCode 分派渲染 + 栅格地理配准贴地形。**阻塞:dd_raster 数据端点未确认**。 --- diff --git a/src/app/TileBasemap.cpp b/src/app/TileBasemap.cpp index 14052b9..943b83d 100644 --- a/src/app/TileBasemap.cpp +++ b/src/app/TileBasemap.cpp @@ -232,9 +232,11 @@ void TileBasemap::refineTile(int z, int x, int y, std::set& out, int& const double cx = (sw.x + ne.x) * 0.5, cy = (sw.y + ne.y) * 0.5; // 瓦片中心(局部米) const double g = std::max(std::abs(ne.x - sw.x), std::abs(ne.y - sw.y)); // 瓦片地面尺寸(米) - // 距离上限(按剖面范围动态):数据中心在局部原点(0,0);瓦片离它太远则不加载——远裁剪面有界 - // (剖面不被近裁剪面切),也避免拉远无限铺。叶块本身可大于此距离(其近端仍在范围内即保留)。 - if (std::sqrt(cx * cx + cy * cy) - g * 0.5 > maxTileDist_) return; + // 距离上限(按剖面范围动态):以覆盖中心(相机焦点 cenX_,cenY_)为心,瓦片离它太远则不加载—— + // 远裁剪面有界(剖面不被近裁剪面切),也避免拉远无限铺。叶块本身可大于此距离(近端仍在范围内即保留)。 + // 心改用焦点而非原点(0,0):否则 frame 锚在别处数据(如深圳)时,看台湾数据全被剔除→底图空。 + const double rx = cx - cenX_, ry = cy - cenY_; + if (std::sqrt(rx * rx + ry * ry) - g * 0.5 > maxTileDist_) return; // 该瓦片投影到屏幕的近似像素尺寸 > 阈值且未到最大层级 → 细分为 4 子块(近处更细)。 double screenPx; @@ -286,6 +288,10 @@ void TileBasemap::refresh() { if (pl[0] * fp[0] + pl[1] * fp[1] + pl[2] * fp[2] + pl[3] < 0.0) for (int k = 0; k < 4; ++k) pl[k] = -pl[k]; } + // 底图覆盖中心 = 相机焦点(用户正看处)的局部 XY,而非世界原点:frame 锚在首个数据集,看远处别处 + // 数据时原点离视野很远会把全部瓦片距离剔除→底图空。焦点为心则底图随视野走(同 frame 仍与数据对齐)。 + cenX_ = fp[0]; + cenY_ = fp[1]; // 底图最大距离按当前剖面合并范围动态定(随勾选增删自动伸缩);无数据用下限。 maxTileDist_ = kRangeFloor; @@ -294,10 +300,10 @@ void TileBasemap::refresh() { if (r > 0.0) maxTileDist_ = std::clamp(r * kRangeFactor, kRangeFloor, kRangeCeil); } - // 四叉树:从数据中心一圈粗根块出发,按屏幕误差细分 → 近细远粗、铺满视野,无单层级盲区。 + // 四叉树:从覆盖中心(相机焦点经纬)一圈粗根块出发,按屏幕误差细分 → 近细远粗、铺满视野,无盲区。 desired_.clear(); int count = 0; - const auto c = frame_->toLatLon(0.0, 0.0); // 数据中心 + const auto c = frame_->toLatLon(cenX_, cenY_); // 覆盖中心 = 相机焦点(非世界原点) const geopro::render::TileXY root = geopro::render::lonLatToTile(c.lon, c.lat, kRootZoom); for (int dy = -1; dy <= 1; ++dy) for (int dx = -1; dx <= 1; ++dx) diff --git a/src/app/TileBasemap.hpp b/src/app/TileBasemap.hpp index cbfb792..da6e7af 100644 --- a/src/app/TileBasemap.hpp +++ b/src/app/TileBasemap.hpp @@ -84,6 +84,10 @@ private: int satMaxZoom_ = 18; // 四叉树当前帧相机参数(refresh 写, refineTile 读):相机位置 + 投影系数 + 视锥 6 面。 double camX_ = 0, camY_ = 0, camZ_ = 0; + // 底图覆盖中心(相机焦点的局部 XY):四叉树根块取此处经纬、距离剔除以此为心。 + // 关键——不能用世界原点(0,0):frame 锚在首个数据集(如深圳),看远处别处数据(如台湾,相距数百公里)时 + // 原点离视野数百公里→全部瓦片被距离剔除→底图空。改用焦点→底图随视野走(瓦片与数据同 frame 仍对齐)。 + double cenX_ = 0, cenY_ = 0; double projK_ = 1.0; bool projParallel_ = false; double frustum_[24] = {0}; // 6 个视锥平面(内法向),AABB 全在某面外则剔除 diff --git a/src/app/VtkSceneView.cpp b/src/app/VtkSceneView.cpp index c6c98be..0204fe5 100644 --- a/src/app/VtkSceneView.cpp +++ b/src/app/VtkSceneView.cpp @@ -80,12 +80,14 @@ void VtkSceneView::removeProps(std::vector>& props) { } bool VtkSceneView::computeDataBounds(double out[6]) const { + // 仅计「可见」prop:二维分析下 3D 体/帘面已隐藏,取景/坐标轴/底图范围都应只围当前可见维度, + // 否则二维取景被隐藏的远处 3D 体撑歪、坐标轴框错维度。 vtkBoundingBox bb; for (const auto& kv : dsProps_) for (const auto& p : kv.second) - if (p) { if (double* b = p->GetBounds()) bb.AddBounds(b); } + if (p && p->GetVisibility()) { if (double* b = p->GetBounds()) bb.AddBounds(b); } for (const auto& p : miscProps_) - if (p) { if (double* b = p->GetBounds()) bb.AddBounds(b); } + if (p && p->GetVisibility()) { if (double* b = p->GetBounds()) bb.AddBounds(b); } if (!bb.IsValid()) return false; bb.GetBounds(out); return true; @@ -102,6 +104,7 @@ void VtkSceneView::clear() { // 只移除数据 prop(按 ds 跟踪)+ 杂项(地形/测线)+ 坐标轴;不动底图(TileBasemap 自管)→ 重建不丢图。 for (auto& kv : dsProps_) removeProps(kv.second); dsProps_.clear(); + mapLineDs_.clear(); // 2D 足迹维度记录随数据图元一并清(模式标志/相机快照保留) removeProps(miscProps_); clearAnomalies(); // 异常 actor 随清场一并移除 if (currentAxes_) { @@ -149,6 +152,7 @@ void VtkSceneView::addCurtain(const std::string& dsId, const geopro::core::Grid& auto curtain = geopro::render::buildCurtain(grid, cs, *frame_); if (curtain) { curtain->SetScale(1.0, 1.0, verticalExaggeration_); // 纵向夸张成墙 + curtain->SetVisibility(analysisMode2D_ ? 0 : 1); // 帘面=3D内容:二维分析下隐藏 scene_.addActor(curtain); dsProps_[dsId].push_back(curtain); } @@ -167,6 +171,7 @@ void VtkSceneView::addVolume(const std::string& dsId, const geopro::data::Volume // 体 actor 不参与拾取:切片选中靠点中切片平面(widget 交互/拾取)。否则点击落到体内部时 // picker 命中体、worldPoint 落体内 → nearestSlice 按平面距离选错切片(用户 ④ 串选)。 volume->PickableOff(); + volume->SetVisibility(analysisMode2D_ ? 0 : 1); // 体=3D内容:二维分析下隐藏 scene_.addViewProp(volume); dsProps_[dsId].push_back(volume); currentVolumeImage_ = image; @@ -187,8 +192,10 @@ void VtkSceneView::addMapLine(const std::string& dsId, const geopro::data::MapLi anchorFrameIfNeeded(line.lat, line.lon, static_cast(line.lat.size())); auto actor = geopro::render::buildMapLine(line.lat, line.lon, worldZ, *frame_); if (actor) { + actor->SetVisibility(analysisMode2D_ ? 1 : 0); // 足迹=2D内容:仅二维分析下显示 scene_.addActor(actor); dsProps_[dsId].push_back(actor); + mapLineDs_.insert(dsId); // 记录此 ds 为 2D 足迹(切 tab 按维度翻可见) } } @@ -206,6 +213,10 @@ void VtkSceneView::removeDataset(const std::string& dsId) { if (it == dsProps_.end()) return; removeProps(it->second); dsProps_.erase(it); + mapLineDs_.erase(dsId); // 若是 2D 足迹则同步去除维度记录 + // 场景已无任何数据图元 → 复位重锚标志:下个数据(可能在别处)重新把 frame 锚到它,底图随之归位。 + // 否则删到空再加远处新数据时,新数据按旧锚点投到偏远世界坐标、底图仍贴在旧位置 → 底图"消失"。 + if (dsProps_.empty()) frameAnchoredToData_ = false; const bool wasVolume = volumes_.erase(dsId) > 0; if (volumeOwnerDs_ == dsId) { // 移除的是"当前体" → currentImage 回退到剩余某体,无则置空 if (!volumes_.empty()) { @@ -303,6 +314,27 @@ void VtkSceneView::fitView() { if (onCameraChanged) onCameraChanged(); // 取景后 → 底图按新视锥重算覆盖(治首帧部分瓦片不出) } +void VtkSceneView::setAnalysisMode2D(bool is2D) { + if (is2D == analysisMode2D_) return; // 幂等:同模式重复切不做事 + analysisMode2D_ = is2D; + + // ① 按维度翻可见标志(不清空、不重建→切换瞬时):2D 足迹↔3D 帘面/体;异常属 3D。 + // 地形/测线(miscProps_)与底图(TileBasemap 自管)两边常驻、不动。 + for (auto& kv : dsProps_) { + const bool is2dContent = mapLineDs_.count(kv.first) > 0; + const bool vis = is2D ? is2dContent : !is2dContent; + for (auto& p : kv.second) + if (p) p->SetVisibility(vis ? 1 : 0); + } + for (auto& kv : anomalyProps_) + if (kv.second) kv.second->SetVisibility(is2D ? 0 : 1); // 异常=3D内容 + + // ② 取景 + 坐标轴 + 渲染统一走 render():朝向按 analysisMode2D_(已设)选近俯视/自由透视; + // ResetCamera 到"可见"数据包围盒(computeDataBounds 只计可见 prop);rebuildAxes 在二维下自移除; + // 末尾 Render + onCameraChanged(底图按新视锥重算)。不再用相机快照(陈旧易错),每次按可见内容取景。 + render(/*is2D ViewMode=*/false, /*resetCamera=*/true); +} + void VtkSceneView::rebuildAxes() { // 先移除上一次的坐标轴 prop:render 可能在一次 rebuild 内多次调用(末尾统一 render + // 异步回灌 render),不先移除会叠加坐标轴(评审 HIGH)。移除后再算 bounds(仅数据图元)。 @@ -310,6 +342,9 @@ void VtkSceneView::rebuildAxes() { scene_.renderer()->RemoveViewProp(currentAxes_); currentAxes_ = nullptr; } + // 二维分析无立体坐标轴:任何渲染路径(全量/增量/切模式)走到此都不重建坐标轴, + // 保证切到二维分析后坐标轴消失、且后续增量渲染不把它带回来。 + if (analysisMode2D_) return; // 坐标轴随数据包围盒重建:仅按数据图元算 bounds(不含底图,否则被~公里级底图撑大), // 再造 vtkCubeAxesActor 入场。None 模式或无数据 → buildAxes 返回 nullptr,场景无坐标轴。 double bounds[6]; @@ -340,14 +375,18 @@ void VtkSceneView::render(bool is2D, bool resetCamera) { // 坐标轴仅三维视图显示(2D 俯视测线不需要立体坐标轴)。 if (!is2D) rebuildAxes(); // 相机预设(朝向)只在取景时应用——保留相机的重建(改放大系数)不重设朝向,否则也会跳视角。 + // 朝向优先看二维分析模式(本期 A):处二维分析→近俯视;否则按 ViewMode(旧 Map2D 正俯视/三维自由)。 + // 这样 VE 改/项目切等全量重建在二维分析下仍保持近俯视,不跳回三维自由视角。 if (resetCamera) { - if (is2D) + if (analysisMode2D_) + geopro::render::applyNearTop2D(scene_.renderer()); + else if (is2D) geopro::render::applyTop2D(scene_.renderer()); else geopro::render::applyFree3D(scene_.renderer()); double bounds[6]; if (computeDataBounds(bounds)) - scene_.renderer()->ResetCamera(bounds); // 取景到数据(不含底图,否则数据缩成小点) + scene_.renderer()->ResetCamera(bounds); // 取景到"可见"数据(不含底图,否则数据缩成小点) else scene_.renderer()->ResetCamera(); } diff --git a/src/app/VtkSceneView.hpp b/src/app/VtkSceneView.hpp index bff287d..38124d1 100644 --- a/src/app/VtkSceneView.hpp +++ b/src/app/VtkSceneView.hpp @@ -2,6 +2,7 @@ #include #include #include +#include #include #include @@ -82,6 +83,13 @@ public: // 相机程序化变化(取景/预设/缩放)后回调,供底图按新视锥重算覆盖(否则首帧部分瓦片要手动微动才出)。 std::function onCameraChanged; + // ── 二维分析改造 A 期:一场景两相机 ────────────────────────────────────────── + // 切「二维分析」(is2D=true):相机锁近俯视、显 2D 足迹/隐 3D(体/帘面/异常);切回反之。 + // 只翻 actor 可见标志(不清空、不重建)→ 切换瞬时、零重插值。地形/底图常驻不动。 + // 切片显隐 + 交互锁由 InteractionManager::setMode2D 配合(上层在同一处调两者)。 + void setAnalysisMode2D(bool is2D); + bool isAnalysisMode2D() const { return analysisMode2D_; } + private: // 首个带经纬数据(剖面/足迹)到达时把共享 frame 重锚到其 lat/lon 包围盒中心:使数据落在世界原点近旁 // (否则样本默认原点可能离真实数据数百公里→图元在视锥外、移动视角也找不到)。已锚或无经纬则跳过。 @@ -146,6 +154,11 @@ private: std::vector> miscProps_; std::string volumeOwnerDs_; // 当前 currentVolumeImage_ 归属的 ds(其被移除时置空切片源) std::map> anomalyProps_; // 异常 id → 3D actor + + // ── 二维分析改造 A 期 ── + // 哪些 dsProps_ 条目是 2D 足迹(addMapLine):切 tab 按此区分维度翻可见(其余 dsProps_=帘面/体=3D)。 + std::set mapLineDs_; + bool analysisMode2D_ = false; // 当前是否处二维分析(默认三维:启动在「三维分析」tab) }; } // namespace geopro::app diff --git a/src/app/main.cpp b/src/app/main.cpp index bf23def..9718785 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -1078,6 +1078,18 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re if (*view2dMode == 4) sceneCtrl->set2DPlacement(4, z); }); + // ── 二维分析改造 A 期:切「三维分析/二维分析」tab → 一场景两相机 ────────────────── + // 三处协作:①切片隐藏+交互锁(仅平移+缩放) [InteractionManager];②按目标维度重置取景基线 + // [VtkSceneController]——使切换后该维度首条数据自动取景;③维度显隐+近俯视/自由相机+取景+坐标轴+ + // 渲染 [VtkSceneView]。顺序:先 ①②(都不渲染),最后 ③ 收尾统一渲染。只翻可见标志、不清空/重建 → + // 切换瞬时;地形+底图常驻。 + QObject::connect(drawer, &geopro::app::ColumnDrawer::analysisModeChanged, &window, + [interactionMgr, sceneCtrl, sceneView](bool is2D) { + interactionMgr->setMode2D(is2D); + sceneCtrl->onAnalysisModeChanged(is2D); + sceneView->setAnalysisMode2D(is2D); + }); + // 首个真实剖面到达 → frame 重锚到数据 lat/lon 后,把选中的底图加载到数据所在位置 // (默认天地图即在此刻出现,避免启动时在样本原点拉无关瓦片)。 sceneView->onFrameReanchored = [basemap, basemapKind]() { diff --git a/src/app/panels/columns/Column2DDataset.cpp b/src/app/panels/columns/Column2DDataset.cpp index 116dfad..4ee40f4 100644 --- a/src/app/panels/columns/Column2DDataset.cpp +++ b/src/app/panels/columns/Column2DDataset.cpp @@ -1,5 +1,8 @@ #include "panels/columns/Column2DDataset.hpp" +#include +#include + #include #include "EmptyAwareComboBox.hpp" @@ -82,15 +85,25 @@ Column2DDataset::Column2DDataset(QWidget* parent) : QWidget(parent) { } void Column2DDataset::setDatasets(const std::vector& rows) { + // 增量保留:记住当前已勾选的足迹 ds,重建后复原(仍存在的项保持勾选)。否则对象树每次增删勾选都触发 + // 本刷新 → 清空全部勾选 + 上抛空集 → 已渲染足迹被移除、列表选中丢失(用户反馈:必须增量更新, + // 与三维分析段 CategorySection::rebuildList 同一处理)。 + std::set wasChecked; + for (QTreeWidgetItemIterator it(list_); *it; ++it) + if ((*it)->checkState(0) == Qt::Checked) + wasChecked.insert((*it)->data(0, kDsIdRole).toString().toStdString()); + { QSignalBlocker blocker(list_); populateDatasetList(list_, rows, /*append=*/false); for (QTreeWidgetItemIterator it(list_); *it; ++it) { (*it)->setFlags((*it)->flags() | Qt::ItemIsUserCheckable); - (*it)->setCheckState(0, Qt::Unchecked); + const std::string id = (*it)->data(0, kDsIdRole).toString().toStdString(); + // 复原勾选:仍存在的曾勾选项保持勾选;新项默认不勾。 + (*it)->setCheckState(0, wasChecked.count(id) ? Qt::Checked : Qt::Unchecked); } } // blocker released here - // 填充后统一发一次(新载入必为空选):清掉上一次的渲染勾选 + // 上抛复原后的勾选集(保持渲染,不再清空 → 控制器据 diff 增量保留已渲染足迹,集合不变则不增删)。 QStringList ids; for (QTreeWidgetItemIterator it(list_); *it; ++it) if ((*it)->checkState(0) == Qt::Checked) diff --git a/src/app/panels/columns/ColumnDrawer.cpp b/src/app/panels/columns/ColumnDrawer.cpp index 13dd8ad..e7fba88 100644 --- a/src/app/panels/columns/ColumnDrawer.cpp +++ b/src/app/panels/columns/ColumnDrawer.cpp @@ -25,6 +25,10 @@ ColumnDrawer::ColumnDrawer(QWidget* parent, geopro::data::DatasetFieldDictionary tabs->addTab(analysisTab_, QStringLiteral("三维分析")); tabs->addTab(col2D_, QStringLiteral("二维分析")); tabs->tabBar()->setUsesScrollButtons(false); // 永不出左右滚动箭头(两 tab 必能平铺) + // 切 tab → 发 analysisModeChanged(is2D):以"当前 widget 是否 col2D"判定,不写死索引。 + connect(tabs, &QTabWidget::currentChanged, this, [this, tabs](int idx) { + emit analysisModeChanged(tabs->widget(idx) == col2D_); + }); // 折叠按钮:固定宽 18px,垂直拉伸。 // 用 SVG 图标(makeGlyph)而非 ◀/▶ 文字——三角符(U+25C0/25B6)不在 YaHei,作按钮文字会触发 diff --git a/src/app/panels/columns/ColumnDrawer.hpp b/src/app/panels/columns/ColumnDrawer.hpp index bfadc73..77b75e6 100644 --- a/src/app/panels/columns/ColumnDrawer.hpp +++ b/src/app/panels/columns/ColumnDrawer.hpp @@ -23,6 +23,10 @@ public: Column2DDataset* col2D() const { return col2D_; } CategoryAnalysisTab* analysisTab() const { return analysisTab_; } +signals: + // 切换「三维分析 / 二维分析」tab:is2D=true 进入二维分析。上层据此切相机+翻维度可见标志。 + void analysisModeChanged(bool is2D); + public slots: void toggleCollapsed(); void expand(); // 强制展开(进入全屏时确保三栏可见) diff --git a/src/controller/VtkSceneController.cpp b/src/controller/VtkSceneController.cpp index c3ba3c0..47c5ce6 100644 --- a/src/controller/VtkSceneController.cpp +++ b/src/controller/VtkSceneController.cpp @@ -46,7 +46,8 @@ void VtkSceneController::setCheckedDatasets(const QStringList& dsIds) { // 取景意图按「场景是否已有数据到场过」判定,而非 checkedDs_ 是否空——否则连续快速勾选第二个 // ds 时 checkedDs_ 已非空但首批尚未到场,会被误清取景意图,相机不对准数据 → 看似不渲染。 fitOnArrival_ = !hadArrivedData_; - if (checkedDs_.empty()) hadArrivedData_ = false; // 全取消 → 下批到场重新取景 + // 仅当 3D 与 2D 都空才复位取景基线:否则取消全部 3D 但仍有 2D 足迹时,下个 3D 勾选会误跳取景。 + if (checkedDs_.empty() && checked2dDs_.empty()) hadArrivedData_ = false; const unsigned long long gen = rebuildGeneration_; // 不自增:并发增量互不作废 for (const auto& id : checkedDs_) @@ -63,14 +64,16 @@ void VtkSceneController::setChecked2DDatasets(const QStringList& dsIds) { // 二维足迹始终画进 View3D 场景,且按 dsId 跟踪 → 一律增量 diff(不全量重建,不打断 3D 帘面/体)。 const std::set oldSet(checked2dDs_.begin(), checked2dDs_.end()); const std::set newSet(newDs.begin(), newDs.end()); - // 此前空场景(无 3D 数据且无 2D 足迹) → 首批足迹到场自动取景;否则增量追加保持相机不跳。 - const bool wasEmpty = checkedDs_.empty() && checked2dDs_.empty(); for (const auto& id : checked2dDs_) if (!newSet.count(id)) view_.removeDataset(id); // 取消勾选 → 移除该足迹图元 checked2dDs_ = std::move(newDs); - fitOnArrival_ = wasEmpty; // 首批足迹(空场景)取景;否则保持当前相机不跳 + // 取景基线与 3D 路径统一用 hadArrivedData_(而非"两栏皆空"):否则二维分析下若已有隐藏的 3D 数据, + // 勾选首条足迹会因 wasEmpty=false 而不取景 → 足迹落在视野外。切 tab 时 onAnalysisModeChanged 已按 + // 目标维度是否有数据重置该基线,故此处首条可见维度数据能正确取景。 + fitOnArrival_ = !hadArrivedData_; + if (checkedDs_.empty() && checked2dDs_.empty()) hadArrivedData_ = false; // 足迹画进 View3D 场景;mode=0 关闭 → 仅记录勾选不渲染(见 set2DPlacement 切回时补画)。 if (placement2dMode_ != 0 && mode_ == ViewMode::View3D) { @@ -210,6 +213,14 @@ void VtkSceneController::setViewMode(ViewMode mode) { rebuildInternal(); } +void VtkSceneController::onAnalysisModeChanged(bool is2D) { + // 切「三维分析/二维分析」tab:按目标维度是否已有数据重置取景基线。 + // 目标维度空 → hadArrivedData_=false:切换后该维度第一条数据自动取景(治"3D 数据不知生成到哪")。 + // 目标维度非空 → hadArrivedData_=true:视图切换时已 fit 到该维度,后续勾选不再跳(与三维一致)。 + // 显隐/相机/坐标轴由 VtkSceneView::setAnalysisMode2D 处理(上层在同一处调用);此处只管取景基线。 + hadArrivedData_ = is2D ? !checked2dDs_.empty() : !checkedDs_.empty(); +} + void VtkSceneController::setLayer(SceneLayer layer, bool on) { switch (layer) { case SceneLayer::Curtain: showCurtain_ = on; break; diff --git a/src/controller/VtkSceneController.hpp b/src/controller/VtkSceneController.hpp index 15f7571..78a3568 100644 --- a/src/controller/VtkSceneController.hpp +++ b/src/controller/VtkSceneController.hpp @@ -42,6 +42,9 @@ public slots: // 二维足迹摆放高度(mode:0关闭 /1 Z=0 /2 顶部 /3 底部 /4 自定义;customZ 仅 mode=4 用)。 void set2DPlacement(int mode, double customZ); void setViewMode(ViewMode mode); + // 切「三维分析/二维分析」tab(A 期):按目标维度是否已有数据重置取景基线,使切换后该维度第一条 + // 数据自动取景。显隐/相机/坐标轴由 VtkSceneView::setAnalysisMode2D 负责(上层在同一处一并调用)。 + void onAnalysisModeChanged(bool is2D); void setLayer(SceneLayer layer, bool on); void setVerticalExaggeration(double ve); void rebuild(); // 主题切换等外部触发的重渲染 diff --git a/src/render/CameraPreset.cpp b/src/render/CameraPreset.cpp index 533cbc6..3101e24 100644 --- a/src/render/CameraPreset.cpp +++ b/src/render/CameraPreset.cpp @@ -8,6 +8,8 @@ namespace { // 三维斜视方位角 / 仰角。 constexpr double kAzimuth = 30.0; constexpr double kElevation = 25.0; +// 二维分析近俯视:自正俯视下压的角度(12°→俯角约78°)。留一点倾斜使高程差可辨。 +constexpr double kNearTopTilt = 12.0; } // namespace void applyTop2D(vtkRenderer* r) @@ -37,6 +39,20 @@ void applyFree3D(vtkRenderer* r) r->ResetCamera(); } +void applyNearTop2D(vtkRenderer* r) +{ + if (!r) return; + auto* c = r->GetActiveCamera(); + c->ParallelProjectionOff(); // 透视:配合一点倾斜,使高程差可见(正交/正俯视下不可辨) + // 自正俯视(+Z 向下看、北朝上)起,下压 kNearTopTilt → 俯角约 78°;方位不偏(正北俯视)。 + c->SetFocalPoint(0, 0, 0); + c->SetPosition(0, 0, 1); + c->SetViewUp(0, 1, 0); + c->Elevation(kNearTopTilt); + c->OrthogonalizeViewUp(); + r->ResetCamera(); +} + void applyView(vtkRenderer* r, ViewDir dir) { if (!r) return; diff --git a/src/render/CameraPreset.hpp b/src/render/CameraPreset.hpp index 99bfa1a..5061074 100644 --- a/src/render/CameraPreset.hpp +++ b/src/render/CameraPreset.hpp @@ -8,6 +8,10 @@ void applyTop2D(vtkRenderer* r); // 自由三维:透视投影,斜视方位看到剖面立体。 void applyFree3D(vtkRenderer* r); +// 二维分析近俯视:透视投影,自正俯视下压一点(约12°→约78°俯角)。留一点倾斜使高程差可见 +// (绝对正俯视下高程不可辨),仅平移+缩放(旋转由 interactor style 锁定)。 +void applyNearTop2D(vtkRenderer* r); + // 快捷视图方向(世界系 x=East,y=North,z=-depth)。 // Top 俯视 (相机在 +Z 向下看) // Bottom 仰视 (相机在 -Z 向上看) diff --git a/src/render/interact/InteractionManager.cpp b/src/render/interact/InteractionManager.cpp index df09dab..4bb8a47 100644 --- a/src/render/interact/InteractionManager.cpp +++ b/src/render/interact/InteractionManager.cpp @@ -260,6 +260,14 @@ void InteractionManager::closeAll() { safeRender(); } +void InteractionManager::setMode2D(bool is2D) { + // 切片属三维内容:二维分析隐藏(不销毁→切回零重建)、三维分析显示。 + for (auto& s : slices_) + if (s) s->setVisible(!is2D); + if (style_) style_->setLock2D(is2D); // 二维=禁旋转、左键平移(仅平移+缩放) + // 不在此渲染:相机/地形/底图/维度显隐及统一 Render 由 VtkSceneView::setAnalysisMode2D 收尾。 +} + void InteractionManager::flipView() { if (!renderer_) return; auto* cam = renderer_->GetActiveCamera(); diff --git a/src/render/interact/InteractionManager.hpp b/src/render/interact/InteractionManager.hpp index f4d5733..21243c8 100644 --- a/src/render/interact/InteractionManager.hpp +++ b/src/render/interact/InteractionManager.hpp @@ -67,6 +67,10 @@ public: void closeSelected(); // 关闭并释放所有切片(切到二维 / 清场 / 体素重建前调)。 void closeAll(); + + // 切二维分析(is2D=true)/三维分析(false):翻所有切片显隐(不销毁,切回零重建) + 锁/解锁交互样式 + // (二维=仅平移+缩放、禁旋转)。地形/底图/相机由 VtkSceneView::setAnalysisMode2D 处理,此处不渲染。 + void setMode2D(bool is2D); // 关闭并释放某体下的所有切片(该体移除/重建时;不动其它体的切片)。 void closeSlicesOfVolume(const std::string& volumeDsId); diff --git a/src/render/interact/PickInteractorStyle.cpp b/src/render/interact/PickInteractorStyle.cpp index 648d6c3..69e8f22 100644 --- a/src/render/interact/PickInteractorStyle.cpp +++ b/src/render/interact/PickInteractorStyle.cpp @@ -2,6 +2,7 @@ #include +#include #include #include #include @@ -47,6 +48,15 @@ bool PickInteractorStyle::pickWorld(Vec3& out) { void PickInteractorStyle::OnLeftButtonDown() { auto* iren = this->GetInteractor(); + // 二维分析:左键拖动=平移(等同中键),不拾取/不旋转 → 仅平移+缩放。抬键由基类按 State 收尾。 + if (lock2D_) { + const int* p = iren ? iren->GetEventPosition() : nullptr; + if (p) this->FindPokedRenderer(p[0], p[1]); + if (!this->CurrentRenderer) return; + this->GrabFocus(this->EventCallbackCommand); + this->StartPan(); + return; + } Vec3 world; const bool hit = pickWorld(world); @@ -82,6 +92,7 @@ void PickInteractorStyle::OnLeftButtonDown() { } void PickInteractorStyle::Rotate() { + if (lock2D_) return; // 二维分析禁旋转(仅平移+缩放) Vec3 c; if (!this->CurrentRenderer || !getRotateCenter || !getRotateCenter(c)) { Superclass::Rotate(); // 无选中物 → 默认绕焦点旋转 diff --git a/src/render/interact/PickInteractorStyle.hpp b/src/render/interact/PickInteractorStyle.hpp index 294490a..ae31c4e 100644 --- a/src/render/interact/PickInteractorStyle.hpp +++ b/src/render/interact/PickInteractorStyle.hpp @@ -31,6 +31,10 @@ public: // 在"按下开始拖动"时调用一次,把焦点设到该中心(位置同步补偿,画面不变)→ 之后绕它旋转、不跳。 std::function getRotateCenter; + // 二维分析锁:开 → 左键拖动改为平移、禁旋转(仅平移+缩放);关 → 恢复三维拾取/旋转交互。 + void setLock2D(bool on) { lock2D_ = on; } + bool isLock2D() const { return lock2D_; } + void OnLeftButtonDown() override; void OnMouseWheelForward() override; void OnMouseWheelBackward() override; @@ -49,6 +53,9 @@ private: // 记上次左键按下时刻+屏幕位置,两次按下间隔 < kDoubleClickMs 且位置相近视为双击。 double lastDownTime_ = -1.0; // 单调时钟(毫秒),-1=无 int lastDownPos_[2] = {0, 0}; + + // 二维分析模式:左键=平移、禁旋转(仅平移+缩放)。由 InteractionManager 在切 tab 时设。 + bool lock2D_ = false; }; } // namespace geopro::render::interact diff --git a/src/render/interact/SliceTool.cpp b/src/render/interact/SliceTool.cpp index 45b1b19..054feab 100644 --- a/src/render/interact/SliceTool.cpp +++ b/src/render/interact/SliceTool.cpp @@ -172,9 +172,16 @@ vtkImageData* SliceTool::reslicedOutput() const { } void SliceTool::setInteractive(bool on) { + interactive_ = on; // 记录锁定态:setVisible 重显时复原 if (widget_) widget_->SetInteraction(on ? 1 : 0); // 关=锁移动/旋转/光标,纹理仍显示 } +void SliceTool::setVisible(bool on) { + if (!widget_) return; + widget_->SetEnabled(on ? 1 : 0); // 翻显隐(不销毁):几何/纹理保留、切回零重建 + if (on) widget_->SetInteraction(interactive_ ? 1 : 0); // SetEnabled 可能重置交互→复原锁定态 +} + vtkSmartPointer SliceTool::coloredResliceImage() const { if (!widget_) return nullptr; vtkImageMapToColors* cm = widget_->GetColorMap(); // widget 内部把 reslice 经 LUT 上色 → 纹理 diff --git a/src/render/interact/SliceTool.hpp b/src/render/interact/SliceTool.hpp index dfcbd56..c043500 100644 --- a/src/render/interact/SliceTool.hpp +++ b/src/render/interact/SliceTool.hpp @@ -84,11 +84,16 @@ public: // 拾取选中/右键菜单由 PickInteractorStyle 独立处理,不受此影响。 void setInteractive(bool on); + // 显/隐切片(切到二维分析时隐藏,切回再显):SetEnabled 翻显隐而非销毁,几何/位置保留、 + // 切回零重建。重显时复原锁定态(SetEnabled 可能把交互重置为开)。 + void setVisible(bool on); + // 关闭:Off() 并解除 interactor 绑定(幂等)。 void close(); private: SliceAxis axis_; + bool interactive_ = true; // 当前是否允许交互(setInteractive 记录):重显(setVisible)时复原锁定态 std::string dsId_; // 已保存切片归属标签(空=临时交互切片) std::string volumeDsId_; // 所属三维体 dsId(多体并发用) vtkImageData* image_; // 非拥有;生命周期由调用方(VtkSceneView 的 currentVolumeImage_)保证 diff --git a/tests/controller/test_vtk_scene_controller.cpp b/tests/controller/test_vtk_scene_controller.cpp index 0d2bbcf..9704ae0 100644 --- a/tests/controller/test_vtk_scene_controller.cpp +++ b/tests/controller/test_vtk_scene_controller.cpp @@ -489,6 +489,40 @@ TEST(VtkSceneController, TwoDFootprintCoexistsWith3DCurtain) { EXPECT_EQ(view.curtains, 1); } +// 回归(BUG3:二维分析切回三维分析后,三维数据"不知生成到哪",要手动适配才定位): +// 二维勾选足迹自动取景后 hadArrivedData_=true;切回三维前 onAnalysisModeChanged(false) 按"三维栏空" +// 复位取景基线 → 勾选三维数据应自动取景(fitView),而非停在旧相机。 +TEST(VtkSceneController, ThreeDDataFitsAfterSwitchingBackFrom2D) { + FakeDsRepo ds; FakeSceneRepo sc; FakeView view; + VtkSceneController c(ds, sc, view); + c.setViewMode(ViewMode::View3D); + + c.onAnalysisModeChanged(true); // 切到二维(2D 栏空 → 基线允许取景) + c.setChecked2DDatasets({"traj1"}); + ASSERT_EQ(view.mapLines, 1); + const int fitsAfter2D = view.fitCalls; + EXPECT_GE(fitsAfter2D, 1); // 足迹首次到场已取景 + + c.onAnalysisModeChanged(false); // 切回三维(3D 栏空 → 基线允许取景) + c.setCheckedDatasets({"prof1"}); + EXPECT_EQ(view.curtains, 1); + EXPECT_GT(view.fitCalls, fitsAfter2D); // 三维数据到场自动取景(修复前不取景) +} + +// 回归(二维分析下已有隐藏 3D 数据时,勾选首条足迹也应取景;旧 wasEmpty 逻辑因 3D 非空而漏取景): +TEST(VtkSceneController, TwoDFootprintFitsEvenWhenHidden3DExists) { + FakeDsRepo ds; FakeSceneRepo sc; FakeView view; + VtkSceneController c(ds, sc, view); + c.setViewMode(ViewMode::View3D); + c.setCheckedDatasets({"prof1"}); // 三维数据(取景一次) + const int fitsAfter3D = view.fitCalls; + + c.onAnalysisModeChanged(true); // 切到二维(2D 栏空 → 基线允许取景) + c.setChecked2DDatasets({"traj1"}); + EXPECT_EQ(view.mapLines, 1); + EXPECT_GT(view.fitCalls, fitsAfter3D); // 首条足迹取景(旧逻辑因有隐藏 3D 而漏) +} + // 自定义摆放(4) → worldZ=customZ;改摆放重摆已勾选足迹(移除旧 + 按新 Z 重加)。 TEST(VtkSceneController, TwoDPlacementCustomZAndReplace) { FakeDsRepo ds; FakeSceneRepo sc; FakeView view;