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

1099 lines
62 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仅改与需求直接相关处
---
## 文件结构(创建/修改一览)
**新建(抽象层 —— spec §5**
- `src/data/repo/CategoryDescriptor.hpp/.cpp` `CategoryDescriptor` + `SceneKind`/`FilterKind`/`OpKind` 枚举 + `byDdCode`/`byDsTypeCode` 便捷器 + `categoryCatalog()`取代 `CategoryConfig.hpp`/`categoryConfigs`)。
- `src/controller/DatasetRenderStrategy.hpp/.cpp` `IDatasetRenderStrategy` 接口 + 字符串键注册表 + 3 实现`VolumeRenderStrategy`/`CurtainRenderStrategy`/`Plane2DRenderStrategy`)。`Plane2DRenderStrategy` 内含 `PlaneZRegistry`平面 z 生命周期+Phase FN `TileBasemap` 平面底图
- `src/app/panels/columns/SectionIconBar.hpp/.cpp` 段头响应式图标工具条默认3超出/挤压收进「…」)。
- `tests/data/test_category_descriptor.cpp` classify 路由 + catalog 完整性纯逻辑测试
- `tests/controller/test_plane_z_registry.cpp` 平面 z 生命周期纯逻辑测试`PlaneZRegistry`)。
- `tests/app/test_section_icon_bar.cpp` 图标溢出可见数纯逻辑测试
**修改**
- `src/app/DatasetCategory.hpp/.cpp` `splitByCategory` 改为遍历 `categoryCatalog()` `classify` 路由取代 ddCode/dsTypeCode 硬匹配)。
- `src/app/panels/columns/CategoryAnalysisTab.hpp/.cpp` 统一单列 `DatasetColumn`动态显隐 + 空占位 + 遍历 catalog 建段
- `src/app/panels/columns/CategorySection.hpp/.cpp` 构造改吃 `CategoryDescriptor`段头按 `operations(OpKind)` `SectionIconBar` `OpKind→UI` 映射一处 `filters(FilterKind)` 建筛选器`FilterKindUI` 映射一处筛选折叠移除导入雷达
- `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` 主流程改查描述符 `renderStrategyId` 策略 `add/remove` + 首勾/全消 `onTypeActivated/Deactivated`」;移除 `view2DMode`/`setAnalysisMode2D`/`set2DPlacement` 维度耦合
- `src/app/VtkSceneView.hpp/.cpp` 移除 `setAnalysisMode2D` 显隐/相机耦合 ds Z`nudgeSelectedMapLinesZ`/`mapLineZOffset_`)。
- `src/app/main.cpp` 接线改造单列喂数据导入雷达接 TopBar3D 底图接工具栏 col2D/analysisMode 勾选并集直接下发控制器按描述符路由策略 dimOf 分判)。
- `CMakeLists.txt`、`tests/CMakeLists.txt` /测试登记
**删除(评估后)**
- `src/app/panels/columns/Column2DDataset.hpp/.cpp` 合并后退役Phase B 末确认无引用再删)。
- `src/data/repo/CategoryConfig.hpp`、`src/app/DatasetDimension.hpp/.cpp` `CategoryDescriptor`/catalog + 策略取代后退役确认无引用再删)。
---
## 阶段总览
- **Phase A**类型抽象基础 —— 描述符目录 `categoryCatalog`A1+ 渲染策略接口/注册表/3 骨架策略A2)。纯逻辑 TDD
- **Phase B**单列面板骨架动态显隐 + 空占位 + tab勾选经描述符路由策略构建后 2D/3D 同列可见
- **Phase C**响应式图标工具条 `operations`/`filters` 驱动+ 筛选折叠 + 新增三维体图标化
- **Phase D**导入雷达移顶部设备菜单 + 3D 底图移渲染工具栏 `TileBasemap` 透明度参数化)。
- **Phase E**单一自由场景 + `Plane2DRenderStrategy` 装入平面 z 生命周期纯逻辑 TDD + 接线)。
- **Phase F**2D 平面底图`TileBasemap` 多实例并入 `Plane2DRenderStrategy` + 段底图 popup)。
每个 Phase 结束都应 `build.bat test` 绿 + `build.bat app` 可运行
---
## Phase A类型抽象基础描述符目录 + 渲染策略注册表)
### Task A1: CategoryDescriptor + categoryCatalog + classify取代 CategorySpec/categoryConfigs
**Files:**
- Create: `src/data/repo/CategoryDescriptor.hpp`、`src/data/repo/CategoryDescriptor.cpp`
- Modify: `src/app/DatasetCategory.cpp:5-20``splitByCategory` 改遍历 catalog classify
- Modify: `CMakeLists.txt`登记 `CategoryDescriptor.cpp`)、`tests/CMakeLists.txt`
- Create: `tests/data/test_category_descriptor.cpp`
**Interfaces:**
- Producesspec §5.15.2`namespace geopro::data` `enum class SceneKind { Volume3D, Curtain3D, Plane2D };`、`enum class FilterKind { DateRange, ArrayType };`、`enum class OpKind { GenerateVolume, Filter, PlaneZ, Basemap };`、`struct CategoryDescriptor{ id,title,sceneKind,classify,filters,operations,renderStrategyId }`、`byDdCode(...)`/`byDsTypeCode(...)`、`const std::vector<CategoryDescriptor>& categoryCatalog();`。
- `splitByCategory(rows)` 返回 `CategoryBuckets`(与 catalog 同序同长),每行归入首个 `classify(row)==true` 的段。
- [ ] **Step 1: 写失败测试**`tests/data/test_category_descriptor.cpp`
```cpp
#include <gtest/gtest.h>
#include "repo/CategoryDescriptor.hpp"
#include "DatasetCategory.hpp"
using namespace geopro::data;
TEST(CategoryCatalog, HasFiveSegmentsInOrder) {
const auto& cat = categoryCatalog();
ASSERT_EQ(cat.size(), 5u);
EXPECT_EQ(cat[0].id, "resistivity");
EXPECT_EQ(cat[3].id, "voxel");
EXPECT_EQ(cat[4].id, "trajectory");
EXPECT_EQ(cat[4].sceneKind, SceneKind::Plane2D);
EXPECT_EQ(cat[4].renderStrategyId, "plane2d");
}
TEST(CategoryCatalog, TrajectoryClassifiesByDdCode) {
const auto& cat = categoryCatalog();
DsRow traj; traj.id = "t1"; traj.ddCode = "dd_trajectory_data";
EXPECT_TRUE(cat[4].classify(traj));
DsRow vox; vox.ddCode = "dd_voxel";
EXPECT_FALSE(cat[4].classify(vox));
}
TEST(SplitByCategory, RoutesRowToFirstMatchingDescriptor) {
DsRow traj; traj.id = "t1"; traj.ddCode = "dd_trajectory_data";
DsRow ert; ert.id = "e1"; ert.dsTypeCode = "ERT platform inversion data";
auto b = geopro::app::splitByCategory({traj, ert});
const auto& cat = categoryCatalog();
ASSERT_EQ(b.segments.size(), cat.size());
EXPECT_EQ(b.segments[0].size(), 1u); // resistivity ← ert
EXPECT_EQ(b.segments[0][0].id, "e1");
EXPECT_EQ(b.segments[4].size(), 1u); // trajectory ← traj
EXPECT_EQ(b.segments[4][0].id, "t1");
}
```
- [ ] **Step 2: 跑测试确认失败**`build.bat test`;预期编译失败(`CategoryDescriptor.hpp` 不存在)。
- [ ] **Step 3: 写 `CategoryDescriptor.hpp`**
```cpp
#pragma once
#include <functional>
#include <initializer_list>
#include <string>
#include <vector>
#include "repo/RepoTypes.hpp" // DsRow
namespace geopro::data {
enum class SceneKind { Volume3D, Curtain3D, Plane2D }; // 渲染语义/共存规则
enum class FilterKind { DateRange, ArrayType }; // 筛选器契约(可扩展)
enum class OpKind { GenerateVolume, Filter, PlaneZ, Basemap }; // 段操作契约(可扩展)
struct CategoryDescriptor {
std::string id;
std::string title;
SceneKind sceneKind;
std::function<bool(const DsRow&)> classify; // 轴1 数据来源/分类
std::vector<FilterKind> filters; // 轴2 筛选器
std::vector<OpKind> operations; // 轴3 段头图标操作
std::string renderStrategyId; // 轴4 渲染策略键
};
// classify 便捷构造器(常见按 ddCode / dsTypeCode 接入)
std::function<bool(const DsRow&)> byDdCode(std::initializer_list<std::string> codes);
std::function<bool(const DsRow&)> byDsTypeCode(std::initializer_list<std::string> codes);
const std::vector<CategoryDescriptor>& categoryCatalog();
} // namespace geopro::data
```
- [ ] **Step 4: 写 `CategoryDescriptor.cpp`**
```cpp
#include "repo/CategoryDescriptor.hpp"
#include <vector>
namespace geopro::data {
std::function<bool(const DsRow&)> byDdCode(std::initializer_list<std::string> codes) {
std::vector<std::string> cs(codes);
return [cs](const DsRow& r) {
for (const auto& c : cs) if (r.ddCode == c) return true;
return false;
};
}
std::function<bool(const DsRow&)> byDsTypeCode(std::initializer_list<std::string> codes) {
std::vector<std::string> cs(codes);
return [cs](const DsRow& r) {
for (const auto& c : cs) if (r.dsTypeCode == c) return true;
return false;
};
}
const std::vector<CategoryDescriptor>& categoryCatalog() {
static const std::vector<CategoryDescriptor> kCat = {
{"resistivity", "电阻率数据", SceneKind::Curtain3D,
byDsTypeCode({"ERT platform inversion data"}),
{FilterKind::DateRange, FilterKind::ArrayType},
{OpKind::GenerateVolume, OpKind::Filter}, "curtain"},
{"apparent", "视电阻率数据", SceneKind::Curtain3D,
byDsTypeCode({"visual resistivity data"}),
{FilterKind::DateRange, FilterKind::ArrayType},
{OpKind::GenerateVolume, OpKind::Filter}, "curtain"},
{"transient", "瞬变电磁数据", SceneKind::Curtain3D,
byDsTypeCode({"DD TRANSIENT ELECTROMAGNETIC INVERSION"}),
{FilterKind::DateRange},
{OpKind::GenerateVolume, OpKind::Filter}, "curtain"},
{"voxel", "三维体", SceneKind::Volume3D,
byDdCode({"dd_voxel"}),
{FilterKind::DateRange},
{OpKind::Filter}, "volume"},
{"trajectory", "轨迹数据", SceneKind::Plane2D,
byDdCode({"dd_trajectory_data"}),
{FilterKind::DateRange},
{OpKind::PlaneZ, OpKind::Filter, OpKind::Basemap}, "plane2d"},
};
return kCat;
}
} // namespace geopro::data
```
- [ ] **Step 5: 改 `splitByCategory``DatasetCategory.cpp`)遍历 catalog 用 classify**
```cpp
#include "DatasetCategory.hpp"
#include "repo/CategoryDescriptor.hpp"
namespace geopro::app {
CategoryBuckets splitByCategory(const std::vector<geopro::data::DsRow>& rows) {
const auto& cat = geopro::data::categoryCatalog();
CategoryBuckets b;
b.segments.resize(cat.size());
for (const auto& r : rows)
for (std::size_t i = 0; i < cat.size(); ++i)
if (cat[i].classify && cat[i].classify(r)) { b.segments[i].push_back(r); break; }
return b;
}
} // namespace geopro::app
```
**过渡策略——加新不删旧**:本任务只让 `splitByCategory` 改用 `categoryCatalog()`(输出 `CategoryBuckets` 签名/顺序不变,因 catalog 与旧 `categoryConfigs` 同 id 同序)。**旧 `CategoryConfig.hpp`/`CategorySpec`/`categoryConfigs` 暂保留**——`CategoryAnalysisTab` 构造仍按 `categoryConfigs()` 建段、`CategorySection` 仍读 `canGenerateVolume`/`hasArrayTypeFilter`故全程可编译。Phase CTask C2`CategorySection`/`CategoryAnalysisTab` 迁到吃 `CategoryDescriptor``operations`/`filters` 驱动)后,再删 `CategoryConfig.hpp`。`DatasetCategory.cpp` include 加 `repo/CategoryDescriptor.hpp`。)
- [ ] **Step 6: 登记 + 跑测试确认通过**`CMakeLists.txt``CategoryDescriptor.cpp``tests/CMakeLists.txt` 加 `test_category_descriptor.cpp`(参照 `test_dataset_category.cpp`)。`build.bat test`;预期新测试全 PASS。
- [ ] **Step 7: 提交**
```bash
git add src/data/repo/CategoryDescriptor.hpp src/data/repo/CategoryDescriptor.cpp src/app/DatasetCategory.hpp src/app/DatasetCategory.cpp tests/data/test_category_descriptor.cpp tests/CMakeLists.txt CMakeLists.txt
git commit -m "feat(vtk): 类目描述符目录 categoryCatalog(classify谓词+扩展契约)取代 categoryConfigs"
```
### Task A2: 渲染策略接口 + 字符串键注册表 + 3 骨架策略
**Files:**
- Create: `src/controller/DatasetRenderStrategy.hpp`、`src/controller/DatasetRenderStrategy.cpp`
- Modify: `CMakeLists.txt`
- Test: 注册表解析逻辑随 Phase E 接入后由集成验证;本任务以编译 + 一个最小注册/解析单元测试佐证。
- Create: `tests/controller/test_render_strategy_registry.cpp`
**Interfaces:**
- Producesspec §5.4`namespace geopro::controller` 内 `class IDatasetRenderStrategy{ virtual add(typeId,dsId); remove(dsId); onTypeActivated(typeId); onTypeDeactivated(typeId); }``class RenderStrategyRegistry{ void registerStrategy(std::string id, std::unique_ptr<IDatasetRenderStrategy>); IDatasetRenderStrategy* get(const std::string& id) const; }`。
- 3 骨架实现:`VolumeRenderStrategy` / `CurtainRenderStrategy` / `Plane2DRenderStrategy`(本任务仅建类与注册;`add/remove` 内部实现 Phase B3D 包现有分支)与 Phase E/F2D 平面+底图)填充)。
- [ ] **Step 1: 写失败测试**`tests/controller/test_render_strategy_registry.cpp`
```cpp
#include <gtest/gtest.h>
#include "controller/DatasetRenderStrategy.hpp"
using namespace geopro::controller;
namespace {
struct FakeStrategy : IDatasetRenderStrategy {
int added = 0;
void add(const std::string&, const std::string&) override { ++added; }
void remove(const std::string&) override {}
};
}
TEST(RenderStrategyRegistry, ResolvesById) {
RenderStrategyRegistry reg;
reg.registerStrategy("fake", std::make_unique<FakeStrategy>());
auto* s = reg.get("fake");
ASSERT_NE(s, nullptr);
s->add("trajectory", "d1");
EXPECT_EQ(static_cast<FakeStrategy*>(s)->added, 1);
EXPECT_EQ(reg.get("missing"), nullptr);
}
```
- [ ] **Step 2: 跑测试确认失败**`build.bat test`;预期编译失败(头文件不存在)。
- [ ] **Step 3: 写 `DatasetRenderStrategy.hpp`**
```cpp
#pragma once
#include <map>
#include <memory>
#include <string>
namespace geopro::controller {
class IDatasetRenderStrategy {
public:
virtual ~IDatasetRenderStrategy() = default;
virtual void add(const std::string& typeId, const std::string& dsId) = 0;
virtual void remove(const std::string& dsId) = 0;
virtual void onTypeActivated(const std::string& /*typeId*/) {}
virtual void onTypeDeactivated(const std::string& /*typeId*/) {}
};
class RenderStrategyRegistry {
public:
void registerStrategy(std::string id, std::unique_ptr<IDatasetRenderStrategy> s) {
strategies_[std::move(id)] = std::move(s);
}
IDatasetRenderStrategy* get(const std::string& id) const {
auto it = strategies_.find(id);
return it == strategies_.end() ? nullptr : it->second.get();
}
private:
std::map<std::string, std::unique_ptr<IDatasetRenderStrategy>> strategies_;
};
} // namespace geopro::controller
```
- [ ] **Step 4: 写 3 骨架策略声明(`DatasetRenderStrategy.hpp` 续 / 或同目录头)** — 各持 `VtkSceneController&`(或所需 View/Repo 引用),`add/remove` 暂转调控制器现有方法Phase B/E/F 内迁实现):
```cpp
// 同文件内(或 controller 内部)声明,构造接 VtkSceneController& 以转调现有渲染:
class VolumeRenderStrategy : public IDatasetRenderStrategy { /* add→现 addDatasetAsync(volume 分支) */ };
class CurtainRenderStrategy : public IDatasetRenderStrategy { /* add→现 addDatasetAsync(curtain 分支) */ };
class Plane2DRenderStrategy : public IDatasetRenderStrategy { /* Phase E/F平面 z + 底图 */ };
```
(具体成员/实现在 Phase EPlane2D 装 PlaneZRegistry与 Phase B3D 两策略包现有分支)填;本步只需类存在并可注册,`.cpp` 给空/最小实现使链接通过。)
- [ ] **Step 5: 登记 + 跑测试确认通过**`CMakeLists.txt``DatasetRenderStrategy.cpp``tests/CMakeLists.txt` 加 `test_render_strategy_registry.cpp`。`build.bat test`;预期 PASS。
- [ ] **Step 6: 提交**
```bash
git add src/controller/DatasetRenderStrategy.hpp src/controller/DatasetRenderStrategy.cpp tests/controller/test_render_strategy_registry.cpp tests/CMakeLists.txt CMakeLists.txt
git commit -m "feat(vtk): 渲染策略接口+字符串键注册表+3骨架策略(volume/curtain/plane2d)"
```
---
## 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: `categoryCatalog()`(含 trajectory 段Task A1
- 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& cat = geopro::data::categoryCatalog();
for (std::size_t i = 0; i < cat.size() && i < b.segments.size(); ++i) {
if (cat[i].id == "voxel") continue; // voxel 由外部单独 setDatasets 注入,勿覆盖
if (auto* sec = section(cat[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 注入。删 `drawer->col2D()->setDatasets(splitByDimension(...).dim2D)` 用法。渲染路由不再用 `dimOf`/`splitByDimension`(改走描述符→策略,见 Step 4`DatasetDimension.*` 待 Phase F Step4 退役。
- [ ] **Step 4: 勾选 → 描述符路由策略(统一入口,无维度散判)** — 统一段的 `checkedDatasetsChanged` 上抛 2D+3D 勾选并集。main 用 `categoryCatalog().classify` 把每个 dsId 解析到其**描述符 id=typeId**,下发控制器统一入口 `setCheckedDatasets(dsId→typeId 列表)`;控制器 diff 后按 `catalog[typeId].renderStrategyId` 查注册表派给策略 `add/remove`,并维护「每 typeId 活跃集」在首勾/全消时调 `onTypeActivated/Deactivated`
```cpp
// main.cpp勾选并集 → (dsId, typeId) 列表 → 控制器统一入口
QObject::connect(analysisTab, &geopro::app::CategoryAnalysisTab::checkedDatasetsChanged, &window,
[sceneCtrl, lastSourceRows](const QStringList& ids) {
std::vector<std::pair<std::string, std::string>> idType; // dsId, typeId(描述符 id)
const auto& cat = geopro::data::categoryCatalog();
for (const QString& q : ids) {
const std::string id = q.toStdString();
const geopro::data::DsRow* row = findRow(*lastSourceRows, id); // main lambda: 按 id 查行
if (!row) continue;
for (const auto& d : cat)
if (d.classify && d.classify(*row)) { idType.push_back({id, d.id}); break; }
}
sceneCtrl->setCheckedDatasets(idType);
});
```
控制器侧(`VtkSceneController`,本步落地统一路由 —— 取代旧 `setChecked`/`setChecked2DDatasets`
```cpp
void VtkSceneController::setCheckedDatasets(
const std::vector<std::pair<std::string,std::string>>& idType) {
std::map<std::string,std::string> next; // dsId→typeId
for (auto& p : idType) next[p.first] = p.second;
const auto& cat = geopro::data::categoryCatalog();
auto stratOf = [&](const std::string& typeId) -> IDatasetRenderStrategy* {
for (const auto& d : cat) if (d.id == typeId) return registry_.get(d.renderStrategyId);
return nullptr;
};
// 移除:旧有新无
for (auto& [id, typeId] : checked_)
if (!next.count(id)) {
if (auto* s = stratOf(typeId)) s->remove(id);
if (--typeActive_[typeId] == 0) if (auto* s = stratOf(typeId)) s->onTypeDeactivated(typeId);
}
// 新增:新有旧无
for (auto& [id, typeId] : next)
if (!checked_.count(id)) {
if (typeActive_[typeId]++ == 0) if (auto* s = stratOf(typeId)) s->onTypeActivated(typeId);
if (auto* s = stratOf(typeId)) s->add(typeId, id);
}
checked_ = std::move(next);
view_.renderIncremental();
}
```
`registry_`=Task A2 的 `RenderStrategyRegistry`,由控制器构造时注册 3 策略;`checked_`=`std::map<dsId,typeId>`、`typeActive_`=`std::map<typeId,int>` 为新成员。`VolumeRenderStrategy`/`CurtainRenderStrategy::add` 内部转调原 `addDatasetAsync` 的体/帘面分支;`Plane2DRenderStrategy::add` 本步暂转调原 `add2DDatasetAsync`Phase E/F 改平面 z+底图)。旧 `setChecked`/`setChecked2DDatasets`/`set2DPlacement` 公有入口删除或内联进策略。)
- [ ] **Step 5: 构建 + 手动验收**`build.bat app`。验收:左侧只剩一个单列(无 tab勾 3D 反演/体 → 渲染帘面/体(经 Curtain/Volume 策略);勾轨迹 → 渲染足迹折线(经 Plane2D 策略,落点暂沿用旧 placementPhase E 改平面);无崩溃。
- [ ] **Step 6: 提交**
```bash
git add src/app/panels/columns/ColumnDrawer.hpp src/app/panels/columns/ColumnDrawer.cpp src/app/main.cpp src/controller/VtkSceneController.hpp src/controller/VtkSceneController.cpp src/controller/DatasetRenderStrategy.hpp src/controller/DatasetRenderStrategy.cpp
git commit -m "refactor(vtk): 抽屉去tab单列; 勾选经描述符路由渲染策略(统一入口,无维度散判)"
```
### 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/CategoryAnalysisTab 迁到描述符;段头图标条由 operations/filters 驱动
> 本任务是 A1「加新不删旧」的**消费方迁移点**`CategorySection`/`CategoryAnalysisTab` 构造从吃 `CategorySpec` 改吃 `CategoryDescriptor`;段头图标由 `descriptor.operations(OpKind)` 驱动、筛选器由 `descriptor.filters(FilterKind)` 驱动。迁完后 `CategoryConfig.hpp`/`CategorySpec` 不再被引用Phase F Step4 删)。
**Files:**
- Modify: `src/app/panels/columns/CategorySection.hpp/.cpp`(构造改吃 `CategoryDescriptor`;段头 + 筛选行)
- Modify: `src/app/panels/columns/CategoryAnalysisTab.cpp`(构造遍历 `categoryCatalog()` 建段、传描述符)
**Interfaces:**
- Consumes: `geopro::data::CategoryDescriptor``operations`/`filters`/`id`/`title`)、`SectionIconBar`。
- Produces: `CategorySection(const CategoryDescriptor& desc, dict, parent)`;段头右侧 `SectionIconBar``desc.operations` 装配;筛选器按 `desc.filters` 装配;筛选行默认折叠由 `OpKind::Filter` 图标 toggle。新增信号 `void zSliderRequested(QString typeId); void basemapPopupRequested(QString typeId);`
- [ ] **Step 1: 构造改吃描述符 + `OpKind→UI` 映射建图标条**`CategorySection` 成员 `spec_` 改为 `CategoryDescriptor desc_`。替换 `CategorySection.cpp:66-111`(旧文字按钮)为:建 `SectionIconBar* iconBar_`,遍历 `desc_.operations` 经**一处映射**生成 `IconAction`
```cpp
std::vector<IconAction> acts;
for (geopro::data::OpKind op : desc_.operations) {
switch (op) {
case geopro::data::OpKind::GenerateVolume:
acts.push_back({"plus", "新增三维体",
[this]{ emit generateVolumeRequested(QString(), checkedDsIds()); }, {}});
break; // dsTypeCode 不再由段配置带,改由 generateVolumeRequested 接收方按 desc_.id 解析
case geopro::data::OpKind::Filter:
acts.push_back({"filter", "筛选",
[this]{ filterRow_->setVisible(!filterRow_->isVisible()); }, {}});
break;
case geopro::data::OpKind::PlaneZ:
acts.push_back({"layers", "z值", {},
[this](QToolButton* b){ /* Task E3 建滑块 popup */ emit zSliderRequested(QString::fromStdString(desc_.id)); }});
break;
case geopro::data::OpKind::Basemap:
acts.push_back({"map", "底图", {},
[this](QToolButton* b){ /* Task F2 建底图 popup */ emit basemapPopupRequested(QString::fromStdString(desc_.id)); }});
break;
}
}
iconBar_->setActions(acts);
hl->addWidget(iconBar_);
```
glyph 键 `"plus"/"filter"/"layers"/"map"` 映射到 `Glyphs.hpp` 现有键,缺则补近义键。`generateVolumeRequested` 第一参数原为 dsTypeCode——改由接收方按 `desc_.id` 经 catalog 反查,或保留 `desc_` 内冗余存一个 dsTypeCode 字段;本计划取「接收方按 id 解析」。)
- [ ] **Step 2: `FilterKind→UI` 建筛选行(默认折叠)** — 替换 `CategorySection.cpp:119-132`:建 `QWidget* filterRow_`(默认 `hide()`),遍历 `desc_.filters``DateRange`→建 `DateRangeEdit``ArrayType`→建 `arrayCombo_`。`passesFilters`/`rebuildList` 据建出的控件判断(无该控件即不筛该维度)。
- [ ] **Step 3: hpp 改造**`CategorySection.hpp`:构造签名改 `const geopro::data::CategoryDescriptor&`;成员 `desc_`;加 `SectionIconBar* iconBar_=nullptr; QWidget* filterRow_=nullptr;`;信号加 `zSliderRequested(QString)`/`basemapPopupRequested(QString)`;移除 `radarImportRequested`。`CategoryAnalysisTab.cpp` 构造改 `for (const auto& desc : geopro::data::categoryCatalog()) new CategorySection(desc, dict, content);``sections_[desc.id]`。
- [ ] **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.cpp
git commit -m "feat(vtk): 段构造迁描述符;段头图标条由operations/filters驱动+筛选折叠"
```
---
## 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`/`placement2dMode_``set2DPlacement` 公有入口已在 B2 随策略切换删除,此处清剩余私有状态)
- Modify: `src/app/main.cpp`(拖 Z 浮层 `498-505`、`544-557`
**Interfaces:**
- Produces: 场景恒自由透视;删除 `setAnalysisMode2D`、`nudgeSelectedMapLinesZ`、`mapLineZOffset_`、拖 Z 浮层、残留 `view2DMode`/`placement2dMode_` 状态。
- [ ] **Step 1: 删 VtkSceneView 维度显隐/相机** — 移除 `setAnalysisMode2D`(420-439) 的按维度翻可见 + 相机锁定;移除 `pickMapLineAt`/`nudgeSelectedMapLinesZ`/`mapLineZOffset_`482-539 及成员)。`mapLineDs_` 是否保留视 Task E2 需要(仍需识别足迹 actor 归属 → 保留)。
- [ ] **Step 2: 删 controller analysisMode/view2DMode 残留** — 移除 `onAnalysisModeChanged`、`placement2dMode_`/`customZ2d_`/`placementZ()`/`view2DMode` 等残留状态(其渲染职责已在 B2 迁入 `Plane2DRenderStrategy`)。
- [ ] **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: Plane2DRenderStrategy 装入平面 z 生命周期PlaneZRegistry纯逻辑 TDD
**Files:**
- Create: `src/controller/PlaneZRegistry.hpp`(纯逻辑头,平面 z 生命周期)
- Create: `tests/controller/test_plane_z_registry.cpp`
- Modify: `tests/CMakeLists.txt`、`CMakeLists.txt`
- Modify: `src/controller/DatasetRenderStrategy.hpp/.cpp``Plane2DRenderStrategy` 持 `PlaneZRegistry``add/remove/onTypeDeactivated` 用之)
**Interfaces:**
- Produces: `class PlaneZRegistry`(纯逻辑核,被 `Plane2DRenderStrategy` 持有):
- `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_z_registry.cpp`
```cpp
#include <gtest/gtest.h>
#include "controller/PlaneZRegistry.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: 实现 `PlaneZRegistry.hpp`**
```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
```
- [ ] **Step 4: 跑测试确认通过**`build.bat test`;登记测试见 Step 5预期 `PlaneZRegistry.*` PASS。
- [ ] **Step 5: 登记 CMake**`tests/CMakeLists.txt``test_plane_z_registry.cpp``PlaneZRegistry.hpp` 为纯头,无需加源)。
- [ ] **Step 6: `Plane2DRenderStrategy` 装入 `PlaneZRegistry`** — 让 A2 建的 `Plane2DRenderStrategy``PlaneZRegistry planeReg_;`,并把 Phase B 里它「转调 `add2DDatasetAsync`」的实现改为:
- `add(typeId, dsId)``dsZ` = 该 ds 既有 z无则取场景地表基准 `view_.zRefElev()`)→ `double z = planeReg_.onChecked(typeId, dsId, dsZ)``repo_.loadMapLine(dsId, ...)` 落地时 `view_.addMapLine(dsId, line, z)`z 取 `planeReg_.planeZ(typeId)` 当前值)。
- `remove(dsId)``view_.removeDataset(dsId)`;并 `planeReg_.onUnchecked(typeId, dsId)`typeId 由 strategy 持有的 dsId→typeId 小表或随 remove 传参得到——`IDatasetRenderStrategy::remove` 只有 dsId故 strategy 内自存 `dsId→typeId`)。
- `onTypeDeactivated(typeId)`:该类型全消(`PlaneZRegistry` 此时已 `hasPlane==false`)→ Phase F 在此销毁平面底图。
- `typeId` 即描述符 id控制器 `setCheckedDatasets` 传入§B2
- [ ] **Step 7: 构建 + 手动验收**`build.bat app`。验收:勾多条轨迹 → 全落同一平面 z首条决定取消首条 → 平面 z 不变;全取消 → 足迹消失。
- [ ] **Step 8: 提交**
```bash
git add src/controller/PlaneZRegistry.hpp tests/controller/test_plane_z_registry.cpp tests/CMakeLists.txt src/controller/DatasetRenderStrategy.hpp src/controller/DatasetRenderStrategy.cpp
git commit -m "feat(vtk): Plane2D 策略装入平面 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)`(转交 `Plane2DRenderStrategy::setPlaneZ`)。`Plane2DRenderStrategy::setPlaneZ(typeId,z)` → `planeReg_.setPlaneZ` + 重摆该类型足迹。
- [ ] **Step 1: z值 popup** — Task C2 的 z值 action `popupBuilder` 内建 `QSlider`(范围按场景高程量级,如 -500..500 米,默认取当前 `planeZ``valueChanged` → `emit planeZChanged(typeIdOfSpec, v)`。`typeId` = `descriptor.id`
- [ ] **Step 2: 转发链**`CategoryAnalysisTab` 转发 `planeZChanged`main 接到 → `sceneCtrl->setPlaneZ(typeId, z)`
- [ ] **Step 3: 落到策略**`VtkSceneController::setPlaneZ``Plane2DRenderStrategy``registry_.get("plane2d")` 下转型,或控制器持其指针)调 `setPlaneZ(typeId,z)`:内部 `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: Plane2DRenderStrategy 持 N 个平面底图 + 段「底图」popup
**Files:**
- Modify: `src/controller/DatasetRenderStrategy.hpp/.cpp``Plane2DRenderStrategy` 加底图实例管理)
- Modify: `src/controller/VtkSceneController.*`、`src/app/main.cpp`、`CategorySection.*`、`CategoryAnalysisTab.*`
**Interfaces:**
- Consumes: `PlaneZRegistry`(已在 strategy 内)、`TileBasemap`多实例F1、`Scene&`/`vtkRenderWindow*`/`GeoLocalFrame`、`dataRadiusProvider`strategy 构造时注入)。
- Produces: `Plane2DRenderStrategy``std::map<std::string, std::unique_ptr<TileBasemap>> bms_;`(按 typeId新方法 `setBasemapKind(typeId,kind)` / `setBasemapOpacity(typeId,o)``onTypeActivated/Deactivated` 建/销毁底图;`setPlaneZ` 同步重建底图于新 z。controller 转发 `setBasemapKind/Opacity`
- [ ] **Step 1: strategy 持底图实例**`Plane2DRenderStrategy``std::map<std::string, std::unique_ptr<TileBasemap>> bms_;`。`onTypeActivated(typeId)`(该类型首勾,平面已建):`bms_[typeId] = std::make_unique<TileBasemap>(scene_, rw_, frame_, planeReg_.planeZ(typeId))` → `setDataRadiusProvider(provider_)``show(Street)`(纯平矢量)+ `setOpacity(0.5)`。`onTypeDeactivated(typeId)`(全消):`bms_.erase(typeId)`(析构移除瓦片,连同折线平面消失)。`setPlaneZ`:销毁旧底图、按新 z 重建(或 `TileBasemap::setGroundZ` 重铺——本步用重建,简单可靠)。
- [ ] **Step 2: 段「底图」popup** — Task C2 的底图 action `popupBuilder`:菜单【矢量平面(默认)/无】+ 透明度滑块(默认50)`CategorySection` 发 `basemapKindChanged(typeId,kind)`/`basemapOpacityChanged(typeId,o)` → `CategoryAnalysisTab` 转发 → main → `sceneCtrl->setBasemapKind/Opacity(typeId,...)``Plane2DRenderStrategy`。「无」= `bms_[typeId]->hide()`(保留实例,仅隐瓦片)。
- [ ] **Step 3: 构建 + 手动验收**`build.bat app`。验收:勾首条轨迹 → 出现该类型平面矢量底图(贴平面 z、纯平无高程、范围≈数据范围z值滑块带底图同升降底图 popup 可切无/调透明度;全取消 → 平面+底图一并消失。
- [ ] **Step 4: 退役旧分类件** — 确认 `splitByDimension`/`DatasetDimension.*`、`CategoryConfig.hpp` 无引用后删除(`grep -rn` 核对);`CMakeLists.txt` 去登记。
- [ ] **Step 5: 提交**
```bash
git add src/controller/DatasetRenderStrategy.hpp src/controller/DatasetRenderStrategy.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): Plane2D 策略持每类型平面矢量底图(多实例+生命周期+底图popup)"
```
---
## Self-Review计划对照 spec
**Spec coverage**
- §5 类型抽象(描述符目录 + classify 谓词 + 扩展契约)→ Task A1 ✓;渲染策略接口/注册表/3 策略 → Task A2 ✓
- §4 面板单列/动态显隐/空占位 → Task B1/B2/B3 ✓
- §5.3 消费方只认抽象(勾选经描述符路由策略,无维度散判)→ B2 ✓
- §6 图标条由 `operations(OpKind)` 驱动 +「…」溢出(数量+宽度两分支) → Task C1/C2 ✓
- §7.1 筛选(`Filter`,由 `filters` 驱动) → C2§7.2 新增三维体(`GenerateVolume`) → C2§7.3 z值(`PlaneZ`) → E3§7.4 2D底图(`Basemap`) → F2§7.5 3D底图移工具栏 → D2/D3§7.6 导入雷达移设备菜单 → D1 ✓
- §8 单一自由场景 + 经策略渲染 + 2D按类型平面(`Plane2DRenderStrategy`) + 废弃逐ds拖Z/view2DMode → E1/E2/E3 ✓
- §9.1 3D共享底图(透明度可调/隐藏) → D2/D3§9.2 N个2D平面底图(TileBasemap多实例,封装于 Plane2D 策略) → F1/F2 ✓
- §10 默认勾选保留 + 2D信号迁段 → B2/E2/E3 ✓
- §12.10 可扩展性(catalog+策略驱动、加描述符即接入) → A1/A2 + 全程消费方改造 ✓
**Placeholder scan** UI 接线步骤给出真实代码骨架 + 参照锚点(`VtkViewToolbar::iconBtn`、`Glyphs.hpp`、旧 `set2DPlacement` 重摆法),非 TODO。纯逻辑任务A1 描述符/分流、A2 注册表、C1 图标溢出、E2 平面 z含完整测试与实现代码。
**Type consistency** `CategoryDescriptor`/`categoryCatalog`/`SceneKind`/`FilterKind`/`OpKind`/`byDdCode`/`byDsTypeCode`(A1) / `IDatasetRenderStrategy`/`RenderStrategyRegistry`/`VolumeRenderStrategy`/`CurtainRenderStrategy`/`Plane2DRenderStrategy`/策略键 `"volume"/"curtain"/"plane2d"`(A2) / `setCheckedDatasets(dsId→typeId)`(B2) / `visibleIconCount`(C1) / `PlaneZRegistry`(E2,置 `Plane2DRenderStrategy` 内) / `setPlaneZ`/`setBasemapKind`/`setBasemapOpacity`(E3/F2) / `TileBasemap::setOpacity`(D2)·`groundZ`(F1) 跨任务名称一致。
**过渡策略:** A1「加新不删旧」——`CategoryDescriptor`/`categoryCatalog` 与旧 `CategorySpec`/`categoryConfigs` 同 id 同序并存,`splitByCategory` 先切到 catalogPhase C 迁 `CategorySection`/`CategoryAnalysisTab` 到描述符后、Phase F Step4 删旧 `CategoryConfig.hpp`/`DatasetDimension.*`。全程可编译。
**已知待实现期细化(非占位,属合理实现细节):** z值滑块数值范围E3按场景高程量级`Glyph::Map` 若不存在选近义键D3`buildDeviceMenu` 自由函数 vs 成员法D1按实际定义形态`Plane2DRenderStrategy` 构造所需 `Scene&/rw/frame/repo/view` 引用的具体注入形态A2/E2`VtkSceneController` 现有持有)。