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

62 KiB
Raw Blame History

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/QTabWidget2D 渲染改「按类型平面 z」并由 TileBasemap 多实例提供 N 个平面底图3D 底图控件移渲染区工具栏、导入雷达移顶部设备菜单。

Tech Stack: C++17、Qt6Widgets、VTK 9.6.2、GoogleTest/CTest、CMakebuild.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 commitsfeat/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/.cppCategoryDescriptor + SceneKind/FilterKind/OpKind 枚举 + byDdCode/byDsTypeCode 便捷器 + categoryCatalog()(取代 CategoryConfig.hpp/categoryConfigs)。
  • src/controller/DatasetRenderStrategy.hpp/.cppIDatasetRenderStrategy 接口 + 字符串键注册表 + 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/.cppsplitByCategory 改为遍历 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 下加「地图」按钮 + popupsetAnalysisMode2D 6 向禁用。
  • src/app/TileBasemap.hpp/.cppgroundZ/opacity 参数化、Street 纯平、多实例可用。
  • src/controller/VtkSceneController.hpp/.cpp — 主流程改「查描述符 → renderStrategyId → 策略 add/remove + 首勾/全消 onTypeActivated/Deactivated」;移除 view2DMode/setAnalysisMode2D/set2DPlacement 维度耦合。
  • src/app/VtkSceneView.hpp/.cpp — 移除 setAnalysisMode2D 显隐/相机耦合、逐 ds 拖 ZnudgeSelectedMapLinesZ/mapLineZOffset_)。
  • src/app/main.cpp — 接线改造(单列喂数据、导入雷达接 TopBar、3D 底图接工具栏、删 col2D/analysisMode 链;勾选并集直接下发控制器,按描述符路由策略,无 dimOf 分判)。
  • CMakeLists.txttests/CMakeLists.txt — 源/测试登记。

删除(评估后)

  • src/app/panels/columns/Column2DDataset.hpp/.cpp — 合并后退役Phase B 末确认无引用再删)。
  • src/data/repo/CategoryConfig.hppsrc/app/DatasetDimension.hpp/.cpp — 由 CategoryDescriptor/catalog + 策略取代后退役(确认无引用再删)。

阶段总览

  • Phase A:类型抽象基础 —— 描述符目录 categoryCatalogA1+ 渲染策略接口/注册表/3 骨架策略A2。纯逻辑 TDD。
  • Phase B:单列面板骨架(动态显隐 + 空占位 + 去 tab勾选经描述符路由策略。构建后 2D/3D 同列可见。
  • Phase C:响应式图标工具条(由 operations/filters 驱动)+ 筛选折叠 + 新增三维体图标化。
  • Phase D:导入雷达移顶部设备菜单 + 3D 底图移渲染工具栏(含 TileBasemap 透明度参数化)。
  • Phase E:单一自由场景 + Plane2DRenderStrategy 装入平面 z 生命周期(纯逻辑 TDD + 接线)。
  • Phase F2D 平面底图(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.hppsrc/data/repo/CategoryDescriptor.cpp
  • Modify: src/app/DatasetCategory.cpp:5-20splitByCategory 改遍历 catalog 用 classify
  • Modify: CMakeLists.txt(登记 CategoryDescriptor.cpp)、tests/CMakeLists.txt
  • Create: tests/data/test_category_descriptor.cpp

Interfaces:

  • Producesspec §5.1/§5.2namespace geopro::dataenum 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

#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

#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
#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: 改 splitByCategoryDatasetCategory.cpp)遍历 catalog 用 classify
#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 C2CategorySection/CategoryAnalysisTab 迁到吃 CategoryDescriptoroperations/filters 驱动)后,再删 CategoryConfig.hppDatasetCategory.cpp include 加 repo/CategoryDescriptor.hpp。)

  • Step 6: 登记 + 跑测试确认通过CMakeLists.txtCategoryDescriptor.cpptests/CMakeLists.txttest_category_descriptor.cpp(参照 test_dataset_category.cpp)。build.bat test;预期新测试全 PASS。

  • Step 7: 提交

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.hppsrc/controller/DatasetRenderStrategy.cpp
  • Modify: CMakeLists.txt
  • Test: 注册表解析逻辑随 Phase E 接入后由集成验证;本任务以编译 + 一个最小注册/解析单元测试佐证。
  • Create: tests/controller/test_render_strategy_registry.cpp

Interfaces:

  • Producesspec §5.4namespace geopro::controllerclass 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

#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

#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 内迁实现):
// 同文件内(或 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.txtDatasetRenderStrategy.cpptests/CMakeLists.txttest_render_strategy_registry.cppbuild.bat test;预期 PASS。

  • Step 6: 提交

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-97setBuckets)、构造函数(加占位)

Interfaces:

  • Consumes: categoryCatalog()(含 trajectory 段Task A1
  • Produces: 段在其 bucket 为空时 hide()、非空 show();全空时显示占位 QLabelsetBuckets 现在也分发 trajectory 段2D

说明:保持类名 CategoryAnalysisTab 不变避免大范围改名风险仅扩展职责为「统一单列」。Phase B 末在文档注释标注其新职责。

  • Step 1: 构造函数加占位 label — 在 CategoryAnalysisTab.cpp 构造函数 scroll->setWidget(content); 之前,于 content 布局内(col 之外、outer 层)添加居中占位。改为在 outer 上叠一个占位 label并存指针

CategoryAnalysisTab.hpp private 区加:

QWidget* placeholder_ = nullptr;  // 全空时显示的占位提示
void updatePlaceholderAndVisibility();  // 据各段数据有无 显隐段 + 占位

CategoryAnalysisTab.cpp 构造函数末尾(scroll->setWidget(content); 之后):

    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 加:
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 的可勾选行存在):
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 注入):
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 区,追加:
        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: 提交

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.cppdrawer 接线:6701388-14151710-1713544-566analysisModeChanged 链)

Interfaces:

  • Consumes: CategoryAnalysisTab*(统一列)。

  • Produces: ColumnDrawer 不再有 col2D() / analysisModeChanged;只暴露 analysisTab()。main 把 2D 数据并入 splitByCategory 流。

  • Step 1: ColumnDrawer 去 QTabWidget/Column2DDatasetColumnDrawer.hpp:删 Column2DDataset* col2D_col2D()analysisModeChangedbody_ 改为直接持 CategoryAnalysisTab*(不再是 QTabWidget.cpp 构造:把 analysisTab_ 直接作为 body 内容(去掉 tab 添加 col2D 的代码、去掉 tab 切换发 analysisModeChanged 的 connect折叠开关逻辑不变折叠隐藏 bodyresizeEvent 里两 tab 平分逻辑删除(只剩单列)。

  • Step 2: main.cpp 删 col2D 接线 — 删除 main.cpp:670drawer->col2D()->setDatasets(...);删除 1388-1415basemapChanged/view2DModeChanged/customZChanged/checkedDatasetsChanged(col2D) 接线(其去向在 Phase D/E/F 重接);删除 544-566selectedDatasetsChanged/setSelectedMapLines 经 col2D 的链2D 选中改由统一段 datasetSelected 承担,已存在);删除 analysisModeChangedsetAnalysisMode2D 的接线。

  • 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 4DatasetDimension.* 待 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

// 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

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 本步暂转调原 add2DDatasetAsyncPhase E/F 改平面 z+底图)。旧 setChecked/setChecked2DDatasets/set2DPlacement 公有入口删除或内联进策略。)

  • Step 5: 构建 + 手动验收build.bat app。验收:左侧只剩一个单列(无 tab勾 3D 反演/体 → 渲染帘面/体(经 Curtain/Volume 策略);勾轨迹 → 渲染足迹折线(经 Plane2D 策略,落点暂沿用旧 placementPhase E 改平面);无崩溃。

  • Step 6: 提交

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: 提交

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.txtCMakeLists.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

#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(函数 + 组件声明)

#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.cppvisibleIconCount(先只够过测试)
#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: 登记到 CMakeCMakeLists.txtSectionIconBar.cpp 到 app 源;tests/CMakeLists.txttest_section_icon_bar.cpp(参照 test_dataset_category.cpp 登记法)。

  • Step 7: 实现组件 relayout/setActions/resizeEventUI构建验证

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_ 隐藏。
}

(具体按钮构造参照 VtkViewToolbariconBtnGlyphs.hpp::makeGlyphpopup 用 QMenu/QWidgetAction。)

  • Step 8: 提交
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::CategoryDescriptoroperations/filters/id/title)、SectionIconBar

  • Produces: CategorySection(const CategoryDescriptor& desc, dict, parent);段头右侧 SectionIconBardesc.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

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_.filtersDateRange→建 DateRangeEditArrayType→建 arrayCombo_passesFilters/rebuildList 据建出的控件判断(无该控件即不筛该维度)。

  • Step 3: hpp 改造CategorySection.hpp:构造签名改 const geopro::data::CategoryDescriptor&;成员 desc_;加 SectionIconBar* iconBar_=nullptr; QWidget* filterRow_=nullptr;;信号加 zSliderRequested(QString)/basemapPopupRequested(QString);移除 radarImportRequestedCategoryAnalysisTab.cpp 构造改 for (const auto& desc : geopro::data::categoryCatalog()) new CategorySection(desc, dict, content);sections_[desc.id]

  • Step 4: 构建 + 手动验收build.bat app。验收:反演段头显示 [新增三维体, 筛选] 图标;轨迹段显示 [z值, 筛选, 底图];点筛选展开/收起筛选行;缩窄左栏宽度 → 右侧图标折进「…」、放宽弹回。

  • Step 5: 提交

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.cppbuildDeviceMenu)、src/app/TopBar.hpp(新信号)
  • Modify: src/app/main.cpp:1056-1059(接线改源)
  • Modify: CategorySection.hpp/.cppCategoryAnalysisTab.hpp/.cpp(移除 radarImportRequested 转发,若 Task C2 未删尽)

Interfaces:

  • Produces: TopBar 新信号 void radarImportRequested(bool impulse);;设备菜单含「导入雷达测线 → 规范化/Impulse」。

  • Step 1: TopBar 设备菜单加项 — 在 buildDeviceMenu(this)(定位其定义 TU内追加子菜单

    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-1059analysisTab.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: 提交

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.hppsrc/app/TileBasemap.cpp:59kTerrainOpacity)、buildFlat/buildWarped opacity 行(~455、~569

Interfaces:

  • Produces: TileBasemap 新增 void setOpacity(double o);(默认 0.5);现有 show(Kind)/hide() 不变。buildFlat/buildWarped 用成员 opacity_ 而非常量。

  • Step 1: 加成员 + setterTileBasemap.hpp private 加 double opacity_ = 0.5;public 加 void setOpacity(double o);.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~455buildWarped~569SetOpacity(kTerrainOpacity)SetOpacity(opacity_);删除或保留 kTerrainOpacity 常量(改为默认初值来源,建议删常量、成员默认 0.5)。

  • Step 3: 构建build.bat app 通过行为暂不变opacity 默认 0.5 略比旧 0.55 透)。

  • Step 4: 提交

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-58Gear 之后)
  • Modify: src/app/main.cpp:1388-1394basemap 接线改到工具栏)

Interfaces:

  • Produces: VtkViewToolbar 新信号 void basemapKindChanged(int kind); void basemapOpacityChanged(double o);kind: 0 天地图 / 1 无)。

  • Step 1: Gear 下加地图按钮 + popupVtkViewToolbar.cppconnect(iconBtn(Glyph::Gear,...)) 之后、sep(); 之前,加:

    {
        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-1394col2D basemapChanged 接线Task B2 已删)在此重建为工具栏信号:
    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: 提交

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-439setAnalysisMode2D)、482-539pickMapLineAt/nudgeSelectedMapLinesZ
  • Modify: src/controller/VtkSceneController.*(残留的 onAnalysisModeChanged/view2DMode/placement2dMode_set2DPlacement 公有入口已在 B2 随策略切换删除,此处清剩余私有状态)
  • Modify: src/app/main.cpp(拖 Z 浮层 498-505544-557

Interfaces:

  • Produces: 场景恒自由透视;删除 setAnalysisMode2DnudgeSelectedMapLinesZmapLineZOffset_、拖 Z 浮层、残留 view2DMode/placement2dMode_ 状态。

  • Step 1: 删 VtkSceneView 维度显隐/相机 — 移除 setAnalysisMode2D(420-439) 的按维度翻可见 + 相机锁定;移除 pickMapLineAt/nudgeSelectedMapLinesZ/mapLineZOffset_482-539 及成员)。mapLineDs_ 是否保留视 Task E2 需要(仍需识别足迹 actor 归属 → 保留)。

  • Step 2: 删 controller analysisMode/view2DMode 残留 — 移除 onAnalysisModeChangedplacement2dMode_/customZ2d_/placementZ()/view2DMode 等残留状态(其渲染职责已在 B2 迁入 Plane2DRenderStrategy)。

  • Step 3: 删 main 拖 Z 浮层接线 — 移除 main.cpp:498-505(高程读数浮层)、544-557pick/nudge 接线)。

  • Step 4: 构建 + 手动验收build.bat app。验收:无 tab 切换后场景恒自由透视2D/3D 同时可见可旋转;无拖 Z 浮层;无崩溃/悬挂引用。

  • Step 5: 提交

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.txtCMakeLists.txt
  • Modify: src/controller/DatasetRenderStrategy.hpp/.cppPlane2DRenderStrategyPlaneZRegistryadd/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

#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

#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: 登记 CMaketests/CMakeLists.txttest_plane_z_registry.cppPlaneZRegistry.hpp 为纯头,无需加源)。

  • Step 6: Plane2DRenderStrategy 装入 PlaneZRegistry — 让 A2 建的 Plane2DRenderStrategyPlaneZRegistry 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: 提交

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.cppz值 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 米,默认取当前 planeZvalueChangedemit planeZChanged(typeIdOfSpec, v)typeId = descriptor.id

  • Step 2: 转发链CategoryAnalysisTab 转发 planeZChangedmain 接到 → sceneCtrl->setPlaneZ(typeId, z)

  • Step 3: 落到策略VtkSceneController::setPlaneZPlane2DRenderStrategyregistry_.get("plane2d") 下转型,或控制器持其指针)调 setPlaneZ(typeId,z):内部 planeReg_.setPlaneZ(typeId,z) 后,对该类型已勾选足迹 removeDataset+addMapLine(...,z) 重摆(参照旧 set2DPlacement 重摆法)。

  • Step 4: 构建 + 手动验收build.bat app。验收:拖 z值滑块 → 该类型整块平面(含其上全部足迹)整体升降。

  • Step 5: 提交

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/.cppkGroundZ → 成员 groundZ_;构造可传初值)

Interfaces:

  • Produces: TileBasemap 构造增可选参 double groundZ = 0.0;内部所有 kGroundZgroundZ_StreetbuildFlat(已是纯平矢量)不变。

  • Step 1: groundZ 成员化TileBasemap.hppdouble groundZ_; + 构造形参 double groundZ = 0.0.cppplaceActor/buildFlat/buildWarped/Z-fighting 偏移里出现的 kGroundZgroundZ_gz = groundZ_ + (z-kMinZoom)*kZEps)。

  • Step 2: 确认多实例无共享态 — review TileBasemap 成员:nam_/placed_/desired_/demCache_/texCache_/observer_ 均 per-instancetileKey static 纯函数 → 多实例安全。无需改。

  • Step 3: 构建build.bat app3D 底图仍 groundZ=0行为不变

  • Step 4: 提交

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/.cppPlane2DRenderStrategy 加底图实例管理)
  • Modify: src/controller/VtkSceneController.*src/app/main.cppCategorySection.*CategoryAnalysisTab.*

Interfaces:

  • Consumes: PlaneZRegistry(已在 strategy 内)、TileBasemap多实例F1Scene&/vtkRenderWindow*/GeoLocalFramedataRadiusProviderstrategy 构造时注入)。

  • Produces: Plane2DRenderStrategystd::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 持底图实例Plane2DRenderStrategystd::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)CategorySectionbasemapKindChanged(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: 提交

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::iconBtnGlyphs.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 若不存在选近义键D3buildDeviceMenu 自由函数 vs 成员法D1按实际定义形态Plane2DRenderStrategy 构造所需 Scene&/rw/frame/repo/view 引用的具体注入形态A2/E2VtkSceneController 现有持有)。