feat(3d-view): 二维分析A期(一场景两相机)+视图切换/底图/增量修复

- 切「二维分析」tab:锁近俯视(下压12°≈78°)、禁旋转(左键平移=仅平移+缩放)、
  按维度翻 actor 可见(轨迹↔体/帘面/异常,不清空)、切片 SetEnabled 显隐、坐标轴在二维移除;
  地形+底图常驻。ColumnDrawer 新增 analysisModeChanged 信号串起三处协作。
- 修复切回三维数据不取景:按目标维度重置取景基线(VtkSceneController::onAnalysisModeChanged),
  computeDataBounds 只计可见 prop,render 朝向认 analysisMode2D_。
- 修复底图在远离锚点的数据处为空(如台湾,frame 锚深圳):底图取瓦片中心+距离剔除
  改用相机焦点(cenX_/cenY_)而非坐标系原点。
- 修复删到空不重锚致底图错位:removeDataset 清空 dsProps_ 时复位 frameAnchoredToData_。
- 修复二维分析列表非增量:对象树勾选刷新保留已勾选足迹(对齐 CategorySection::rebuildList),
  不再清空渲染/丢失列表选中。
- 新增控制器回归测试 2 项(切模式取景基线)。
This commit is contained in:
gaozheng 2026-06-26 20:53:53 +08:00
parent 4e998374e7
commit 6a10975b6b
20 changed files with 227 additions and 21 deletions

View File

@ -5,12 +5,13 @@
--- ---
## 0. 立刻要做的事(下个会话从这里开始) ## 0. 立刻要做的事(下个会话从这里开始)
**实现二维分析改造**spec 已写好:`docs/superpowers/specs/2026-06-26-2d-analysis-topdown-elevation.md`commit `227ee8f`)。 **二维分析改造 A 期已实现**(未提交,下个会话需用户实跑反馈手感/角度。spec`docs/superpowers/specs/2026-06-26-2d-analysis-topdown-elevation.md`commit `227ee8f`)。分期 A→B→C
- 设计已与用户**逐条确认**(见 §4 决策)。分期 A→B→C - **A已实现 ✅build+439测试全绿未提交**一场景两相机。切「二维分析」tab → 近俯视(下压12°≈78°俯角)+禁旋转(左键改平移、仅平移/缩放);按维度翻 actor `SetVisibility`(轨迹↔体/帘面/异常,**不清空**);切片 `SetEnabled` 显隐(不销毁);地形+底图常驻;切回三维还原相机快照。**待用户实跑**:①近俯视角度是否合适②切换是否瞬时③左键平移手感④切回三维视角还原是否自然。
- **A先做**一个场景两相机——切「二维分析」tab → 相机锁定**近俯视(7580°禁旋转仅平移/缩放)**;切 tab 翻另一方数据集 actor 的 `SetVisibility`**不清空**);地形+底图常驻。 - 改动文件:`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`(接信号)。
- **B**:二维里选中 2D 内容(单/多选)→ 竖向拖动只改**高程 Z**、锁 XY、实时高程读数。 - 已知小风险2D 取景 `computeDataBounds` 含隐藏的 3D 体包围盒(地形主导,影响小);切片 `SetEnabled` 显隐属 GUI 不可自测项。
- **C**dd_raster 纳入 2D 过滤 + 按 ddCode 分派渲染 + 栅格地理配准贴地形。**阻塞dd_raster 数据端点未确认**。 - **B下一步**:二维里选中 2D 内容(单/多选)→ 竖向拖动只改**高程 Z**、锁 XY、实时高程读数。锚点新增 2D 拾取-拖动交互(仅 Z 平移),可参考切片 widget`PickInteractorStyle` 在 lock2D 下保留拾取A 期为简化已禁拾取B 期需放开 2D 内容拾取)。
- 实现锚点:`view2DModeChanged` 信号(`Column2DDataset`→`sceneCtrl`)已存在,在其上扩展相机/可见标志切换。 - **注意**A 期 lock2D 下 `OnLeftButtonDown` 直接 StartPan、跳过拾取。B 期要支持选中 2D 内容拖动,需改为「命中 2D 足迹→进入 Z 拖动;否则平移」。
- **C**dd_raster 纳入 2D 过滤 + 按 ddCode 分派渲染 + 栅格地理配准贴地形。**阻塞dd_raster 数据端点未确认**。
--- ---

View File

@ -232,9 +232,11 @@ void TileBasemap::refineTile(int z, int x, int y, std::set<long long>& out, int&
const double cx = (sw.x + ne.x) * 0.5, cy = (sw.y + ne.y) * 0.5; // 瓦片中心(局部米) 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)); // 瓦片地面尺寸(米) const double g = std::max(std::abs(ne.x - sw.x), std::abs(ne.y - sw.y)); // 瓦片地面尺寸(米)
// 距离上限(按剖面范围动态):数据中心在局部原点(0,0);瓦片离它太远则不加载——远裁剪面有界 // 距离上限(按剖面范围动态):以覆盖中心(相机焦点 cenX_,cenY_)为心,瓦片离它太远则不加载——
// (剖面不被近裁剪面切),也避免拉远无限铺。叶块本身可大于此距离(其近端仍在范围内即保留)。 // 远裁剪面有界(剖面不被近裁剪面切),也避免拉远无限铺。叶块本身可大于此距离(近端仍在范围内即保留)。
if (std::sqrt(cx * cx + cy * cy) - g * 0.5 > maxTileDist_) return; // 心改用焦点而非原点(0,0):否则 frame 锚在别处数据(如深圳)时,看台湾数据全被剔除→底图空。
const double rx = cx - cenX_, ry = cy - cenY_;
if (std::sqrt(rx * rx + ry * ry) - g * 0.5 > maxTileDist_) return;
// 该瓦片投影到屏幕的近似像素尺寸 > 阈值且未到最大层级 → 细分为 4 子块(近处更细)。 // 该瓦片投影到屏幕的近似像素尺寸 > 阈值且未到最大层级 → 细分为 4 子块(近处更细)。
double screenPx; 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) 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]; for (int k = 0; k < 4; ++k) pl[k] = -pl[k];
} }
// 底图覆盖中心 = 相机焦点(用户正看处)的局部 XY而非世界原点frame 锚在首个数据集,看远处别处
// 数据时原点离视野很远会把全部瓦片距离剔除→底图空。焦点为心则底图随视野走(同 frame 仍与数据对齐)。
cenX_ = fp[0];
cenY_ = fp[1];
// 底图最大距离按当前剖面合并范围动态定(随勾选增删自动伸缩);无数据用下限。 // 底图最大距离按当前剖面合并范围动态定(随勾选增删自动伸缩);无数据用下限。
maxTileDist_ = kRangeFloor; maxTileDist_ = kRangeFloor;
@ -294,10 +300,10 @@ void TileBasemap::refresh() {
if (r > 0.0) maxTileDist_ = std::clamp(r * kRangeFactor, kRangeFloor, kRangeCeil); if (r > 0.0) maxTileDist_ = std::clamp(r * kRangeFactor, kRangeFloor, kRangeCeil);
} }
// 四叉树:从数据中心一圈粗根块出发,按屏幕误差细分 → 近细远粗、铺满视野,无单层级盲区。 // 四叉树:从覆盖中心(相机焦点经纬)一圈粗根块出发,按屏幕误差细分 → 近细远粗、铺满视野,无盲区。
desired_.clear(); desired_.clear();
int count = 0; 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); const geopro::render::TileXY root = geopro::render::lonLatToTile(c.lon, c.lat, kRootZoom);
for (int dy = -1; dy <= 1; ++dy) for (int dy = -1; dy <= 1; ++dy)
for (int dx = -1; dx <= 1; ++dx) for (int dx = -1; dx <= 1; ++dx)

View File

@ -84,6 +84,10 @@ private:
int satMaxZoom_ = 18; int satMaxZoom_ = 18;
// 四叉树当前帧相机参数(refresh 写, refineTile 读):相机位置 + 投影系数 + 视锥 6 面。 // 四叉树当前帧相机参数(refresh 写, refineTile 读):相机位置 + 投影系数 + 视锥 6 面。
double camX_ = 0, camY_ = 0, camZ_ = 0; double camX_ = 0, camY_ = 0, camZ_ = 0;
// 底图覆盖中心(相机焦点的局部 XY):四叉树根块取此处经纬、距离剔除以此为心。
// 关键——不能用世界原点(0,0)frame 锚在首个数据集(如深圳),看远处别处数据(如台湾,相距数百公里)时
// 原点离视野数百公里→全部瓦片被距离剔除→底图空。改用焦点→底图随视野走(瓦片与数据同 frame 仍对齐)。
double cenX_ = 0, cenY_ = 0;
double projK_ = 1.0; double projK_ = 1.0;
bool projParallel_ = false; bool projParallel_ = false;
double frustum_[24] = {0}; // 6 个视锥平面(内法向)AABB 全在某面外则剔除 double frustum_[24] = {0}; // 6 个视锥平面(内法向)AABB 全在某面外则剔除

View File

@ -80,12 +80,14 @@ void VtkSceneView::removeProps(std::vector<vtkSmartPointer<vtkProp>>& props) {
} }
bool VtkSceneView::computeDataBounds(double out[6]) const { bool VtkSceneView::computeDataBounds(double out[6]) const {
// 仅计「可见」prop二维分析下 3D 体/帘面已隐藏,取景/坐标轴/底图范围都应只围当前可见维度,
// 否则二维取景被隐藏的远处 3D 体撑歪、坐标轴框错维度。
vtkBoundingBox bb; vtkBoundingBox bb;
for (const auto& kv : dsProps_) for (const auto& kv : dsProps_)
for (const auto& p : kv.second) 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_) 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; if (!bb.IsValid()) return false;
bb.GetBounds(out); bb.GetBounds(out);
return true; return true;
@ -102,6 +104,7 @@ void VtkSceneView::clear() {
// 只移除数据 prop按 ds 跟踪)+ 杂项(地形/测线)+ 坐标轴;不动底图(TileBasemap 自管)→ 重建不丢图。 // 只移除数据 prop按 ds 跟踪)+ 杂项(地形/测线)+ 坐标轴;不动底图(TileBasemap 自管)→ 重建不丢图。
for (auto& kv : dsProps_) removeProps(kv.second); for (auto& kv : dsProps_) removeProps(kv.second);
dsProps_.clear(); dsProps_.clear();
mapLineDs_.clear(); // 2D 足迹维度记录随数据图元一并清(模式标志/相机快照保留)
removeProps(miscProps_); removeProps(miscProps_);
clearAnomalies(); // 异常 actor 随清场一并移除 clearAnomalies(); // 异常 actor 随清场一并移除
if (currentAxes_) { 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_); auto curtain = geopro::render::buildCurtain(grid, cs, *frame_);
if (curtain) { if (curtain) {
curtain->SetScale(1.0, 1.0, verticalExaggeration_); // 纵向夸张成墙 curtain->SetScale(1.0, 1.0, verticalExaggeration_); // 纵向夸张成墙
curtain->SetVisibility(analysisMode2D_ ? 0 : 1); // 帘面=3D内容二维分析下隐藏
scene_.addActor(curtain); scene_.addActor(curtain);
dsProps_[dsId].push_back(curtain); dsProps_[dsId].push_back(curtain);
} }
@ -167,6 +171,7 @@ void VtkSceneView::addVolume(const std::string& dsId, const geopro::data::Volume
// 体 actor 不参与拾取切片选中靠点中切片平面widget 交互/拾取)。否则点击落到体内部时 // 体 actor 不参与拾取切片选中靠点中切片平面widget 交互/拾取)。否则点击落到体内部时
// picker 命中体、worldPoint 落体内 → nearestSlice 按平面距离选错切片(用户 ④ 串选)。 // picker 命中体、worldPoint 落体内 → nearestSlice 按平面距离选错切片(用户 ④ 串选)。
volume->PickableOff(); volume->PickableOff();
volume->SetVisibility(analysisMode2D_ ? 0 : 1); // 体=3D内容二维分析下隐藏
scene_.addViewProp(volume); scene_.addViewProp(volume);
dsProps_[dsId].push_back(volume); dsProps_[dsId].push_back(volume);
currentVolumeImage_ = image; currentVolumeImage_ = image;
@ -187,8 +192,10 @@ void VtkSceneView::addMapLine(const std::string& dsId, const geopro::data::MapLi
anchorFrameIfNeeded(line.lat, line.lon, static_cast<int>(line.lat.size())); anchorFrameIfNeeded(line.lat, line.lon, static_cast<int>(line.lat.size()));
auto actor = geopro::render::buildMapLine(line.lat, line.lon, worldZ, *frame_); auto actor = geopro::render::buildMapLine(line.lat, line.lon, worldZ, *frame_);
if (actor) { if (actor) {
actor->SetVisibility(analysisMode2D_ ? 1 : 0); // 足迹=2D内容仅二维分析下显示
scene_.addActor(actor); scene_.addActor(actor);
dsProps_[dsId].push_back(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; if (it == dsProps_.end()) return;
removeProps(it->second); removeProps(it->second);
dsProps_.erase(it); dsProps_.erase(it);
mapLineDs_.erase(dsId); // 若是 2D 足迹则同步去除维度记录
// 场景已无任何数据图元 → 复位重锚标志:下个数据(可能在别处)重新把 frame 锚到它,底图随之归位。
// 否则删到空再加远处新数据时,新数据按旧锚点投到偏远世界坐标、底图仍贴在旧位置 → 底图"消失"。
if (dsProps_.empty()) frameAnchoredToData_ = false;
const bool wasVolume = volumes_.erase(dsId) > 0; const bool wasVolume = volumes_.erase(dsId) > 0;
if (volumeOwnerDs_ == dsId) { // 移除的是"当前体" → currentImage 回退到剩余某体,无则置空 if (volumeOwnerDs_ == dsId) { // 移除的是"当前体" → currentImage 回退到剩余某体,无则置空
if (!volumes_.empty()) { if (!volumes_.empty()) {
@ -303,6 +314,27 @@ void VtkSceneView::fitView() {
if (onCameraChanged) onCameraChanged(); // 取景后 → 底图按新视锥重算覆盖(治首帧部分瓦片不出) 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() { void VtkSceneView::rebuildAxes() {
// 先移除上一次的坐标轴 proprender 可能在一次 rebuild 内多次调用(末尾统一 render + // 先移除上一次的坐标轴 proprender 可能在一次 rebuild 内多次调用(末尾统一 render +
// 异步回灌 render不先移除会叠加坐标轴评审 HIGH。移除后再算 bounds仅数据图元 // 异步回灌 render不先移除会叠加坐标轴评审 HIGH。移除后再算 bounds仅数据图元
@ -310,6 +342,9 @@ void VtkSceneView::rebuildAxes() {
scene_.renderer()->RemoveViewProp(currentAxes_); scene_.renderer()->RemoveViewProp(currentAxes_);
currentAxes_ = nullptr; currentAxes_ = nullptr;
} }
// 二维分析无立体坐标轴:任何渲染路径(全量/增量/切模式)走到此都不重建坐标轴,
// 保证切到二维分析后坐标轴消失、且后续增量渲染不把它带回来。
if (analysisMode2D_) return;
// 坐标轴随数据包围盒重建:仅按数据图元算 bounds(不含底图,否则被~公里级底图撑大) // 坐标轴随数据包围盒重建:仅按数据图元算 bounds(不含底图,否则被~公里级底图撑大)
// 再造 vtkCubeAxesActor 入场。None 模式或无数据 → buildAxes 返回 nullptr场景无坐标轴。 // 再造 vtkCubeAxesActor 入场。None 模式或无数据 → buildAxes 返回 nullptr场景无坐标轴。
double bounds[6]; double bounds[6];
@ -340,14 +375,18 @@ void VtkSceneView::render(bool is2D, bool resetCamera) {
// 坐标轴仅三维视图显示2D 俯视测线不需要立体坐标轴)。 // 坐标轴仅三维视图显示2D 俯视测线不需要立体坐标轴)。
if (!is2D) rebuildAxes(); if (!is2D) rebuildAxes();
// 相机预设(朝向)只在取景时应用——保留相机的重建(改放大系数)不重设朝向,否则也会跳视角。 // 相机预设(朝向)只在取景时应用——保留相机的重建(改放大系数)不重设朝向,否则也会跳视角。
// 朝向优先看二维分析模式(本期 A):处二维分析→近俯视;否则按 ViewMode(旧 Map2D 正俯视/三维自由)。
// 这样 VE 改/项目切等全量重建在二维分析下仍保持近俯视,不跳回三维自由视角。
if (resetCamera) { if (resetCamera) {
if (is2D) if (analysisMode2D_)
geopro::render::applyNearTop2D(scene_.renderer());
else if (is2D)
geopro::render::applyTop2D(scene_.renderer()); geopro::render::applyTop2D(scene_.renderer());
else else
geopro::render::applyFree3D(scene_.renderer()); geopro::render::applyFree3D(scene_.renderer());
double bounds[6]; double bounds[6];
if (computeDataBounds(bounds)) if (computeDataBounds(bounds))
scene_.renderer()->ResetCamera(bounds); // 取景到数据(不含底图,否则数据缩成小点) scene_.renderer()->ResetCamera(bounds); // 取景到"可见"数据(不含底图,否则数据缩成小点)
else else
scene_.renderer()->ResetCamera(); scene_.renderer()->ResetCamera();
} }

View File

@ -2,6 +2,7 @@
#include <functional> #include <functional>
#include <map> #include <map>
#include <memory> #include <memory>
#include <set>
#include <string> #include <string>
#include <vector> #include <vector>
@ -82,6 +83,13 @@ public:
// 相机程序化变化(取景/预设/缩放)后回调,供底图按新视锥重算覆盖(否则首帧部分瓦片要手动微动才出)。 // 相机程序化变化(取景/预设/缩放)后回调,供底图按新视锥重算覆盖(否则首帧部分瓦片要手动微动才出)。
std::function<void()> onCameraChanged; std::function<void()> onCameraChanged;
// ── 二维分析改造 A 期:一场景两相机 ──────────────────────────────────────────
// 切「二维分析」(is2D=true):相机锁近俯视、显 2D 足迹/隐 3D(体/帘面/异常);切回反之。
// 只翻 actor 可见标志(不清空、不重建)→ 切换瞬时、零重插值。地形/底图常驻不动。
// 切片显隐 + 交互锁由 InteractionManager::setMode2D 配合(上层在同一处调两者)。
void setAnalysisMode2D(bool is2D);
bool isAnalysisMode2D() const { return analysisMode2D_; }
private: private:
// 首个带经纬数据(剖面/足迹)到达时把共享 frame 重锚到其 lat/lon 包围盒中心:使数据落在世界原点近旁 // 首个带经纬数据(剖面/足迹)到达时把共享 frame 重锚到其 lat/lon 包围盒中心:使数据落在世界原点近旁
// (否则样本默认原点可能离真实数据数百公里→图元在视锥外、移动视角也找不到)。已锚或无经纬则跳过。 // (否则样本默认原点可能离真实数据数百公里→图元在视锥外、移动视角也找不到)。已锚或无经纬则跳过。
@ -146,6 +154,11 @@ private:
std::vector<vtkSmartPointer<vtkProp>> miscProps_; std::vector<vtkSmartPointer<vtkProp>> miscProps_;
std::string volumeOwnerDs_; // 当前 currentVolumeImage_ 归属的 ds其被移除时置空切片源 std::string volumeOwnerDs_; // 当前 currentVolumeImage_ 归属的 ds其被移除时置空切片源
std::map<std::string, vtkSmartPointer<vtkActor>> anomalyProps_; // 异常 id → 3D actor std::map<std::string, vtkSmartPointer<vtkActor>> anomalyProps_; // 异常 id → 3D actor
// ── 二维分析改造 A 期 ──
// 哪些 dsProps_ 条目是 2D 足迹(addMapLine):切 tab 按此区分维度翻可见(其余 dsProps_=帘面/体=3D)。
std::set<std::string> mapLineDs_;
bool analysisMode2D_ = false; // 当前是否处二维分析(默认三维启动在「三维分析」tab)
}; };
} // namespace geopro::app } // namespace geopro::app

View File

@ -1078,6 +1078,18 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
if (*view2dMode == 4) sceneCtrl->set2DPlacement(4, z); 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 后,把选中的底图加载到数据所在位置 // 首个真实剖面到达 → frame 重锚到数据 lat/lon 后,把选中的底图加载到数据所在位置
// (默认天地图即在此刻出现,避免启动时在样本原点拉无关瓦片)。 // (默认天地图即在此刻出现,避免启动时在样本原点拉无关瓦片)。
sceneView->onFrameReanchored = [basemap, basemapKind]() { sceneView->onFrameReanchored = [basemap, basemapKind]() {

View File

@ -1,5 +1,8 @@
#include "panels/columns/Column2DDataset.hpp" #include "panels/columns/Column2DDataset.hpp"
#include <set>
#include <string>
#include <QComboBox> #include <QComboBox>
#include "EmptyAwareComboBox.hpp" #include "EmptyAwareComboBox.hpp"
@ -82,15 +85,25 @@ Column2DDataset::Column2DDataset(QWidget* parent) : QWidget(parent) {
} }
void Column2DDataset::setDatasets(const std::vector<geopro::data::DsRow>& rows) { void Column2DDataset::setDatasets(const std::vector<geopro::data::DsRow>& rows) {
// 增量保留:记住当前已勾选的足迹 ds重建后复原仍存在的项保持勾选。否则对象树每次增删勾选都触发
// 本刷新 → 清空全部勾选 + 上抛空集 → 已渲染足迹被移除、列表选中丢失(用户反馈:必须增量更新,
// 与三维分析段 CategorySection::rebuildList 同一处理)。
std::set<std::string> wasChecked;
for (QTreeWidgetItemIterator it(list_); *it; ++it)
if ((*it)->checkState(0) == Qt::Checked)
wasChecked.insert((*it)->data(0, kDsIdRole).toString().toStdString());
{ {
QSignalBlocker blocker(list_); QSignalBlocker blocker(list_);
populateDatasetList(list_, rows, /*append=*/false); populateDatasetList(list_, rows, /*append=*/false);
for (QTreeWidgetItemIterator it(list_); *it; ++it) { for (QTreeWidgetItemIterator it(list_); *it; ++it) {
(*it)->setFlags((*it)->flags() | Qt::ItemIsUserCheckable); (*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 } // blocker released here
// 填充后统一发一次(新载入必为空选):清掉上一次的渲染勾选 // 上抛复原后的勾选集(保持渲染,不再清空 → 控制器据 diff 增量保留已渲染足迹,集合不变则不增删)。
QStringList ids; QStringList ids;
for (QTreeWidgetItemIterator it(list_); *it; ++it) for (QTreeWidgetItemIterator it(list_); *it; ++it)
if ((*it)->checkState(0) == Qt::Checked) if ((*it)->checkState(0) == Qt::Checked)

View File

@ -25,6 +25,10 @@ ColumnDrawer::ColumnDrawer(QWidget* parent, geopro::data::DatasetFieldDictionary
tabs->addTab(analysisTab_, QStringLiteral("三维分析")); tabs->addTab(analysisTab_, QStringLiteral("三维分析"));
tabs->addTab(col2D_, QStringLiteral("二维分析")); tabs->addTab(col2D_, QStringLiteral("二维分析"));
tabs->tabBar()->setUsesScrollButtons(false); // 永不出左右滚动箭头(两 tab 必能平铺) 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垂直拉伸。 // 折叠按钮:固定宽 18px垂直拉伸。
// 用 SVG 图标(makeGlyph)而非 ◀/▶ 文字——三角符(U+25C0/25B6)不在 YaHei作按钮文字会触发 // 用 SVG 图标(makeGlyph)而非 ◀/▶ 文字——三角符(U+25C0/25B6)不在 YaHei作按钮文字会触发

View File

@ -23,6 +23,10 @@ public:
Column2DDataset* col2D() const { return col2D_; } Column2DDataset* col2D() const { return col2D_; }
CategoryAnalysisTab* analysisTab() const { return analysisTab_; } CategoryAnalysisTab* analysisTab() const { return analysisTab_; }
signals:
// 切换「三维分析 / 二维分析」tabis2D=true 进入二维分析。上层据此切相机+翻维度可见标志。
void analysisModeChanged(bool is2D);
public slots: public slots:
void toggleCollapsed(); void toggleCollapsed();
void expand(); // 强制展开(进入全屏时确保三栏可见) void expand(); // 强制展开(进入全屏时确保三栏可见)

View File

@ -46,7 +46,8 @@ void VtkSceneController::setCheckedDatasets(const QStringList& dsIds) {
// 取景意图按「场景是否已有数据到场过」判定,而非 checkedDs_ 是否空——否则连续快速勾选第二个 // 取景意图按「场景是否已有数据到场过」判定,而非 checkedDs_ 是否空——否则连续快速勾选第二个
// ds 时 checkedDs_ 已非空但首批尚未到场,会被误清取景意图,相机不对准数据 → 看似不渲染。 // ds 时 checkedDs_ 已非空但首批尚未到场,会被误清取景意图,相机不对准数据 → 看似不渲染。
fitOnArrival_ = !hadArrivedData_; 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_; // 不自增:并发增量互不作废 const unsigned long long gen = rebuildGeneration_; // 不自增:并发增量互不作废
for (const auto& id : checkedDs_) for (const auto& id : checkedDs_)
@ -63,14 +64,16 @@ void VtkSceneController::setChecked2DDatasets(const QStringList& dsIds) {
// 二维足迹始终画进 View3D 场景,且按 dsId 跟踪 → 一律增量 diff不全量重建不打断 3D 帘面/体)。 // 二维足迹始终画进 View3D 场景,且按 dsId 跟踪 → 一律增量 diff不全量重建不打断 3D 帘面/体)。
const std::set<std::string> oldSet(checked2dDs_.begin(), checked2dDs_.end()); const std::set<std::string> oldSet(checked2dDs_.begin(), checked2dDs_.end());
const std::set<std::string> newSet(newDs.begin(), newDs.end()); const std::set<std::string> newSet(newDs.begin(), newDs.end());
// 此前空场景(无 3D 数据且无 2D 足迹) → 首批足迹到场自动取景;否则增量追加保持相机不跳。
const bool wasEmpty = checkedDs_.empty() && checked2dDs_.empty();
for (const auto& id : checked2dDs_) for (const auto& id : checked2dDs_)
if (!newSet.count(id)) view_.removeDataset(id); // 取消勾选 → 移除该足迹图元 if (!newSet.count(id)) view_.removeDataset(id); // 取消勾选 → 移除该足迹图元
checked2dDs_ = std::move(newDs); 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 切回时补画)。 // 足迹画进 View3D 场景mode=0 关闭 → 仅记录勾选不渲染(见 set2DPlacement 切回时补画)。
if (placement2dMode_ != 0 && mode_ == ViewMode::View3D) { if (placement2dMode_ != 0 && mode_ == ViewMode::View3D) {
@ -210,6 +213,14 @@ void VtkSceneController::setViewMode(ViewMode mode) {
rebuildInternal(); 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) { void VtkSceneController::setLayer(SceneLayer layer, bool on) {
switch (layer) { switch (layer) {
case SceneLayer::Curtain: showCurtain_ = on; break; case SceneLayer::Curtain: showCurtain_ = on; break;

View File

@ -42,6 +42,9 @@ public slots:
// 二维足迹摆放高度mode0关闭 /1 Z=0 /2 顶部 /3 底部 /4 自定义customZ 仅 mode=4 用)。 // 二维足迹摆放高度mode0关闭 /1 Z=0 /2 顶部 /3 底部 /4 自定义customZ 仅 mode=4 用)。
void set2DPlacement(int mode, double customZ); void set2DPlacement(int mode, double customZ);
void setViewMode(ViewMode mode); void setViewMode(ViewMode mode);
// 切「三维分析/二维分析」tabA 期):按目标维度是否已有数据重置取景基线,使切换后该维度第一条
// 数据自动取景。显隐/相机/坐标轴由 VtkSceneView::setAnalysisMode2D 负责(上层在同一处一并调用)。
void onAnalysisModeChanged(bool is2D);
void setLayer(SceneLayer layer, bool on); void setLayer(SceneLayer layer, bool on);
void setVerticalExaggeration(double ve); void setVerticalExaggeration(double ve);
void rebuild(); // 主题切换等外部触发的重渲染 void rebuild(); // 主题切换等外部触发的重渲染

View File

@ -8,6 +8,8 @@ namespace {
// 三维斜视方位角 / 仰角。 // 三维斜视方位角 / 仰角。
constexpr double kAzimuth = 30.0; constexpr double kAzimuth = 30.0;
constexpr double kElevation = 25.0; constexpr double kElevation = 25.0;
// 二维分析近俯视:自正俯视下压的角度(12°→俯角约78°)。留一点倾斜使高程差可辨。
constexpr double kNearTopTilt = 12.0;
} // namespace } // namespace
void applyTop2D(vtkRenderer* r) void applyTop2D(vtkRenderer* r)
@ -37,6 +39,20 @@ void applyFree3D(vtkRenderer* r)
r->ResetCamera(); 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) void applyView(vtkRenderer* r, ViewDir dir)
{ {
if (!r) return; if (!r) return;

View File

@ -8,6 +8,10 @@ void applyTop2D(vtkRenderer* r);
// 自由三维:透视投影,斜视方位看到剖面立体。 // 自由三维:透视投影,斜视方位看到剖面立体。
void applyFree3D(vtkRenderer* r); void applyFree3D(vtkRenderer* r);
// 二维分析近俯视:透视投影,自正俯视下压一点(约12°→约78°俯角)。留一点倾斜使高程差可见
// (绝对正俯视下高程不可辨),仅平移+缩放(旋转由 interactor style 锁定)。
void applyNearTop2D(vtkRenderer* r);
// 快捷视图方向(世界系 x=East,y=North,z=-depth // 快捷视图方向(世界系 x=East,y=North,z=-depth
// Top 俯视 (相机在 +Z 向下看) // Top 俯视 (相机在 +Z 向下看)
// Bottom 仰视 (相机在 -Z 向上看) // Bottom 仰视 (相机在 -Z 向上看)

View File

@ -260,6 +260,14 @@ void InteractionManager::closeAll() {
safeRender(); 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() { void InteractionManager::flipView() {
if (!renderer_) return; if (!renderer_) return;
auto* cam = renderer_->GetActiveCamera(); auto* cam = renderer_->GetActiveCamera();

View File

@ -67,6 +67,10 @@ public:
void closeSelected(); void closeSelected();
// 关闭并释放所有切片(切到二维 / 清场 / 体素重建前调)。 // 关闭并释放所有切片(切到二维 / 清场 / 体素重建前调)。
void closeAll(); void closeAll();
// 切二维分析(is2D=true)/三维分析(false):翻所有切片显隐(不销毁,切回零重建) + 锁/解锁交互样式
// (二维=仅平移+缩放、禁旋转)。地形/底图/相机由 VtkSceneView::setAnalysisMode2D 处理,此处不渲染。
void setMode2D(bool is2D);
// 关闭并释放某体下的所有切片(该体移除/重建时;不动其它体的切片)。 // 关闭并释放某体下的所有切片(该体移除/重建时;不动其它体的切片)。
void closeSlicesOfVolume(const std::string& volumeDsId); void closeSlicesOfVolume(const std::string& volumeDsId);

View File

@ -2,6 +2,7 @@
#include <chrono> #include <chrono>
#include <vtkCallbackCommand.h>
#include <vtkCamera.h> #include <vtkCamera.h>
#include <vtkCellPicker.h> #include <vtkCellPicker.h>
#include <vtkMath.h> #include <vtkMath.h>
@ -47,6 +48,15 @@ bool PickInteractorStyle::pickWorld(Vec3& out) {
void PickInteractorStyle::OnLeftButtonDown() { void PickInteractorStyle::OnLeftButtonDown() {
auto* iren = this->GetInteractor(); 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; Vec3 world;
const bool hit = pickWorld(world); const bool hit = pickWorld(world);
@ -82,6 +92,7 @@ void PickInteractorStyle::OnLeftButtonDown() {
} }
void PickInteractorStyle::Rotate() { void PickInteractorStyle::Rotate() {
if (lock2D_) return; // 二维分析禁旋转(仅平移+缩放)
Vec3 c; Vec3 c;
if (!this->CurrentRenderer || !getRotateCenter || !getRotateCenter(c)) { if (!this->CurrentRenderer || !getRotateCenter || !getRotateCenter(c)) {
Superclass::Rotate(); // 无选中物 → 默认绕焦点旋转 Superclass::Rotate(); // 无选中物 → 默认绕焦点旋转

View File

@ -31,6 +31,10 @@ public:
// 在"按下开始拖动"时调用一次,把焦点设到该中心(位置同步补偿,画面不变)→ 之后绕它旋转、不跳。 // 在"按下开始拖动"时调用一次,把焦点设到该中心(位置同步补偿,画面不变)→ 之后绕它旋转、不跳。
std::function<bool(Vec3& center)> getRotateCenter; std::function<bool(Vec3& center)> getRotateCenter;
// 二维分析锁:开 → 左键拖动改为平移、禁旋转(仅平移+缩放);关 → 恢复三维拾取/旋转交互。
void setLock2D(bool on) { lock2D_ = on; }
bool isLock2D() const { return lock2D_; }
void OnLeftButtonDown() override; void OnLeftButtonDown() override;
void OnMouseWheelForward() override; void OnMouseWheelForward() override;
void OnMouseWheelBackward() override; void OnMouseWheelBackward() override;
@ -49,6 +53,9 @@ private:
// 记上次左键按下时刻+屏幕位置,两次按下间隔 < kDoubleClickMs 且位置相近视为双击。 // 记上次左键按下时刻+屏幕位置,两次按下间隔 < kDoubleClickMs 且位置相近视为双击。
double lastDownTime_ = -1.0; // 单调时钟(毫秒)-1=无 double lastDownTime_ = -1.0; // 单调时钟(毫秒)-1=无
int lastDownPos_[2] = {0, 0}; int lastDownPos_[2] = {0, 0};
// 二维分析模式:左键=平移、禁旋转(仅平移+缩放)。由 InteractionManager 在切 tab 时设。
bool lock2D_ = false;
}; };
} // namespace geopro::render::interact } // namespace geopro::render::interact

View File

@ -172,9 +172,16 @@ vtkImageData* SliceTool::reslicedOutput() const {
} }
void SliceTool::setInteractive(bool on) { void SliceTool::setInteractive(bool on) {
interactive_ = on; // 记录锁定态setVisible 重显时复原
if (widget_) widget_->SetInteraction(on ? 1 : 0); // 关=锁移动/旋转/光标,纹理仍显示 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<vtkImageData> SliceTool::coloredResliceImage() const { vtkSmartPointer<vtkImageData> SliceTool::coloredResliceImage() const {
if (!widget_) return nullptr; if (!widget_) return nullptr;
vtkImageMapToColors* cm = widget_->GetColorMap(); // widget 内部把 reslice 经 LUT 上色 → 纹理 vtkImageMapToColors* cm = widget_->GetColorMap(); // widget 内部把 reslice 经 LUT 上色 → 纹理

View File

@ -84,11 +84,16 @@ public:
// 拾取选中/右键菜单由 PickInteractorStyle 独立处理,不受此影响。 // 拾取选中/右键菜单由 PickInteractorStyle 独立处理,不受此影响。
void setInteractive(bool on); void setInteractive(bool on);
// 显/隐切片(切到二维分析时隐藏,切回再显)SetEnabled 翻显隐而非销毁,几何/位置保留、
// 切回零重建。重显时复原锁定态(SetEnabled 可能把交互重置为开)。
void setVisible(bool on);
// 关闭Off() 并解除 interactor 绑定(幂等)。 // 关闭Off() 并解除 interactor 绑定(幂等)。
void close(); void close();
private: private:
SliceAxis axis_; SliceAxis axis_;
bool interactive_ = true; // 当前是否允许交互(setInteractive 记录):重显(setVisible)时复原锁定态
std::string dsId_; // 已保存切片归属标签(空=临时交互切片) std::string dsId_; // 已保存切片归属标签(空=临时交互切片)
std::string volumeDsId_; // 所属三维体 dsId多体并发用 std::string volumeDsId_; // 所属三维体 dsId多体并发用
vtkImageData* image_; // 非拥有;生命周期由调用方(VtkSceneView 的 currentVolumeImage_)保证 vtkImageData* image_; // 非拥有;生命周期由调用方(VtkSceneView 的 currentVolumeImage_)保证

View File

@ -489,6 +489,40 @@ TEST(VtkSceneController, TwoDFootprintCoexistsWith3DCurtain) {
EXPECT_EQ(view.curtains, 1); 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 重加)。 // 自定义摆放(4) → worldZ=customZ改摆放重摆已勾选足迹移除旧 + 按新 Z 重加)。
TEST(VtkSceneController, TwoDPlacementCustomZAndReplace) { TEST(VtkSceneController, TwoDPlacementCustomZAndReplace) {
FakeDsRepo ds; FakeSceneRepo sc; FakeView view; FakeDsRepo ds; FakeSceneRepo sc; FakeView view;