# 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、Qt6(Widgets)、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 F)N 个 `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)` 建筛选器(`FilterKind→UI` 映射一处);筛选折叠;移除导入雷达。 - `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` — 接线改造(单列喂数据、导入雷达接 TopBar、3D 底图接工具栏、删 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:** - Produces(spec §5.1/§5.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& categoryCatalog();`。 - `splitByCategory(rows)` 返回 `CategoryBuckets`(与 catalog 同序同长),每行归入首个 `classify(row)==true` 的段。 - [ ] **Step 1: 写失败测试** — `tests/data/test_category_descriptor.cpp`: ```cpp #include #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 #include #include #include #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 classify; // 轴1 数据来源/分类 std::vector filters; // 轴2 筛选器 std::vector operations; // 轴3 段头图标操作 std::string renderStrategyId; // 轴4 渲染策略键 }; // classify 便捷构造器(常见按 ddCode / dsTypeCode 接入) std::function byDdCode(std::initializer_list codes); std::function byDsTypeCode(std::initializer_list codes); const std::vector& categoryCatalog(); } // namespace geopro::data ``` - [ ] **Step 4: 写 `CategoryDescriptor.cpp`**: ```cpp #include "repo/CategoryDescriptor.hpp" #include namespace geopro::data { std::function byDdCode(std::initializer_list codes) { std::vector cs(codes); return [cs](const DsRow& r) { for (const auto& c : cs) if (r.ddCode == c) return true; return false; }; } std::function byDsTypeCode(std::initializer_list codes) { std::vector cs(codes); return [cs](const DsRow& r) { for (const auto& c : cs) if (r.dsTypeCode == c) return true; return false; }; } const std::vector& categoryCatalog() { static const std::vector 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& 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 C(Task 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:** - Produces(spec §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* get(const std::string& id) const; }`。 - 3 骨架实现:`VolumeRenderStrategy` / `CurtainRenderStrategy` / `Plane2DRenderStrategy`(本任务仅建类与注册;`add/remove` 内部实现 Phase B(3D 包现有分支)与 Phase E/F(2D 平面+底图)填充)。 - [ ] **Step 1: 写失败测试** — `tests/controller/test_render_strategy_registry.cpp`: ```cpp #include #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()); auto* s = reg.get("fake"); ASSERT_NE(s, nullptr); s->add("trajectory", "d1"); EXPECT_EQ(static_cast(s)->added, 1); EXPECT_EQ(reg.get("missing"), nullptr); } ``` - [ ] **Step 2: 跑测试确认失败** — `build.bat test`;预期编译失败(头文件不存在)。 - [ ] **Step 3: 写 `DatasetRenderStrategy.hpp`**: ```cpp #pragma once #include #include #include 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 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> 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 E(Plane2D 装 PlaneZRegistry)与 Phase B(3D 两策略包现有分支)填;本步只需类存在并可注册,`.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 `。) - [ ] **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> 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>& idType) { std::map 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`、`typeActive_`=`std::map` 为新成员。`VolumeRenderStrategy`/`CurtainRenderStrategy::add` 内部转调原 `addDatasetAsync` 的体/帘面分支;`Plane2DRenderStrategy::add` 本步暂转调原 `add2DDatasetAsync`(Phase E/F 改平面 z+底图)。旧 `setChecked`/`setChecked2DDatasets`/`set2DPlacement` 公有入口删除或内联进策略。) - [ ] **Step 5: 构建 + 手动验收** — `build.bat app`。验收:左侧只剩一个单列(无 tab);勾 3D 反演/体 → 渲染帘面/体(经 Curtain/Volume 策略);勾轨迹 → 渲染足迹折线(经 Plane2D 策略,落点暂沿用旧 placement,Phase 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 #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 #include #include #include 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 onClick; // 直接动作;为空则用 popupBuilder std::function popupBuilder; // 弹 popup(z值/底图/筛选用) }; class SectionIconBar : public QWidget { Q_OBJECT public: explicit SectionIconBar(QWidget* parent = nullptr); void setActions(const std::vector& actions); // 重建按钮 void setMaxIcons(int n) { maxIcons_ = n; relayout(); } protected: void resizeEvent(QResizeEvent* e) override; private: void relayout(); // 按当前宽度算可见数,多余进「…」菜单 std::vector actions_; std::vector 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 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& a) { actions_ = a; // 清旧按钮、按 actions_ 建 QToolButton(autoRaise + 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(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 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 ` `。`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 #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 #include #include 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 members; }; std::map 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 F:2D 平面底图(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> bms_;`(按 typeId);新方法 `setBasemapKind(typeId,kind)` / `setBasemapOpacity(typeId,o)`;`onTypeActivated/Deactivated` 建/销毁底图;`setPlaneZ` 同步重建底图于新 z。controller 转发 `setBasemapKind/Opacity`。 - [ ] **Step 1: strategy 持底图实例** — `Plane2DRenderStrategy` 内 `std::map> bms_;`。`onTypeActivated(typeId)`(该类型首勾,平面已建):`bms_[typeId] = std::make_unique(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` 先切到 catalog;Phase 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` 现有持有)。