geopro/docs/superpowers/plans/2026-06-30-vtk-merged-datas...

842 lines
47 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# VTK 合并数据集单栏重构 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 把「三维分析 + 二维分析」两 tab 合并为一个无标题单列数据集栏2D/3D 同列分段、按数据动态显隐、段操作改响应式图标工具条2D 数据以「按类型一块平面 + 平面底图」与 3D 体/帘面在同一自由透视场景共存。
**Architecture:** 复用方案 A —— 将 `CategoryAnalysisTab` 升级为统一单列 `DatasetColumn`,用扩展的段配置(增 `dimension`)同时驱动 3D/2D 段;新增响应式 `SectionIconBar`;删 `Column2DDataset`/`QTabWidget`2D 渲染改「按类型平面 z」并由 `TileBasemap` 多实例提供 N 个平面底图3D 底图控件移渲染区工具栏、导入雷达移顶部设备菜单。
**Tech Stack:** C++17、Qt6Widgets、VTK 9.6.2、GoogleTest/CTest、CMake`build.bat`)。
**Spec:** `docs/superpowers/specs/2026-06-30-vtk-merged-dataset-column-refactor-design.md`
## Global Constraints
- 构建:`build.bat app`(编 app/ `build.bat test`(编+跑测试)。**cmake 不在 PATH**,用 VS 自带;**用户须关闭运行中的 app 才能重链**LNK1104 是锁,提示用户关闭,不要自行重试绕过)。
- 提交:按具体文件 `git add`**绝不 `-A`****禁用署名**(全局规则,无 Co-Authored-By中文 conventional commits`feat`/`fix`/`refactor`/`docs`/`test`),无尾随空格。
- `.bat`/脚本必须纯 ASCII。
- 命名:类型 `PascalCase`、成员 `snake_case_` 尾下划线、常量 `kPascalCase`、命名空间 `geopro::app` 等(随现有文件)。
- CJK 路径坑:`std::ifstream` 不吃中文路径(本计划不涉文件读,注意即可)。
- 不可变/小文件/函数 <50 /文件 <800 行原则surgical changes仅改与需求直接相关处
---
## 文件结构(创建/修改一览)
**新建**
- `src/app/panels/columns/SectionIconBar.hpp/.cpp` 段头响应式图标工具条默认3超出/挤压收进「…」)。
- `src/controller/PlaneBasemapManager.hpp/.cpp` 2D 类型管理平面 z + 平面底图实例生命周期首勾建/全消销毁)。
- `tests/app/test_section_icon_bar.cpp` 图标溢出可见数纯逻辑测试
- `tests/controller/test_plane_basemap_manager.cpp` 平面 z 生命周期纯逻辑测试
**修改**
- `src/app/DatasetCategory.hpp`、`src/data/repo/CategoryConfig.hpp`、`src/app/DatasetCategory.cpp` 段配置加 `dimension` + trajectory + 分流
- `src/app/panels/columns/CategoryAnalysisTab.hpp/.cpp` 升级为 `DatasetColumn`动态显隐 + 空占位 + 段维度分发
- `src/app/panels/columns/CategorySection.hpp/.cpp` 段头接 `SectionIconBar`筛选折叠新增三维体图标化2D z值/底图图标移除导入雷达
- `src/app/panels/columns/ColumnDrawer.hpp/.cpp` tab + `Column2DDataset`单列承载
- `src/app/TopBar.hpp/.cpp` `buildDeviceMenu` 所在 TU)— 设备菜单加导入雷达
- `src/app/VtkViewToolbar.hpp/.cpp` Gear 下加地图按钮 + popup `setAnalysisMode2D` 6 向禁用
- `src/app/TileBasemap.hpp/.cpp` `groundZ`/`opacity` 参数化Street 纯平多实例可用
- `src/controller/VtkSceneController.hpp/.cpp` `PlaneBasemapManager`2D 落点改按类型平面 z」;移除 `view2DMode`/`setAnalysisMode2D` 耦合
- `src/app/VtkSceneView.hpp/.cpp` 移除 `setAnalysisMode2D` 显隐/相机耦合 ds Z`nudgeSelectedMapLinesZ`/`mapLineZOffset_`)。
- `src/app/main.cpp` 接线改造单列喂 2D 数据导入雷达改接 TopBar3D 底图改接工具栏 col2D/analysisMode )。
- `tests/app/test_dataset_category.cpp`、`tests/CMakeLists.txt` 测试扩展/登记
**删除(评估后)**
- `src/app/panels/columns/Column2DDataset.hpp/.cpp` 合并后退役Phase B 末确认无引用再删)。
---
## 阶段总览
- **Phase A**段配置加维度 + trajectory 段分流纯逻辑 TDD)。
- **Phase B**单列面板骨架动态显隐 + 空占位 + tab)。构建后 2D/3D 同列可见
- **Phase C**响应式图标工具条 + 筛选折叠 + 新增三维体图标化
- **Phase D**导入雷达移顶部设备菜单 + 3D 底图移渲染工具栏 `TileBasemap` 透明度参数化)。
- **Phase E**单一自由场景 + 2D 按类型平面 z纯逻辑 TDD + 接线)。
- **Phase F**2D 平面底图`TileBasemap` 多实例 + 管理器 + 段底图 popup)。
每个 Phase 结束都应 `build.bat test` 绿 + `build.bat app` 可运行
---
## Phase A段配置 + 维度分流
### Task A1: CategorySpec 加 dimension + trajectory 段 + splitByCategory 路由
**Files:**
- Modify: `src/data/repo/CategoryConfig.hpp`
- Modify: `src/app/DatasetCategory.cpp:5-20`
- Test: `tests/app/test_dataset_category.cpp`
**Interfaces:**
- Produces: `enum class CategoryDim { D3, D2 };``CategorySpec` 增成员 `CategoryDim dim;``categoryConfigs()` 末尾新增 id=`"trajectory"` `ddCode="dd_trajectory_data"`, `dim=D2``splitByCategory` `dd_trajectory_data` 命中 trajectory
- [ ] **Step 1: 写失败测试** `tests/app/test_dataset_category.cpp` 末尾追加
```cpp
TEST(DatasetCategory, RoutesTrajectoryRowToTrajectorySegment) {
using geopro::data::DsRow;
DsRow traj;
traj.id = "t1";
traj.ddCode = "dd_trajectory_data";
auto b = geopro::app::splitByCategory({traj});
const auto& cfg = geopro::app::categoryConfigs();
int idx = -1;
for (std::size_t i = 0; i < cfg.size(); ++i)
if (cfg[i].id == "trajectory") idx = static_cast<int>(i);
ASSERT_GE(idx, 0) << "categoryConfigs must contain a 'trajectory' segment";
EXPECT_EQ(cfg[idx].dim, geopro::app::CategoryDim::D2);
ASSERT_EQ(b.segments[idx].size(), 1u);
EXPECT_EQ(b.segments[idx][0].id, "t1");
}
TEST(DatasetCategory, InversionSegmentsAreD3) {
const auto& cfg = geopro::app::categoryConfigs();
for (const auto& c : cfg)
if (c.id == "resistivity" || c.id == "voxel")
EXPECT_EQ(c.dim, geopro::app::CategoryDim::D3);
}
```
- [ ] **Step 2: 跑测试确认失败** `build.bat test`预期 `test_dataset_category` 编译失败`CategoryDim` 未定义 / `dim` 成员不存在)。
- [ ] **Step 3: 改 `CategoryConfig.hpp`** `namespace geopro::app {` `struct CategorySpec` 上方加枚举并给 struct 加成员 4 个现有段补 `dim`追加 trajectory
```cpp
enum class CategoryDim { D3, D2 }; // 段维度:三维(体/帘面) / 二维(轨迹/平面)
struct CategorySpec {
std::string id;
std::string title;
std::string dsTypeCode;
std::string ddCode;
bool canGenerateVolume;
bool hasArrayTypeFilter;
CategoryDim dim; // 维度(图标集/渲染路由用)
};
inline const std::vector<CategorySpec>& categoryConfigs() {
static const std::vector<CategorySpec> kCfg = {
{"resistivity", "电阻率数据", "ERT platform inversion data", "", true, true, CategoryDim::D3},
{"apparent", "视电阻率数据", "visual resistivity data", "", true, true, CategoryDim::D3},
{"transient", "瞬变电磁数据", "DD TRANSIENT ELECTROMAGNETIC INVERSION", "", true, false, CategoryDim::D3},
{"voxel", "三维体", "", "dd_voxel", false, false, CategoryDim::D3},
{"trajectory", "轨迹数据", "", "dd_trajectory_data", false, false, CategoryDim::D2},
};
return kCfg;
}
```
- [ ] **Step 4: `splitByCategory` 已按 ddCode 匹配**`DatasetCategory.cpp:12-13` 现逻辑已覆盖 `dd_trajectory_data` trajectory 段有 ddCode)。**无需改 `DatasetCategory.cpp` 函数体**仅确认编译 `DsRow` `id` 字段以外用到的成员按实际字段名调整测试
- [ ] **Step 5: 跑测试确认通过** `build.bat test`预期 `DatasetCategory.*` PASS
- [ ] **Step 6: 提交**
```bash
git add src/data/repo/CategoryConfig.hpp tests/app/test_dataset_category.cpp
git commit -m "feat(vtk): 段配置加维度 + trajectory 2D 段并入 splitByCategory"
```
---
## Phase B单列面板骨架动态显隐 + 空占位 + 去 tab
### Task B1: DatasetColumn 动态显隐空段 + 空面板占位
**Files:**
- Modify: `src/app/panels/columns/CategoryAnalysisTab.hpp`
- Modify: `src/app/panels/columns/CategoryAnalysisTab.cpp:88-97``setBuckets`)、构造函数加占位
**Interfaces:**
- Consumes: `categoryConfigs()` trajectory )。
- Produces: 段在其 bucket 为空时 `hide()`非空 `show()`全空时显示占位 `QLabel`。`setBuckets` 现在也分发 trajectory 2D)。
> 说明:保持类名 `CategoryAnalysisTab` 不变避免大范围改名风险仅扩展职责为「统一单列」。Phase B 末在文档注释标注其新职责。
- [ ] **Step 1: 构造函数加占位 label** `CategoryAnalysisTab.cpp` 构造函数 `scroll->setWidget(content);` 之前 `content` 布局内`col` 之外、`outer` 添加居中占位改为在 `outer` 上叠一个占位 label并存指针
`CategoryAnalysisTab.hpp` private 区加
```cpp
QWidget* placeholder_ = nullptr; // 全空时显示的占位提示
void updatePlaceholderAndVisibility(); // 据各段数据有无 显隐段 + 占位
```
`CategoryAnalysisTab.cpp` 构造函数末尾`scroll->setWidget(content);` 之后):
```cpp
placeholder_ = new QLabel(QStringLiteral("请在左侧对象树勾选测线 / 数据集"), this);
placeholder_->setAlignment(Qt::AlignCenter);
placeholder_->setWordWrap(true);
applyTokenizedStyleSheet(placeholder_,
QStringLiteral("QLabel{color:{{text/tertiary}};padding:24px;}"));
outer->addWidget(placeholder_, 0); // 与 scroll 同级;由 updatePlaceholderAndVisibility 切显隐
placeholder_->hide();
```
(顶部 include 补 `#include <QLabel>`。)
- [ ] **Step 2: 实现 `updatePlaceholderAndVisibility`** — 段有无数据按其 list 行数判定(容器节点不算)。在 `CategoryAnalysisTab.cpp` 加:
```cpp
void CategoryAnalysisTab::updatePlaceholderAndVisibility() {
bool anyVisible = false;
for (auto* sec : ordered_) {
const bool has = sec->hasRenderableRows(); // Task B1-Step3 在 CategorySection 新增
sec->setVisible(has);
if (has) anyVisible = true;
}
if (scroll_) scroll_->setVisible(anyVisible);
if (placeholder_) placeholder_->setVisible(!anyVisible);
relayoutSections();
}
```
- [ ] **Step 3: CategorySection 加 `hasRenderableRows()`**`CategorySection.hpp` public 加 `bool hasRenderableRows() const;``.cpp` 实现(数据行 = 非 container 的可勾选行存在):
```cpp
bool CategorySection::hasRenderableRows() const {
if (!list_) return false;
for (QTreeWidgetItemIterator it(list_); *it; ++it)
if ((*it)->data(0, kDsDdCodeRole).toString() != QStringLiteral("container"))
return true;
return false;
}
```
- [ ] **Step 4: `setBuckets` 分发 trajectory + 触发显隐** — 改 `CategoryAnalysisTab.cpp:88-97`:去掉对 voxel 的 `continue` 之外保持,循环末尾调用显隐刷新。注意 trajectory 段D2数据来自 splitByCategory 第 5 桶自然分发voxel 仍跳过mock 注入):
```cpp
void CategoryAnalysisTab::setBuckets(const CategoryBuckets& b) {
const auto& cfg = categoryConfigs();
for (std::size_t i = 0; i < cfg.size() && i < b.segments.size(); ++i) {
if (cfg[i].id == "voxel") continue; // voxel 由外部单独 setDatasets 注入,勿覆盖
if (auto* sec = section(cfg[i].id)) sec->setDatasets(b.segments[i]);
}
updatePlaceholderAndVisibility();
}
```
并在 `section("voxel")->setDatasets(...)` 的外部调用点之后也需刷新 —— 故在 `CategorySection::setDatasets` 触发后由信号驱动Step 5。
- [ ] **Step 5: 数据变化驱动显隐** — 段内容重建后段的「有无行」可能变(如 voxel 注入、筛选)。在 `CategoryAnalysisTab` 构造的每段 connect 区,追加:
```cpp
connect(sec, &CategorySection::checkedDatasetsChanged, this,
[this](const QStringList&) { updatePlaceholderAndVisibility(); });
```
(与已有 checkedDatasetsChanged lambda 并存;或在那个 lambda 末尾调用 `updatePlaceholderAndVisibility()`。)另外在 `CategorySection::setDatasets` 末尾发一个轻量信号或直接由 tab 在调用 `section("voxel")->setDatasets()` 后手动 `updatePlaceholderAndVisibility()`。最简:在 tab 暴露 `void refreshVisibility(){ updatePlaceholderAndVisibility(); }` 供 main 在注入 voxel 数据后调用。
- [ ] **Step 6: 构建 + 手动验收**`build.bat app`。验收:无数据时面板显示占位语;勾选含轨迹/反演的测线后,对应段出现、占位消失;不含某类型时该段不显示。
- [ ] **Step 7: 提交**
```bash
git add src/app/panels/columns/CategoryAnalysisTab.hpp src/app/panels/columns/CategoryAnalysisTab.cpp src/app/panels/columns/CategorySection.hpp src/app/panels/columns/CategorySection.cpp
git commit -m "feat(vtk): 数据集单栏 段按数据动态显隐 + 全空占位提示"
```
### Task B2: ColumnDrawer 去 tab + 单列承载 + 接 2D 数据
**Files:**
- Modify: `src/app/panels/columns/ColumnDrawer.hpp/.cpp`
- Modify: `src/app/main.cpp`drawer 接线:`670`、`1388-1415`、`1710-1713`、`544-566`、`analysisModeChanged` 链)
**Interfaces:**
- Consumes: `CategoryAnalysisTab*`(统一列)。
- Produces: `ColumnDrawer` 不再有 `col2D()` / `analysisModeChanged`;只暴露 `analysisTab()`。main 把 2D 数据并入 `splitByCategory` 流。
- [ ] **Step 1: ColumnDrawer 去 QTabWidget/Column2DDataset**`ColumnDrawer.hpp`:删 `Column2DDataset* col2D_`、`col2D()`、`analysisModeChanged``body_` 改为直接持 `CategoryAnalysisTab*`(不再是 QTabWidget。`.cpp` 构造:把 `analysisTab_` 直接作为 body 内容(去掉 tab 添加 col2D 的代码、去掉 tab 切换发 `analysisModeChanged` 的 connect折叠开关逻辑不变折叠隐藏 body。`resizeEvent` 里两 tab 平分逻辑删除(只剩单列)。
- [ ] **Step 2: main.cpp 删 col2D 接线** — 删除 `main.cpp:670``drawer->col2D()->setDatasets(...)`;删除 `1388-1415``basemapChanged`/`view2DModeChanged`/`customZChanged`/`checkedDatasetsChanged`(col2D) 接线(其去向在 Phase D/E/F 重接);删除 `544-566``selectedDatasetsChanged`/`setSelectedMapLines` 经 col2D 的链2D 选中改由统一段 `datasetSelected` 承担,已存在);删除 `analysisModeChanged``setAnalysisMode2D` 的接线。
- [ ] **Step 3: main.cpp 2D 数据并入单列** — 找到喂 buckets 的位置(`setBuckets(splitByCategory(...))` 调用点),确认 `splitByCategory(*lastSourceRows)` 现已含 trajectory 段Task A1故 2D 轨迹随 `setBuckets` 自动进 trajectory 段,无需单独 col2D 注入。删 `splitByDimension(...).dim2D` 用法(保留 `dimOf` 给渲染路由)。
- [ ] **Step 4: 2D 勾选 → 渲染接线** — 统一段的 `checkedDatasetsChanged` 现合并 2D+3D 勾选并集上抛。需在控制器侧按 ddCode 分派3D→`setChecked3D`、2D→`setChecked2DDatasets`。**本步仅接线让 2D 段勾选仍走 `setChecked2DDatasets`**:在 main 接收 `analysisTab` 的勾选并集后,用 `dimOf(ddCode)` 拆分为 2D/3D 两组分别下发(拆分依据 ddCode需 main 持有 dsId→ddCode 映射,复用现有 `lastSourceRows`)。
```cpp
// main.cppanalysisTab.checkedDatasetsChanged 接收后拆分下发
QObject::connect(analysisTab, &geopro::app::CategoryAnalysisTab::checkedDatasetsChanged, &window,
[sceneCtrl, lastSourceRows](const QStringList& ids) {
QStringList ids3d, ids2d;
for (const QString& id : ids) {
const std::string ddc = ddCodeOf(*lastSourceRows, id.toStdString()); // 小工具:按 id 查 ddCode
(geopro::app::dimOf(ddc) == geopro::app::Dim::D2 ? ids2d : ids3d) << id;
}
sceneCtrl->setChecked3DDatasets(ids3d); // 现 setChecked 改名/封装为 3D 专用
sceneCtrl->setChecked2DDatasets(ids2d);
});
```
`dimOf` 现为 `DatasetDimension.cpp` 匿名命名空间内部函数 → 提升为头文件可见的 `geopro::app::dimOf(const std::string&)``ddCodeOf` 为 main 内 lambda遍历 `*lastSourceRows``r.id==id` 返回 `r.ddCode`。`setChecked3DDatasets` = 现 `VtkSceneController::setChecked` 重命名,仅语义澄清。)
- [ ] **Step 5: 构建 + 手动验收**`build.bat app`。验收:左侧只剩一个单列(无 tab勾 3D 反演/体 → 渲染体/帘面;勾轨迹 → 渲染足迹折线(落点暂沿用旧 placementPhase E 改平面);无崩溃。
- [ ] **Step 6: 提交**
```bash
git add src/app/panels/columns/ColumnDrawer.hpp src/app/panels/columns/ColumnDrawer.cpp src/app/main.cpp src/app/DatasetDimension.hpp src/app/DatasetDimension.cpp src/controller/VtkSceneController.hpp src/controller/VtkSceneController.cpp
git commit -m "refactor(vtk): 抽屉去 tab 改单列; 2D/3D 勾选按维度拆分下发"
```
### Task B3: 退役 Column2DDataset
**Files:**
- Delete: `src/app/panels/columns/Column2DDataset.hpp/.cpp`
- Modify: `CMakeLists.txt`(移除其源文件登记)、其余 include 引用
- [ ] **Step 1: 确认无引用**`grep -rn "Column2DDataset" src` 应只剩将删的文件自身。若 main/其他仍引用,先清掉。
- [ ] **Step 2: 删文件 + CMake 登记** — 删两文件;在 `CMakeLists.txt` 移除 `Column2DDataset.cpp`
- [ ] **Step 3: 构建**`build.bat app` 通过。
- [ ] **Step 4: 提交**
```bash
git add -u
git commit -m "refactor(vtk): 移除退役的 Column2DDataset(并入统一单列)"
```
---
## Phase C响应式图标工具条 + 筛选折叠 + 新增三维体图标化
### Task C1: SectionIconBar 溢出计算(纯逻辑 TDD
**Files:**
- Create: `src/app/panels/columns/SectionIconBar.hpp/.cpp`
- Create: `tests/app/test_section_icon_bar.cpp`
- Modify: `tests/CMakeLists.txt`、`CMakeLists.txt`
**Interfaces:**
- Produces: 自由函数 `int visibleIconCount(int totalIcons, int availablePx, int iconPx, int overflowPx, int maxIcons);` —— 返回在「最多 `maxIcons`、每图标 `iconPx`、溢出按钮 `overflowPx`、可用宽 `availablePx`」下能显示的图标数(其余收进「…」)。规则:先取 `min(totalIcons, maxIcons)` 个的理想数;若 `理想数 * iconPx > availablePx`,逐个减少直到 `n*iconPx + (有溢出时 overflowPx) <= availablePx`,至少 0当有图标被折叠时需为「…」预留 `overflowPx`
- [ ] **Step 1: 写失败测试**`tests/app/test_section_icon_bar.cpp`
```cpp
#include <gtest/gtest.h>
#include "panels/columns/SectionIconBar.hpp"
using geopro::app::visibleIconCount;
TEST(SectionIconBar, ShowsAllWhenWideEnoughAndUnderMax) {
// 3 图标, 宽 1000, 每个 30, 溢出 30, max 3 → 全显
EXPECT_EQ(visibleIconCount(3, 1000, 30, 30, 3), 3);
}
TEST(SectionIconBar, CapsAtMaxIcons) {
// 5 图标但 max 3, 宽足够 → 显 3, 其余 2 进溢出(此时需留溢出位)
EXPECT_EQ(visibleIconCount(5, 1000, 30, 30, 3), 3);
}
TEST(SectionIconBar, FoldsRightWhenNarrow) {
// 3 图标, max 3, 但宽只够 2 个 + 溢出: 75px, 30 each, overflow 30 → 2*30+30=90>75 → 1*30+30=60<=75 → 1
EXPECT_EQ(visibleIconCount(3, 75, 30, 30, 3), 1);
}
TEST(SectionIconBar, NoOverflowReserveWhenAllFit) {
// 2 图标全显且 <=max, 不需溢出位: 宽 60 恰好 2*30
EXPECT_EQ(visibleIconCount(2, 60, 30, 30, 3), 2);
}
TEST(SectionIconBar, ZeroWhenTooNarrow) {
EXPECT_EQ(visibleIconCount(3, 20, 30, 30, 3), 0);
}
```
- [ ] **Step 2: 跑测试确认失败**`build.bat test`;预期编译失败(头文件/函数不存在)。
- [ ] **Step 3: 实现 `SectionIconBar.hpp`(函数 + 组件声明)**
```cpp
#pragma once
#include <QWidget>
#include <QString>
#include <functional>
#include <vector>
class QToolButton;
class QResizeEvent;
namespace geopro::app {
// 纯逻辑:给定约束返回可见图标数(其余收进「…」)。见 spec §6。
int visibleIconCount(int totalIcons, int availablePx, int iconPx, int overflowPx, int maxIcons);
// 段头响应式图标工具条≤maxIcons 且宽度够则全显;否则右侧依次收进末尾「…」下拉。
struct IconAction {
QString glyphKey; // 图标键(映射到 Glyph
QString tooltip;
std::function<void()> onClick; // 直接动作;为空则用 popupBuilder
std::function<void(QToolButton*)> popupBuilder; // 弹 popupz值/底图/筛选用)
};
class SectionIconBar : public QWidget {
Q_OBJECT
public:
explicit SectionIconBar(QWidget* parent = nullptr);
void setActions(const std::vector<IconAction>& actions); // 重建按钮
void setMaxIcons(int n) { maxIcons_ = n; relayout(); }
protected:
void resizeEvent(QResizeEvent* e) override;
private:
void relayout(); // 按当前宽度算可见数,多余进「…」菜单
std::vector<IconAction> actions_;
std::vector<QToolButton*> btns_;
QToolButton* overflowBtn_ = nullptr;
int maxIcons_ = 3;
int iconPx_ = 30;
int overflowPx_ = 30;
};
} // namespace geopro::app
```
- [ ] **Step 4: 实现 `SectionIconBar.cpp` 的 `visibleIconCount`(先只够过测试)**
```cpp
#include "panels/columns/SectionIconBar.hpp"
#include <algorithm>
namespace geopro::app {
int visibleIconCount(int totalIcons, int availablePx, int iconPx, int overflowPx, int maxIcons) {
if (totalIcons <= 0 || iconPx <= 0) return 0;
const int ideal = std::min(totalIcons, std::max(0, maxIcons));
const bool overflowFromCap = totalIcons > ideal; // 超 max → 必有溢出位
// 先看理想数能否放下(若已因 cap 溢出,理想数也要含溢出位)
auto fits = [&](int n, bool withOverflow) {
return n * iconPx + (withOverflow ? overflowPx : 0) <= availablePx;
};
int n = ideal;
bool overflow = overflowFromCap;
while (n > 0 && !fits(n, overflow || (totalIcons > n))) {
--n;
overflow = true; // 一旦减少必有「…」
}
if (n < 0) n = 0;
return n;
}
} // namespace geopro::app
```
- [ ] **Step 5: 跑测试确认通过**`build.bat test`;预期 `SectionIconBar.*` PASS登记测试见 Step 6
- [ ] **Step 6: 登记到 CMake**`CMakeLists.txt``SectionIconBar.cpp` 到 app 源;`tests/CMakeLists.txt` 加 `test_section_icon_bar.cpp`(参照 `test_dataset_category.cpp` 登记法)。
- [ ] **Step 7: 实现组件 `relayout`/`setActions`/`resizeEvent`**UI构建验证
```cpp
SectionIconBar::SectionIconBar(QWidget* parent) : QWidget(parent) {
auto* lay = new QHBoxLayout(this);
lay->setContentsMargins(0, 0, 0, 0);
lay->setSpacing(0);
}
void SectionIconBar::setActions(const std::vector<IconAction>& a) {
actions_ = a;
// 清旧按钮、按 actions_ 建 QToolButtonautoRaise + glyph + tooltip + 连点击/popup
// 末尾建 overflowBtn_(InstantPopup, 文本「…」)relayout()。
relayout();
}
void SectionIconBar::resizeEvent(QResizeEvent* e) { QWidget::resizeEvent(e); relayout(); }
void SectionIconBar::relayout() {
const int vis = visibleIconCount(static_cast<int>(actions_.size()), width(), iconPx_, overflowPx_, maxIcons_);
// 前 vis 个按钮 show其余 hide 并塞进 overflowBtn_ 的菜单vis==actions_.size() 时 overflowBtn_ 隐藏。
}
```
(具体按钮构造参照 `VtkViewToolbar``iconBtn``Glyphs.hpp::makeGlyph`popup 用 `QMenu`/`QWidgetAction`。)
- [ ] **Step 8: 提交**
```bash
git add src/app/panels/columns/SectionIconBar.hpp src/app/panels/columns/SectionIconBar.cpp tests/app/test_section_icon_bar.cpp tests/CMakeLists.txt CMakeLists.txt
git commit -m "feat(vtk): 段头响应式图标工具条 SectionIconBar(溢出折叠+单元测试)"
```
### Task C2: CategorySection 段头接入图标条 + 筛选折叠 + 新增三维体图标化
**Files:**
- Modify: `src/app/panels/columns/CategorySection.cpp:37-132`(段头 + 筛选行)、`CategorySection.hpp`
**Interfaces:**
- Consumes: `SectionIconBar`、`spec_.dim`、`spec_.canGenerateVolume`。
- Produces: 段头右侧为 `SectionIconBar`;筛选行默认折叠,由「筛选」图标 toggle「新增三维体」改图标保留 `generateVolumeRequested`)。
- [ ] **Step 1: 段头去文字按钮、加 SectionIconBar** — 替换 `CategorySection.cpp:66-111`(新增三维体 + 导入雷达文字按钮)为:构建 `SectionIconBar* iconBar_`,按 `spec_` 组装 actions
- 通用:`{筛选}` → toggle `filterRow_` 可见。
- `spec_.canGenerateVolume`:前插 `{新增三维体}``emit generateVolumeRequested(dsTypeCode, checkedDsIds())`
- `spec_.dim==D2``{z值, 筛选, 底图}`z值/底图 popup 在 Phase E/F 接,先占位发信号 `zSliderRequested()`/`basemapPopupRequested()`)。
- **不再有导入雷达**Phase D 移走)。
添加到 `hl->addWidget(iconBar_)`
- [ ] **Step 2: 筛选行默认折叠**`CategorySection.cpp:119-132``filterRow` 包进一个 `QWidget* filterRow_`,默认 `hide()`;「筛选」图标点击 toggle 其可见。`hasArrayTypeFilter` 仍只对 ERT 段建 arrayCombo。
- [ ] **Step 3: hpp 增成员/信号**`CategorySection.hpp``SectionIconBar* iconBar_=nullptr; QWidget* filterRow_=nullptr;`;新增信号 `void zSliderRequested(); void basemapPopupRequested();`Phase E/F 用)。移除 `radarImportRequested`Phase D 删其发射)。
- [ ] **Step 4: 构建 + 手动验收**`build.bat app`。验收:反演段头显示 [新增三维体, 筛选] 图标;轨迹段显示 [z值, 筛选, 底图];点筛选展开/收起筛选行;缩窄左栏宽度 → 右侧图标折进「…」、放宽弹回。
- [ ] **Step 5: 提交**
```bash
git add src/app/panels/columns/CategorySection.hpp src/app/panels/columns/CategorySection.cpp
git commit -m "feat(vtk): 段头改图标工具条 + 筛选默认折叠 + 新增三维体图标化"
```
---
## Phase D导入雷达移顶部菜单 + 3D 底图移渲染工具栏
### Task D1: 导入雷达移到 TopBar 设备菜单
**Files:**
- Modify: `src/app/TopBar.cpp``buildDeviceMenu`)、`src/app/TopBar.hpp`(新信号)
- Modify: `src/app/main.cpp:1056-1059`(接线改源)
- Modify: `CategorySection.hpp/.cpp`、`CategoryAnalysisTab.hpp/.cpp`(移除 radarImportRequested 转发,若 Task C2 未删尽)
**Interfaces:**
- Produces: `TopBar` 新信号 `void radarImportRequested(bool impulse);`;设备菜单含「导入雷达测线 → 规范化/Impulse」。
- [ ] **Step 1: TopBar 设备菜单加项** — 在 `buildDeviceMenu(this)`(定位其定义 TU内追加子菜单
```cpp
QMenu* radar = menu->addMenu(QStringLiteral("导入雷达测线"));
radar->addAction(QStringLiteral("规范化测线目录(.head/.data)…"), this,
[this] { emit radarImportRequested(false); });
radar->addAction(QStringLiteral("Impulse 测线目录(.iprb)…"), this,
[this] { emit radarImportRequested(true); });
```
`TopBar.hpp` signals 区加 `void radarImportRequested(bool impulse);`。若 `buildDeviceMenu` 是自由函数无法 emit 成员信号,则改为 TopBar 成员方法 `buildDeviceMenu()`,参照已有 `buildViewMenu()` 成员法。)
- [ ] **Step 2: main 接线改源**`main.cpp:1056-1059``analysisTab.radarImportRequested` 改为 `topBar->radarImportRequested`(导入流程目标不变)。
- [ ] **Step 3: 删 CategorySection/Tab 的 radarImportRequested** — 移除 `CategorySection.cpp:88-111` 残留Task C2 应已删按钮)、`CategorySection.hpp:54` 信号、`CategoryAnalysisTab.cpp:50-51` 与 `.hpp:42` 的转发。
- [ ] **Step 4: 构建 + 手动验收**`build.bat app`。验收:顶部「设备」菜单出现「导入雷达测线」二级项,点击走原导入流程;三维体段头无导入雷达按钮。
- [ ] **Step 5: 提交**
```bash
git add src/app/TopBar.hpp src/app/TopBar.cpp src/app/main.cpp src/app/panels/columns/CategorySection.hpp src/app/panels/columns/CategorySection.cpp src/app/panels/columns/CategoryAnalysisTab.hpp src/app/panels/columns/CategoryAnalysisTab.cpp
git commit -m "feat(vtk): 导入雷达入口移到顶部设备菜单(临时测试功能集中化)"
```
### Task D2: TileBasemap 透明度参数化 + 隐藏 API
**Files:**
- Modify: `src/app/TileBasemap.hpp`、`src/app/TileBasemap.cpp:59``kTerrainOpacity`)、`buildFlat`/`buildWarped` opacity 行(~455、~569
**Interfaces:**
- Produces: `TileBasemap` 新增 `void setOpacity(double o);`(默认 0.5);现有 `show(Kind)`/`hide()` 不变。`buildFlat`/`buildWarped` 用成员 `opacity_` 而非常量。
- [ ] **Step 1: 加成员 + setter**`TileBasemap.hpp` private 加 `double opacity_ = 0.5;`public 加 `void setOpacity(double o);`。`.cpp` 实现:
```cpp
void TileBasemap::setOpacity(double o) {
o = std::clamp(o, 0.0, 1.0);
if (o == opacity_) return;
opacity_ = o;
if (kind_ != Hidden) show(kind_); // 重建套用新透明度
}
```
- [ ] **Step 2: 渲染用 opacity_**`buildFlat`~455`buildWarped`~569`SetOpacity(kTerrainOpacity)``SetOpacity(opacity_)`;删除或保留 `kTerrainOpacity` 常量(改为默认初值来源,建议删常量、成员默认 0.5)。
- [ ] **Step 3: 构建**`build.bat app` 通过行为暂不变opacity 默认 0.5 略比旧 0.55 透)。
- [ ] **Step 4: 提交**
```bash
git add src/app/TileBasemap.hpp src/app/TileBasemap.cpp
git commit -m "refactor(vtk): TileBasemap 透明度参数化(默认0.5)备多实例与可调"
```
### Task D3: VtkViewToolbar 加「地图」按钮 + popup接 3D 底图
**Files:**
- Modify: `src/app/VtkViewToolbar.hpp/.cpp:55-58`Gear 之后)
- Modify: `src/app/main.cpp:1388-1394`basemap 接线改到工具栏)
**Interfaces:**
- Produces: `VtkViewToolbar` 新信号 `void basemapKindChanged(int kind); void basemapOpacityChanged(double o);`kind: 0 天地图 / 1 无)。
- [ ] **Step 1: Gear 下加地图按钮 + popup**`VtkViewToolbar.cpp``connect(iconBtn(Glyph::Gear,...))` 之后、`sep();` 之前,加:
```cpp
{
auto* mapBtn = iconBtn(Glyph::Map, QStringLiteral("底图")); // 若无 Glyph::Map 用近义图标
mapBtn->setPopupMode(QToolButton::InstantPopup);
auto* menu = new QMenu(mapBtn);
// 底图类型
menu->addAction(QStringLiteral("天地图"), this, [this]{ emit basemapKindChanged(0); });
menu->addAction(QStringLiteral("无"), this, [this]{ emit basemapKindChanged(1); });
menu->addSeparator();
// 透明度滑块(QWidgetAction 承载 QSlider 0..100 默认 50)
auto* wa = new QWidgetAction(menu);
auto* sld = new QSlider(Qt::Horizontal, menu);
sld->setRange(0, 100); sld->setValue(50);
connect(sld, &QSlider::valueChanged, this, [this](int v){ emit basemapOpacityChanged(v/100.0); });
wa->setDefaultWidget(sld);
menu->addAction(wa);
mapBtn->setMenu(menu);
}
```
`VtkViewToolbar.hpp` signals 区加两信号include `<QMenu> <QWidgetAction> <QSlider>`。`Glyph::Map` 不存在则在 `Glyphs.hpp` 选已有近义键。)
- [ ] **Step 2: main 接线**`main.cpp:1388-1394``col2D basemapChanged` 接线Task B2 已删)在此重建为工具栏信号:
```cpp
QObject::connect(toolbar, &geopro::app::VtkViewToolbar::basemapKindChanged, basemap,
[basemap, basemapKind](int kind) {
*basemapKind = (kind == 0) ? geopro::app::TileBasemap::Satellite
: geopro::app::TileBasemap::Hidden;
basemap->show(*basemapKind);
});
QObject::connect(toolbar, &geopro::app::VtkViewToolbar::basemapOpacityChanged, basemap,
[basemap](double o) { basemap->setOpacity(o); });
```
`toolbar` = 现有 `VtkViewToolbar` 实例指针,按 main 里实际变量名接。)
- [ ] **Step 3: 去 setAnalysisMode2D 6 向禁用**`VtkViewToolbar::setAnalysisMode2D` 不再被调用Phase B 已断 analysisModeChanged可保留空实现或删除声明+定义+`viewDirButtons_` 禁用逻辑(建议删,单一自由场景 6 向恒可用)。
- [ ] **Step 4: 构建 + 手动验收**`build.bat app`。验收:渲染区工具栏 Gear 下方有「底图」按钮;点开可切天地图/无、拖透明度实时变化3D 底图为全局唯一。
- [ ] **Step 5: 提交**
```bash
git add src/app/VtkViewToolbar.hpp src/app/VtkViewToolbar.cpp src/app/main.cpp
git commit -m "feat(vtk): 3D 底图控件移渲染区工具栏(天地图/无+透明度滑块)"
```
---
## Phase E单一自由场景 + 2D 按类型平面 z
### Task E1: 移除维度相机/显隐耦合 + 废弃逐 ds 拖 Z
**Files:**
- Modify: `src/app/VtkSceneView.hpp/.cpp:420-439``setAnalysisMode2D`)、`482-539``pickMapLineAt`/`nudgeSelectedMapLinesZ`
- Modify: `src/controller/VtkSceneController.*``onAnalysisModeChanged`、`view2DMode`/`set2DPlacement` mode 维度)
- Modify: `src/app/main.cpp`(拖 Z 浮层 `498-505`、`544-557`
**Interfaces:**
- Produces: 场景恒自由透视;删除 `setAnalysisMode2D`、`nudgeSelectedMapLinesZ`、`mapLineZOffset_`、拖 Z 浮层。`set2DPlacement(mode,z)` 简化为后续平面 z 接口Task E2 替换)。
- [ ] **Step 1: 删 VtkSceneView 维度显隐/相机** — 移除 `setAnalysisMode2D`(420-439) 的按维度翻可见 + 相机锁定;移除 `pickMapLineAt`/`nudgeSelectedMapLinesZ`/`mapLineZOffset_`482-539 及成员)。`mapLineDs_` 是否保留视 Task E2 需要(仍需识别足迹 actor 归属 → 保留)。
- [ ] **Step 2: 删 controller analysisMode/view2DMode 耦合** — 移除 `onAnalysisModeChanged`、`placement2dMode_` 的 0/2/3 模式分支(顶/底/关闭);`set2DPlacement` 暂留接口待 E2 改造。
- [ ] **Step 3: 删 main 拖 Z 浮层接线** — 移除 `main.cpp:498-505`(高程读数浮层)、`544-557`pick/nudge 接线)。
- [ ] **Step 4: 构建 + 手动验收**`build.bat app`。验收:无 tab 切换后场景恒自由透视2D/3D 同时可见可旋转;无拖 Z 浮层;无崩溃/悬挂引用。
- [ ] **Step 5: 提交**
```bash
git add src/app/VtkSceneView.hpp src/app/VtkSceneView.cpp src/controller/VtkSceneController.hpp src/controller/VtkSceneController.cpp src/app/main.cpp
git commit -m "refactor(vtk): 删维度相机/显隐耦合与逐ds拖Z(单一自由场景)"
```
### Task E2: 2D 按类型平面 z 生命周期(纯逻辑 TDD+ 控制器接入
**Files:**
- Create: `src/controller/PlaneBasemapManager.hpp/.cpp`(先只含平面 z 生命周期逻辑;底图实例 Phase F 加)
- Create: `tests/controller/test_plane_basemap_manager.cpp`
- Modify: `tests/CMakeLists.txt`、`CMakeLists.txt`
- Modify: `src/controller/VtkSceneController.cpp`2D 落点改用管理器平面 z
**Interfaces:**
- Produces: `class PlaneZRegistry`(纯逻辑核):
- `double onChecked(const std::string& typeId, const std::string& dsId, double dsZ);` —— 某类型某 ds 勾选;首勾记录平面 z=dsZ返回该类型当前平面 z。
- `void onUnchecked(const std::string& typeId, const std::string& dsId);` —— 取消;该类型空集时清除(平面消失)。
- `bool hasPlane(const std::string& typeId) const;`
- `double planeZ(const std::string& typeId) const;`
- `void setPlaneZ(const std::string& typeId, double z);` —— 滑块整体调。
- [ ] **Step 1: 写失败测试**`tests/controller/test_plane_basemap_manager.cpp`
```cpp
#include <gtest/gtest.h>
#include "controller/PlaneBasemapManager.hpp"
using geopro::controller::PlaneZRegistry;
TEST(PlaneZRegistry, FirstCheckSetsPlaneZ) {
PlaneZRegistry r;
EXPECT_DOUBLE_EQ(r.onChecked("trajectory", "a", 12.0), 12.0);
EXPECT_TRUE(r.hasPlane("trajectory"));
EXPECT_DOUBLE_EQ(r.planeZ("trajectory"), 12.0);
}
TEST(PlaneZRegistry, SecondCheckKeepsFirstZ) {
PlaneZRegistry r;
r.onChecked("trajectory", "a", 12.0);
EXPECT_DOUBLE_EQ(r.onChecked("trajectory", "b", 99.0), 12.0); // 投影到首个 ds 的平面
}
TEST(PlaneZRegistry, PlaneDisappearsWhenAllUnchecked) {
PlaneZRegistry r;
r.onChecked("trajectory", "a", 12.0);
r.onChecked("trajectory", "b", 99.0);
r.onUnchecked("trajectory", "a");
EXPECT_TRUE(r.hasPlane("trajectory")); // 还有 b
r.onUnchecked("trajectory", "b");
EXPECT_FALSE(r.hasPlane("trajectory")); // 全消 → 平面消失
}
TEST(PlaneZRegistry, RecheckAfterEmptyResetsZ) {
PlaneZRegistry r;
r.onChecked("trajectory", "a", 12.0);
r.onUnchecked("trajectory", "a");
EXPECT_DOUBLE_EQ(r.onChecked("trajectory", "c", 7.0), 7.0); // 重新首勾 → 新 z
}
TEST(PlaneZRegistry, SetPlaneZMovesPlane) {
PlaneZRegistry r;
r.onChecked("trajectory", "a", 12.0);
r.setPlaneZ("trajectory", 30.0);
EXPECT_DOUBLE_EQ(r.planeZ("trajectory"), 30.0);
}
```
- [ ] **Step 2: 跑测试确认失败**`build.bat test`;预期编译失败。
- [ ] **Step 3: 实现 `PlaneBasemapManager.hpp` 的 `PlaneZRegistry`**
```cpp
#pragma once
#include <map>
#include <set>
#include <string>
namespace geopro::controller {
// 纯逻辑:按 2D 类型管理「平面 z + 成员集」。首勾定 z(之后固定); 全消则平面消失。见 spec §8.2。
class PlaneZRegistry {
public:
double onChecked(const std::string& typeId, const std::string& dsId, double dsZ) {
auto& p = planes_[typeId];
if (p.members.empty()) p.z = dsZ; // 首勾定 z
p.members.insert(dsId);
return p.z;
}
void onUnchecked(const std::string& typeId, const std::string& dsId) {
auto it = planes_.find(typeId);
if (it == planes_.end()) return;
it->second.members.erase(dsId);
if (it->second.members.empty()) planes_.erase(it); // 全消 → 平面消失
}
bool hasPlane(const std::string& typeId) const { return planes_.count(typeId) > 0; }
double planeZ(const std::string& typeId) const {
auto it = planes_.find(typeId);
return it == planes_.end() ? 0.0 : it->second.z;
}
void setPlaneZ(const std::string& typeId, double z) {
auto it = planes_.find(typeId);
if (it != planes_.end()) it->second.z = z;
}
private:
struct Plane { double z = 0.0; std::set<std::string> members; };
std::map<std::string, Plane> planes_;
};
} // namespace geopro::controller
```
`PlaneBasemapManager.cpp` 暂可空/仅 include底图实例逻辑 Phase F 加。)
- [ ] **Step 4: 跑测试确认通过**`build.bat test`;登记测试见 Step 5预期 `PlaneZRegistry.*` PASS。
- [ ] **Step 5: 登记 CMake**`CMakeLists.txt``PlaneBasemapManager.cpp``tests/CMakeLists.txt` 加 `test_plane_basemap_manager.cpp`
- [ ] **Step 6: 控制器接入平面 z**`VtkSceneController``PlaneZRegistry planeReg_;``setChecked2DDatasets` diff 时:新增 ds → 需其 dsZ初值`view_.zRefElev()` 或 0取 spec「首个勾选 ds 的 z」——首个 ds 无既有 z 则取场景地表基准 `zRefElev()`)→ `planeReg_.onChecked(typeId, dsId, zRef)` 得平面 z → `view_.addMapLine(dsId, line, planeZ)`;取消 → `planeReg_.onUnchecked``view_.removeDataset`。`typeId` = ds 所属段 id2D 类型),由 ddCode 映射(`dd_trajectory_data`→"trajectory")。
- [ ] **Step 7: 构建 + 手动验收**`build.bat app`。验收:勾多条轨迹 → 全落同一平面 z首条决定取消首条 → 平面 z 不变;全取消 → 足迹消失。
- [ ] **Step 8: 提交**
```bash
git add src/controller/PlaneBasemapManager.hpp src/controller/PlaneBasemapManager.cpp tests/controller/test_plane_basemap_manager.cpp tests/CMakeLists.txt CMakeLists.txt src/controller/VtkSceneController.hpp src/controller/VtkSceneController.cpp
git commit -m "feat(vtk): 2D 按类型平面 z 生命周期(首勾定z/全消消失)+单元测试"
```
### Task E3: 段「z值」滑块 popup 接平面 z
**Files:**
- Modify: `src/app/panels/columns/CategorySection.cpp`z值 action 的 popup
- Modify: `src/app/panels/columns/CategoryAnalysisTab.*`、`src/app/main.cpp`(信号转发到 controller `setPlaneZ`
**Interfaces:**
- Produces: `CategorySection` 信号 `void planeZChanged(const QString& typeId, double z);`controller `void setPlaneZ(const QString& typeId, double z)``planeReg_.setPlaneZ` + 重摆该类型足迹。
- [ ] **Step 1: z值 popup** — Task C2 的 z值 action `popupBuilder` 内建 `QSlider`(范围按场景高程量级,如 -500..500 米,默认取当前 `planeZ``valueChanged` → `emit planeZChanged(typeIdOfSpec, v)`。`typeId` = `spec_.id`
- [ ] **Step 2: 转发链**`CategoryAnalysisTab` 转发 `planeZChanged`main 接到 → `sceneCtrl->setPlaneZ(typeId, z)`
- [ ] **Step 3: controller setPlaneZ**`planeReg_.setPlaneZ(typeId,z)` 后,对该类型已勾选足迹 `removeDataset`+`addMapLine(...,z)` 重摆(参照旧 `set2DPlacement` 重摆法)。
- [ ] **Step 4: 构建 + 手动验收**`build.bat app`。验收:拖 z值滑块 → 该类型整块平面(含其上全部足迹)整体升降。
- [ ] **Step 5: 提交**
```bash
git add src/app/panels/columns/CategorySection.hpp src/app/panels/columns/CategorySection.cpp src/app/panels/columns/CategoryAnalysisTab.hpp src/app/panels/columns/CategoryAnalysisTab.cpp src/app/main.cpp src/controller/VtkSceneController.hpp src/controller/VtkSceneController.cpp
git commit -m "feat(vtk): 2D 段 z值滑块整体升降类型平面"
```
---
## Phase F2D 平面底图TileBasemap 多实例)
### Task F1: TileBasemap groundZ 参数化 + 多实例可用
**Files:**
- Modify: `src/app/TileBasemap.hpp/.cpp``kGroundZ` → 成员 `groundZ_`;构造可传初值)
**Interfaces:**
- Produces: `TileBasemap` 构造增可选参 `double groundZ = 0.0`;内部所有 `kGroundZ``groundZ_``Street` 走 `buildFlat`(已是纯平矢量)不变。
- [ ] **Step 1: groundZ 成员化**`TileBasemap.hpp``double groundZ_;` + 构造形参 `double groundZ = 0.0``.cpp` 把 `placeActor`/`buildFlat`/`buildWarped`/Z-fighting 偏移里出现的 `kGroundZ``groundZ_``gz = groundZ_ + (z-kMinZoom)*kZEps`)。
- [ ] **Step 2: 确认多实例无共享态** — review `TileBasemap` 成员:`nam_`/`placed_`/`desired_`/`demCache_`/`texCache_`/`observer_` 均 per-instance`tileKey` static 纯函数 → 多实例安全。无需改。
- [ ] **Step 3: 构建**`build.bat app`3D 底图仍 groundZ=0行为不变
- [ ] **Step 4: 提交**
```bash
git add src/app/TileBasemap.hpp src/app/TileBasemap.cpp
git commit -m "refactor(vtk): TileBasemap groundZ 参数化支持多实例平面底图"
```
### Task F2: PlaneBasemapManager 管 N 个平面底图实例 + 段「底图」popup
**Files:**
- Modify: `src/controller/PlaneBasemapManager.hpp/.cpp`(加底图实例管理)
- Modify: `src/controller/VtkSceneController.*`、`src/app/main.cpp`、`CategorySection.*`
**Interfaces:**
- Consumes: `PlaneZRegistry`(平面 z 生命周期)、`TileBasemap`(多实例)、`Scene&`/`vtkRenderWindow*`/`GeoLocalFrame`、`dataRadiusProvider`。
- Produces: `PlaneBasemapManager`:按 typeId 持 `TileBasemap`Street 纯平、groundZ=planeZ、opacity`onPlaneCreated(typeId,z)` 建实例、`onPlaneDestroyed(typeId)` 销毁、`setKind(typeId,kind)`/`setOpacity(typeId,o)`/`setPlaneZ(typeId,z)`(重建实例于新 z
- [ ] **Step 1: 管理器持底图实例**`PlaneBasemapManager``std::map<std::string, std::unique_ptr<TileBasemap>> bms_;`。`onPlaneCreated``new TileBasemap(scene, rw, frame, groundZ=z)` → `setDataRadiusProvider(provider)``show(Street)`(默认矢量平面)、`setOpacity(0.5)`。`onPlaneDestroyed``bms_.erase(typeId)`(析构移除瓦片)。`setPlaneZ`:销毁旧实例、按新 z 重建(或加 `TileBasemap::setGroundZ` 重铺——本步用重建,简单可靠)。
- [ ] **Step 2: 串到平面生命周期** — controller 在 `PlaneZRegistry` 首勾(`hasPlane` false→true) 时 `mgr.onPlaneCreated(typeId, z)`;全消(true→false) 时 `mgr.onPlaneDestroyed(typeId)``setPlaneZ` 同步 `mgr.setPlaneZ`
- [ ] **Step 3: 段「底图」popup** — Task C2 的底图 action `popupBuilder`:菜单【矢量平面(默认)/无】+ 透明度滑块(默认50);发 `basemapKindChanged(typeId,kind)`/`basemapOpacityChanged(typeId,o)` → main → `mgr.setKind/ setOpacity`。「无」= `bms_[typeId]->hide()`(保留实例,仅隐瓦片)。
- [ ] **Step 4: 构建 + 手动验收**`build.bat app`。验收:勾首条轨迹 → 出现该类型平面矢量底图(贴平面 z、纯平无高程、范围≈数据范围z值滑块带底图同升降底图 popup 可切无/调透明度;全取消 → 平面+底图一并消失。
- [ ] **Step 5: 提交**
```bash
git add src/controller/PlaneBasemapManager.hpp src/controller/PlaneBasemapManager.cpp src/controller/VtkSceneController.hpp src/controller/VtkSceneController.cpp src/app/main.cpp src/app/panels/columns/CategorySection.hpp src/app/panels/columns/CategorySection.cpp src/app/panels/columns/CategoryAnalysisTab.hpp src/app/panels/columns/CategoryAnalysisTab.cpp
git commit -m "feat(vtk): 2D 每类型平面矢量底图(TileBasemap多实例+生命周期+底图popup)"
```
---
## Self-Review计划对照 spec
**Spec coverage**
- §4 面板单列/动态显隐/空占位 → Task B1/B2/B3 ✓
- §5 统一段配置(dimension+trajectory) → Task A1 ✓
- §6 响应式图标条+「…」溢出(数量+宽度两分支) → Task C1/C2 ✓
- §7.1 筛选折叠 → C2§7.2 新增三维体图标 → C2§7.3 z值 → E3§7.4 2D底图 → F2§7.5 3D底图移工具栏 → D2/D3§7.6 导入雷达移设备菜单 → D1 ✓
- §8 单一自由场景 + 2D按类型平面 + 废弃逐ds拖Z/view2DMode → E1/E2/E3 ✓
- §9.1 3D共享底图(透明度可调/隐藏) → D2/D3§9.2 N个2D平面底图(TileBasemap多实例+生命周期) → F1/F2 ✓
- §10 默认勾选保留 + 2D信号迁段 → B2/E2/E3 ✓
**Placeholder scan** UI 接线步骤C1-Step7、F2 等)给出真实代码骨架 + 参照锚点(`VtkViewToolbar::iconBtn`、`Glyphs.hpp`、旧 `set2DPlacement` 重摆法),非 TODO。纯逻辑任务A1/C1/E2含完整测试与实现代码。
**Type consistency** `CategoryDim`(A1) / `visibleIconCount`(C1) / `PlaneZRegistry`(E2) / `TileBasemap::setOpacity`(D2)·`groundZ`(F1) / `setChecked3DDatasets`·`setChecked2DDatasets`·`setPlaneZ`(B2/E2/E3) 跨任务名称一致。
**已知待实现期细化(非占位,属合理实现细节):** z值滑块数值范围E3按场景高程量级`Glyph::Map` 若不存在选近义键D3`buildDeviceMenu` 自由函数 vs 成员法D1按实际定义形态