geopro/docs/superpowers/plans/2026-06-16-vtk-3d-three-col...

41 KiB
Raw Permalink 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: 把 VTK 工作台的"旧二维/三维切换 + 三浮层"过渡态,重构成需求 A1 的「三个子列表栏」(三维数据集 / 二维数据集 / 三维分析内嵌在唯一的中央「VTK视图」左侧并接通已有渲染/切片能力;同时给 VTK视图 + 数据详情 加全屏按钮。

Architecture: 三栏抽成独立 widgetsrc/app/panels/columns/),各自只发信号、不依赖控制器;ColumnDrawer 作为 vtkWidget 在 HBox 中的左侧兄弟控件(非 GL 浮层,规避原生 GL 浮层 z 序/圆角伪影),可折叠。main.cpp::buildWorkbench 删三浮层+分段切换,改挂三栏并把信号接到既有 VtkSceneController/InteractionManager/DatasetDetailController。数据集列表由 WorkbenchNavControllerDsRow、按 I3dSceneRepository::dimensionOf 过滤后分发到三栏。

Tech Stack: C++17, Qt6 Widgets, VTK9, Qt-ADS。构建 build.bat(见"构建/验证铁律"),测试 GoogleTest/CTest。


⚠️ 构建/验证铁律(每个 Task 都遵守)

  • 构建:从 Git Bash 调 cmd.exe /c "chcp 65001 >nul && cd /d D:\Git\lanbingtech\geopro && .\build.bat test"。命令:app/test/rebuild(全量)/configure
  • ninja 偶发漏编 → 改头/布局后用 build.bat rebuild;验 exe 新鲜:stat -c '%y' build/release/src/app/geopro_desktop.exe
  • 切勿 rm -rf build/releasevcpkg 重编依赖极慢)。
  • Claude 工具跑 build 偶被 Start-Process 钩子劫持静默不跑所有"用户实测"步骤必须由用户在其终端 build.bat rebuild 跑并目视。Claude 不能 GUI 测 VTK 交互。
  • 纯逻辑(如维度过滤)抽函数 + GoogleTest 单测UI/交互靠 build 绿 + 用户实测清单。

测试方式约定(本计划特例,覆盖默认 TDD-everywhere

  • 逻辑步骤(标 [逻辑]):先写失败测试 → 跑红 → 实现 → 跑绿 → 提交。
  • UI 步骤(标 [UI]):实现 → build.bat rebuild 编绿 → 用户实测清单 → 提交。Claude 不声称"已验证"交互,只验证编译通过 + 代码读校。

File Structure

新建:

  • src/app/panels/columns/ColumnDrawer.hpp/.cpp — 抽屉容器QTabWidget 三 tab + 折叠开关)。
  • src/app/panels/columns/Column3DDataset.hpp/.cpp — 三维数据集栏4 工具条栏位 + 3D 数据集树)。
  • src/app/panels/columns/Column2DDataset.hpp/.cpp — 二维数据集栏(地图/2D视图控件 + 2D 数据集树)。
  • src/app/panels/columns/Column3DAnalysis.hpp/.cpp — 三维分析栏(对象→三维体→切片 树 + 两个右键菜单)。
  • src/app/DatasetDimension.hpp/.cpp — 纯函数 splitByDimension(...)(可单测)。
  • tests/app/test_dataset_dimension.cpp — 维度过滤单测。

修改:

  • src/app/main.cppbuildWorkbench:删三浮层(393-556)/分段切换(380-389,832-843)/showLayerPanel(804-827)/相关 connect改挂 ColumnDrawer接信号rename vtkDockbump dockState 版本;接维度过滤;全屏按钮。
  • src/app/Glyphs.hpp/.cpp — 加 Glyph::Fullscreen + SVG。
  • src/app/CMakeLists.txt — 加新源文件。
  • tests/CMakeLists.txt(或对应)— 加 test_dataset_dimension。

Task 1: 加 Fullscreen 图标 [UI]

Files:

  • Modify: src/app/Glyphs.hpp:15-35Glyph 枚举)

  • Modify: src/app/Glyphs.cppSVG path 映射,参照现有 case

  • Step 1: 在 Glyph 枚举加 Fullscreen

src/app/Glyphs.hpp,在 Collapse, 之后加:

    Collapse,  // 折叠(双箭头)
    Fullscreen,  // 全屏 / 最大化
  • Step 2: 在 Glyphs.cpp 的 svg 映射加 case

找到 makeGlyph/svg path 的 switch(参照 case Glyph::Collapse:),加:

    case Glyph::Fullscreen:
        return QStringLiteral("<path d='M8 3H5a2 2 0 0 0-2 2v3m13-5h3a2 2 0 0 1 2 2v3"
                              "M21 16v3a2 2 0 0 1-2 2h-3M8 21H5a2 2 0 0 1-2-2v-3'/>");

(若已有 restore/缩小语义图标可复用,但需一个独立 Fullscreen 项。)

  • Step 3: 编译

Run: cmd.exe /c "chcp 65001 >nul && cd /d D:\Git\lanbingtech\geopro && .\build.bat app" Expected: 编译通过exe 重新生成)。

  • Step 4: 提交
git add src/app/Glyphs.hpp src/app/Glyphs.cpp
git commit -m "feat(vtk): 加 Glyph::Fullscreen 图标(三栏重构全屏按钮用)"

Task 2: 维度过滤纯函数 [逻辑]

把"DsRow 列表 → 按维度分三组"抽成可单测纯函数。dimensionOf 已在 I3dSceneRepository(接口),这里做的是列表分流

Files:

  • Create: src/app/DatasetDimension.hpp

  • Create: src/app/DatasetDimension.cpp

  • Test: tests/app/test_dataset_dimension.cpp

  • Modify: src/app/CMakeLists.txttests/CMakeLists.txt

  • Step 1: 写失败测试

tests/app/test_dataset_dimension.cpp

#include <gtest/gtest.h>
#include "app/DatasetDimension.hpp"
#include "data/repo/RepoTypes.hpp"

using geopro::data::DsRow;
using geopro::app::splitByDimension;
using geopro::app::DimBuckets;

static DsRow row(const char* id, const char* ddCode) {
    DsRow r; r.id = id; r.ddCode = ddCode; return r;
}

TEST(DatasetDimension, SplitsByDdCode) {
    std::vector<DsRow> in{
        row("a", "dd_section"),          // 3D
        row("b", "dd_voxel"),            // 3D
        row("c", "dd_trajectory_data"),  // 2D
        row("d", "dd_slice"),            // Analysis
        row("e", "dd_unknownxyz"),       // Other → 不入任何栏
    };
    DimBuckets b = splitByDimension(in);
    ASSERT_EQ(b.dim3D.size(), 2u);
    EXPECT_EQ(b.dim3D[0].id, "a");
    EXPECT_EQ(b.dim3D[1].id, "b");
    ASSERT_EQ(b.dim2D.size(), 1u);
    EXPECT_EQ(b.dim2D[0].id, "c");
    ASSERT_EQ(b.analysis.size(), 1u);
    EXPECT_EQ(b.analysis[0].id, "d");
}

TEST(DatasetDimension, EmptyInput) {
    DimBuckets b = splitByDimension({});
    EXPECT_TRUE(b.dim3D.empty());
    EXPECT_TRUE(b.dim2D.empty());
    EXPECT_TRUE(b.analysis.empty());
}
  • Step 2: 跑测试确认失败

Run: cmd.exe /c "chcp 65001 >nul && cd /d D:\Git\lanbingtech\geopro && .\build.bat test" Expected: FAILDatasetDimension.hpp 不存在 / 链接失败)。

  • Step 3: 写实现

src/app/DatasetDimension.hpp

#pragma once
#include <vector>
#include "data/repo/RepoTypes.hpp"

namespace geopro::app {

struct DimBuckets {
    std::vector<geopro::data::DsRow> dim3D;
    std::vector<geopro::data::DsRow> dim2D;
    std::vector<geopro::data::DsRow> analysis;
};

// 按 ddCode 把 ds 分流到 三维数据集 / 二维数据集 / 三维分析 三栏。
// Other 维度不入任何栏(保留 parentId 顺序,调用方可直接喂 populateDatasetListDimBuckets splitByDimension(const std::vector<geopro::data::DsRow>& rows);

}  // namespace geopro::app

src/app/DatasetDimension.cpp

#include "app/DatasetDimension.hpp"

namespace geopro::app {

namespace {
// 与 LocalSample3dRepository::dimensionOf 同一映射spec §6.1)。
// 抽到此处以便纯函数单测;将来后端返 dimension 字段时此函数改读字段即可。
enum class Dim { D3, D2, Analysis, Other };
Dim dimOf(const std::string& c) {
    if (c == "dd_voxel" || c == "dd_Structual3D" || c == "dd_Property3D" ||
        c == "dd_section" || c == "dd_inversion_data")
        return Dim::D3;
    if (c == "dd_slice") return Dim::Analysis;
    if (c == "dd_trajectory_data") return Dim::D2;
    return Dim::Other;
}
}  // namespace

DimBuckets splitByDimension(const std::vector<geopro::data::DsRow>& rows) {
    DimBuckets b;
    for (const auto& r : rows) {
        switch (dimOf(r.ddCode)) {
            case Dim::D3: b.dim3D.push_back(r); break;
            case Dim::D2: b.dim2D.push_back(r); break;
            case Dim::Analysis: b.analysis.push_back(r); break;
            case Dim::Other: break;
        }
    }
    return b;
}

}  // namespace geopro::app

注:dimensionOf 同时存在于 LocalSample3dRepository(渲染编排用)。此处复制映射是有意——纯函数便于单测、且与"将来后端返 dimension 字段"解耦。后续若收敛为单一真源,再让本函数调用注入的 repo。落地时若 reviewer 要求单一真源,可改签名 splitByDimension(rows, const I3dSceneRepository&),本期按纯函数。

  • Step 4: 注册到 CMake

src/app/CMakeLists.txt:把 DatasetDimension.cpp 加入 app 目标源列表(仿照同目录 .cpp 的加法)。 tests/CMakeLists.txt(或 tests/apptest_dataset_dimension.cpp 加入测试目标(仿照 test_3d_repo 的注册)。

  • Step 5: 跑测试确认通过

Run: cmd.exe /c "chcp 65001 >nul && cd /d D:\Git\lanbingtech\geopro && .\build.bat test" Expected: DatasetDimension.* 2 项 PASS总数 ≥ 223/223。

  • Step 6: 提交
git add src/app/DatasetDimension.hpp src/app/DatasetDimension.cpp tests/app/test_dataset_dimension.cpp src/app/CMakeLists.txt tests/CMakeLists.txt
git commit -m "feat(vtk): 维度过滤纯函数 splitByDimension + 单测"

Task 3: 三维数据集栏 widget [UI]

独立 widget4 工具条栏位(坐标轴设置 / 水平垂直比例 / 快捷视图 / 缩放)+ 数据集树。只发信号。控件创建可搬运 main.cpp:433-516axisBar的 combo/slider/button 构造与样式,重排成 4 分组(参照原型 docs/superpowers/mockups/2026-06-16-three-column-layout.htmltoolbar3D)。

Files:

  • Create: src/app/panels/columns/Column3DDataset.hpp/.cpp

  • Modify: src/app/CMakeLists.txt

  • Step 1: 写头文件(信号 API

src/app/panels/columns/Column3DDataset.hpp

#pragma once
#include <QWidget>
#include <QStringList>
#include <vector>
#include "controller/I3dSceneView.hpp"   // AxesMode/AxesUnit/ViewDir
#include "data/repo/RepoTypes.hpp"

class QTreeWidget;

namespace geopro::app {

// 三维数据集栏:坐标轴设置 + 水平/垂直比例 + 快捷视图 + 缩放 + 3D 数据集列表。
class Column3DDataset : public QWidget {
    Q_OBJECT
public:
    explicit Column3DDataset(QWidget* parent = nullptr);
    // 用 3D 维度的 ds 填充列表(调用 populateDatasetList    void setDatasets(const std::vector<geopro::data::DsRow>& rows);

signals:
    void axesModeChanged(geopro::controller::AxesMode mode);
    void axesUnitChanged(geopro::controller::AxesUnit unit);
    void verticalExaggerationChanged(double ve);
    void viewRequested(geopro::controller::ViewDir dir);
    void zoomInRequested();
    void zoomOutRequested();
    void fitRequested();
    void oPointClicked();   // O点位置按钮本期弹框留 stub
    void fontClicked();     // 字体按钮(本期 stub
    void checkedDatasetsChanged(const QStringList& dsIds);  // 列表勾选变化

private:
    QTreeWidget* list_ = nullptr;
};

}  // namespace geopro::app
  • Step 2: 写实现(构造 4 分组 + 列表)

src/app/panels/columns/Column3DDataset.cpp:用 QVBoxLayout 堆 4 个分组 QGroupBox/QFrame(标题 + 表单行)+ QTreeWidget 列表。控件构造照搬 main.cpp:464-500axesModeCombo/axesUnitCombo/veSlider/btnFront..btnFit样式用 applyTokenizedStyleSheet 照搬 main.cpp:437-457。各控件 connect 到本类 emit ...。要点(完整骨架):

#include "app/panels/columns/Column3DDataset.hpp"
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QFormLayout>
#include <QComboBox>
#include <QSlider>
#include <QPushButton>
#include <QLabel>
#include <QTreeWidget>
#include "app/Theme.hpp"
#include "app/panels/DatasetListPanel.hpp"   // populateDatasetList

using geopro::controller::AxesMode;
using geopro::controller::AxesUnit;
using geopro::controller::ViewDir;

namespace geopro::app {

Column3DDataset::Column3DDataset(QWidget* parent) : QWidget(parent) {
    auto* root = new QVBoxLayout(this);
    root->setContentsMargins(space::kMd, space::kMd, space::kMd, space::kMd);
    root->setSpacing(space::kMd);

    // —— 坐标轴设置 组:显示方式▾ / O点位置(按钮) / 刻度▾ / 字体(按钮) ——
    {
        auto* form = new QFormLayout();
        auto* mode = new QComboBox();
        mode->addItem(QStringLiteral("标准"), static_cast<int>(AxesMode::Standard));
        mode->addItem(QStringLiteral("三维立体"), static_cast<int>(AxesMode::Stereo));
        mode->addItem(QStringLiteral("不显示"), static_cast<int>(AxesMode::None));
        connect(mode, qOverload<int>(&QComboBox::currentIndexChanged), this,
                [this, mode](int){ emit axesModeChanged(static_cast<AxesMode>(mode->currentData().toInt())); });
        auto* oPoint = new QPushButton(QStringLiteral("设置…"));
        connect(oPoint, &QPushButton::clicked, this, &Column3DDataset::oPointClicked);
        auto* unit = new QComboBox();
        unit->addItem(QStringLiteral("无刻度"), static_cast<int>(AxesUnit::None));
        unit->addItem(QStringLiteral("米"), static_cast<int>(AxesUnit::Meter));
        unit->addItem(QStringLiteral("英尺"), static_cast<int>(AxesUnit::Feet));
        unit->addItem(QStringLiteral("经纬度"), static_cast<int>(AxesUnit::LatLon));
        unit->setCurrentIndex(1);
        connect(unit, qOverload<int>(&QComboBox::currentIndexChanged), this,
                [this, unit](int){ emit axesUnitChanged(static_cast<AxesUnit>(unit->currentData().toInt())); });
        auto* font = new QPushButton(QStringLiteral("设置…"));
        connect(font, &QPushButton::clicked, this, &Column3DDataset::fontClicked);
        form->addRow(QStringLiteral("显示方式"), mode);
        form->addRow(QStringLiteral("O点位置"), oPoint);
        form->addRow(QStringLiteral("刻度"), unit);
        form->addRow(QStringLiteral("字体"), font);
        root->addWidget(new QLabel(QStringLiteral("坐标轴设置")));
        root->addLayout(form);
    }
    // —— 水平/垂直比例 组:单个滑块 + 数值 ——
    {
        auto* row = new QHBoxLayout();
        auto* slider = new QSlider(Qt::Horizontal);
        slider->setMinimum(1); slider->setMaximum(10); slider->setValue(2);
        auto* val = new QLabel(QStringLiteral("2.0×"));
        connect(slider, &QSlider::valueChanged, this, [this, val](int v){
            val->setText(QStringLiteral("%1.0×").arg(v));
            emit verticalExaggerationChanged(static_cast<double>(v));
        });
        row->addWidget(slider, 1); row->addWidget(val);
        root->addWidget(new QLabel(QStringLiteral("水平/垂直比例")));
        root->addLayout(row);
    }
    // —— 快捷视图 组:前/后/左/右/上/下 ——
    {
        auto* row = new QHBoxLayout();
        struct V { const char* t; ViewDir d; };
        for (V v : { V{"前",ViewDir::Front}, V{"后",ViewDir::Back}, V{"左",ViewDir::Left},
                     V{"右",ViewDir::Right}, V{"上",ViewDir::Top}, V{"下",ViewDir::Bottom} }) {
            auto* b = new QPushButton(QString::fromUtf8(v.t));
            ViewDir d = v.d;
            connect(b, &QPushButton::clicked, this, [this, d]{ emit viewRequested(d); });
            row->addWidget(b);
        }
        root->addWidget(new QLabel(QStringLiteral("快捷视图")));
        root->addLayout(row);
    }
    // —— 缩放 组:放大/缩小/适配 ——
    {
        auto* row = new QHBoxLayout();
        auto* in = new QPushButton(QStringLiteral("放大"));
        auto* out = new QPushButton(QStringLiteral("缩小"));
        auto* fit = new QPushButton(QStringLiteral("适配"));
        connect(in, &QPushButton::clicked, this, &Column3DDataset::zoomInRequested);
        connect(out, &QPushButton::clicked, this, &Column3DDataset::zoomOutRequested);
        connect(fit, &QPushButton::clicked, this, &Column3DDataset::fitRequested);
        row->addWidget(in); row->addWidget(out); row->addWidget(fit);
        root->addWidget(new QLabel(QStringLiteral("缩放")));
        root->addLayout(row);
    }
    // —— 数据集列表3D 维度)——
    list_ = new QTreeWidget();
    list_->setHeaderHidden(true);
    list_->setRootIsDecorated(true);
    applyDatasetCardDelegate(list_);
    connect(list_, &QTreeWidget::itemChanged, this, [this](QTreeWidgetItem*, int){
        QStringList ids;
        for (QTreeWidgetItemIterator it(list_); *it; ++it)
            if ((*it)->checkState(0) == Qt::Checked)
                ids << (*it)->data(0, /*kDsIdRole*/ Qt::UserRole + 1).toString();
        emit checkedDatasetsChanged(ids);
    });
    root->addWidget(list_, 1);
}

void Column3DDataset::setDatasets(const std::vector<geopro::data::DsRow>& rows) {
    populateDatasetList(list_, rows, /*append=*/false);
    // 列表项需可勾选populateDatasetList 后给每项加 Qt::ItemIsUserCheckable + Unchecked。
    for (QTreeWidgetItemIterator it(list_); *it; ++it) {
        (*it)->setFlags((*it)->flags() | Qt::ItemIsUserCheckable);
        if ((*it)->checkState(0) == Qt::Unchecked || (*it)->checkState(0) == Qt::Checked) {}
        else (*it)->setCheckState(0, Qt::Unchecked);
    }
}

}  // namespace geopro::app

注 1kDsIdRole 的真实值见 src/app/panels/DatasetListPanel.cppQt::UserRole+? )。落地时 include 该常量或用其公开定义,勿硬编码 UserRole+1——读 DatasetListPanel.hpp/.cpp 取真实 role 常量。 注 2populateDatasetList 生成的项默认不可勾选;本栏需勾选渲染,故 setDatasets 后补 ItemIsUserCheckable + Unchecked(见上)。 注 3分组标题/表单样式照原型;可用 applyTokenizedStyleSheet 套深色令牌(搬 main.cpp:437-457)。

  • Step 3: 注册 CMake + 编译

src/app/CMakeLists.txtpanels/columns/Column3DDataset.cpp。 Run: cmd.exe /c "chcp 65001 >nul && cd /d D:\Git\lanbingtech\geopro && .\build.bat app" Expected: 编译通过。

  • Step 4: 提交
git add src/app/panels/columns/Column3DDataset.hpp src/app/panels/columns/Column3DDataset.cpp src/app/CMakeLists.txt
git commit -m "feat(vtk): 三维数据集栏 widget(4工具条栏位+3D数据集列表,只发信号)"

Task 4: 二维数据集栏 widget [UI]

Files:

  • Create: src/app/panels/columns/Column2DDataset.hpp/.cpp

  • Modify: src/app/CMakeLists.txt

  • Step 1: 写头文件

Column2DDataset.hpp

#pragma once
#include <QWidget>
#include <QStringList>
#include <vector>
#include "data/repo/RepoTypes.hpp"
class QTreeWidget;
namespace geopro::app {
class Column2DDataset : public QWidget {
    Q_OBJECT
public:
    explicit Column2DDataset(QWidget* parent = nullptr);
    void setDatasets(const std::vector<geopro::data::DsRow>& rows);
signals:
    void basemapChanged(int index);      // 0 天地图 / 1 Google / 2 隐藏
    void view2DModeChanged(int index);   // 0 关闭 /1 Z=0 /2 顶部 /3 底部 /4 自定义
    void customZChanged(double z);        // 世界绝对高程(米),向上为正
    void checkedDatasetsChanged(const QStringList& dsIds);
private:
    QTreeWidget* list_ = nullptr;
};
}
  • Step 2: 写实现

Column2DDataset.cpp:地图 combo天地图/Google Map/隐藏)→ basemapChanged2D视图 combo关闭/Z=0/顶部/底部/自定义)→ view2DModeChanged,选"自定义"时显一个 QDoubleSpinBox(范围 ±1e6后缀 " m")→ customZChangedQTreeWidget 列表同 Task3可勾选 + setDatasets 用 populateDatasetList。骨架同 Column3DDataset 模式QFormLayout 两组 + 列表),此处不赘述控件 connect与 Task3 同形)。自定义 Z 输入框默认 setVisible(false),在 view2DModeChanged 槽里 zEdit->setVisible(index==4)

  • Step 3: 注册 CMake + 编译

src/app/CMakeLists.txtpanels/columns/Column2DDataset.cpp。 Run: cmd.exe /c "chcp 65001 >nul && cd /d D:\Git\lanbingtech\geopro && .\build.bat app" Expected: 编译通过。

  • Step 4: 提交
git add src/app/panels/columns/Column2DDataset.hpp src/app/panels/columns/Column2DDataset.cpp src/app/CMakeLists.txt
git commit -m "feat(vtk): 二维数据集栏 widget(地图/2D视图+自定义Z输入+2D列表)"

Task 5: 三维分析栏 widget + 两个右键菜单 [UI]

Files:

  • Create: src/app/panels/columns/Column3DAnalysis.hpp/.cpp

  • Modify: src/app/CMakeLists.txt

  • Step 1: 写头文件

Column3DAnalysis.hpp

#pragma once
#include <QWidget>
#include <QStringList>
#include <vector>
#include "data/repo/RepoTypes.hpp"
#include "render/interact/SlicePlaneMath.hpp"   // SliceAxis
class QTreeWidget;
class QTreeWidgetItem;
namespace geopro::app {
// 三维分析栏:对象→三维体模型→切片 树 + 三维体/切片两套右键菜单。
class Column3DAnalysis : public QWidget {
    Q_OBJECT
public:
    explicit Column3DAnalysis(QWidget* parent = nullptr);
    void setDatasets(const std::vector<geopro::data::DsRow>& rows);  // Analysis 维度(三维体/切片)
signals:
    // 三维体右键:切片▸(上下/前后/左右/任意)
    void sliceRequested(geopro::render::interact::SliceAxis axis);
    void colorScaleRequested(const QString& dsId);        // 三维体&切片(本期 stub)
    void visibilityToggled(const QString& dsId);          // 显示/隐藏
    void detailRequested(const QString& dsId, const QString& ddCode, const QString& name);
    // 切片右键(本期 stub,菜单可见但发信号给上层提示"待实现")
    void sliceSaveRequested(const QString& dsId);
    void sliceSaveAsRequested(const QString& dsId);
    void sliceExportRequested(const QString& dsId);
    void sliceDeleteRequested(const QString& dsId);
    void checkedItemsChanged(const QStringList& dsIds);
private:
    void onContextMenu(const QPoint& pos);
    QTreeWidget* tree_ = nullptr;
};
}
  • Step 2: 写实现(树 + 右键分派)

Column3DAnalysis.cpptree_setContextMenuPolicy(Qt::CustomContextMenu)connect customContextMenuRequestedonContextMenu。节点类型用 item->data(0, role) 区分"三维体" vs "切片"(建树时按 ddCodedd_voxel/dd_Structual3D/dd_Property3D/dd_section 为三维体;dd_slice 为切片)。右键分派(核心):

void Column3DAnalysis::onContextMenu(const QPoint& pos) {
    QTreeWidgetItem* it = tree_->itemAt(pos);
    if (!it) return;
    const QString dsId = it->data(0, kDsIdRole).toString();
    const QString ddCode = it->data(0, kDsDdCodeRole).toString();
    const QString name = it->data(0, kDsNameRole).toString();
    const bool isSlice = (ddCode == QStringLiteral("dd_slice"));
    QMenu menu(this);
    if (!isSlice) {
        // 三维体数据集:切片▸(上下/前后/左右/任意) / 色阶 / 显示·隐藏 / 数据详情
        QMenu* sub = menu.addMenu(QStringLiteral("切片"));
        using SA = geopro::render::interact::SliceAxis;
        sub->addAction(QStringLiteral("上下"), this, [this]{ emit sliceRequested(SA::UpDown); });
        sub->addAction(QStringLiteral("前后"), this, [this]{ emit sliceRequested(SA::FrontBack); });
        sub->addAction(QStringLiteral("左右"), this, [this]{ emit sliceRequested(SA::LeftRight); });
        sub->addAction(QStringLiteral("任意"), this, [this]{ emit sliceRequested(SA::Oblique); });
        menu.addAction(QStringLiteral("色阶"), this, [this, dsId]{ emit colorScaleRequested(dsId); });
        menu.addAction(QStringLiteral("显示 / 隐藏"), this, [this, dsId]{ emit visibilityToggled(dsId); });
        menu.addAction(QStringLiteral("数据详情"), this, [this, dsId, ddCode, name]{ emit detailRequested(dsId, ddCode, name); });
    } else {
        // 切片数据集:保存/保存为/导出/删除 — 色阶/显示·隐藏/数据详情
        menu.addAction(QStringLiteral("保存"), this, [this, dsId]{ emit sliceSaveRequested(dsId); });
        menu.addAction(QStringLiteral("保存为"), this, [this, dsId]{ emit sliceSaveAsRequested(dsId); });
        menu.addAction(QStringLiteral("导出"), this, [this, dsId]{ emit sliceExportRequested(dsId); });
        menu.addAction(QStringLiteral("删除"), this, [this, dsId]{ emit sliceDeleteRequested(dsId); });
        menu.addSeparator();
        menu.addAction(QStringLiteral("色阶"), this, [this, dsId]{ emit colorScaleRequested(dsId); });
        menu.addAction(QStringLiteral("显示 / 隐藏"), this, [this, dsId]{ emit visibilityToggled(dsId); });
        menu.addAction(QStringLiteral("数据详情"), this, [this, dsId, ddCode, name]{ emit detailRequested(dsId, ddCode, name); });
    }
    menu.exec(tree_->viewport()->mapToGlobal(pos));
}

kDsIdRole/kDsDdCodeRole/kDsNameRole:用 DatasetListPanel 的公开 role 常量(读 DatasetListPanel.hpp。树构建populateDatasetList(tree_, analysisRows, false) 起步(它已按 parentId 建树:切片挂三维体下),再补可勾选标志(同 Task3 注 2

  • Step 3: 注册 CMake + 编译

src/app/CMakeLists.txtpanels/columns/Column3DAnalysis.cpp。 Run: cmd.exe /c "chcp 65001 >nul && cd /d D:\Git\lanbingtech\geopro && .\build.bat app" Expected: 编译通过。

  • Step 4: 提交
git add src/app/panels/columns/Column3DAnalysis.hpp src/app/panels/columns/Column3DAnalysis.cpp src/app/CMakeLists.txt
git commit -m "feat(vtk): 三维分析栏 widget(对象→三维体→切片树+两套右键菜单)"

Task 6: 抽屉容器 ColumnDrawer [UI]

Files:

  • Create: src/app/panels/columns/ColumnDrawer.hpp/.cpp

  • Modify: src/app/CMakeLists.txt

  • Step 1: 写头文件

ColumnDrawer.hpp

#pragma once
#include <QWidget>
namespace geopro::app {
class Column3DDataset; class Column2DDataset; class Column3DAnalysis;
// VTK视图左侧内嵌抽屉三 tab(三维数据集/二维数据集/三维分析) + 折叠开关。
class ColumnDrawer : public QWidget {
    Q_OBJECT
public:
    explicit ColumnDrawer(QWidget* parent = nullptr);
    Column3DDataset* col3D() const { return col3D_; }
    Column2DDataset* col2D() const { return col2D_; }
    Column3DAnalysis* colAnalysis() const { return colAnalysis_; }
public slots:
    void toggleCollapsed();    // 折叠/展开(宽度切换)
private:
    Column3DDataset* col3D_ = nullptr;
    Column2DDataset* col2D_ = nullptr;
    Column3DAnalysis* colAnalysis_ = nullptr;
    QWidget* body_ = nullptr;  // QTabWidget 容器,折叠时隐藏
    bool collapsed_ = false;
};
}
  • Step 2: 写实现

QTabWidget 三页(三维数据集/二维数据集/三维分析)放入 body_;旁边一个细长折叠按钮(◀/▶,调 toggleCollapsed)。toggleCollapsedcollapsed_ = !collapsed_; body_->setVisible(!collapsed_); 并切按钮箭头。固定展开宽度约 300setFixedWidthsetMaximumWidth,折叠时设 0/隐藏 body

  • Step 3: 注册 CMake + 编译

Run: cmd.exe /c "chcp 65001 >nul && cd /d D:\Git\lanbingtech\geopro && .\build.bat app" Expected: 编译通过。

  • Step 4: 提交
git add src/app/panels/columns/ColumnDrawer.hpp src/app/panels/columns/ColumnDrawer.cpp src/app/CMakeLists.txt
git commit -m "feat(vtk): ColumnDrawer 抽屉容器(三tab+折叠)"

Task 7: main.cpp 装配——删三浮层/切换,挂抽屉,接信号,改名 [UI]

这是核心整合。一次性替换,因 axisBar/sliceBar/layerPanel 的 connect 互相牵连,无法逐控件保持编译。改完一次编绿。

Files:

  • Modify: src/app/main.cpp(多处,见下)

  • Step 1: 删旧 UI 构造

删除:

  • layerPanel393-429axisBar433-516RightTopAnchor520sliceBar525-556BottomLeftAnchor556

  • 分段切换:buildSegmentedHeader/viewHeader/act2D/act3D380-389改为简单标题头(见 Step 3

  • showLayerPanel lambda804-827及其所有调用。

  • updateSliceButtons559-569addSlice572-575、sliceBar 按钮 connect576-590

  • 旧 connectlayer checkboxes846-851、axisBar 控件857-889、act2D/act3D832-843

  • 保留 interactionMgr 创建309-321emptyStatesceneCtrlvtkWidget

  • Step 2: 建 ColumnDrawer + 改 centerWidget 布局为 [抽屉 | GL]

centerWidget 构造处374 一带改为顶部一个标题头Step 3下面一个 QHBoxLayoutdrawer + vtkWidget

#include "app/panels/columns/ColumnDrawer.hpp"
#include "app/panels/columns/Column3DDataset.hpp"
#include "app/panels/columns/Column2DDataset.hpp"
#include "app/panels/columns/Column3DAnalysis.hpp"
// ...
auto* drawer = new geopro::app::ColumnDrawer(centerWidget);
auto* viewRow = new QHBoxLayout();
viewRow->setContentsMargins(0,0,0,0); viewRow->setSpacing(0);
viewRow->addWidget(drawer);            // 左侧抽屉
viewRow->addWidget(vtkWidget, 1);      // 右侧 GL 画布
// centerLayout: [标题头] + [viewRow]
centerLayout->addWidget(viewHeader);   // Step 3 的新标题头
centerLayout->addLayout(viewRow, 1);

设计:抽屉是 vtkWidget 的布局兄弟(非 GL 子浮层),规避 main.cpp:397-399 注释提到的原生 GL 浮层圆角/底色伪影。视觉等同原型(栏在左、画布在右)。

  • Step 3: 新标题头(含全屏按钮,Task 8 接线)

替换分段头为 buildPanelHeader

auto* viewHeader = geopro::app::buildPanelHeader(
    geopro::app::Glyph::Map, QStringLiteral("VTK视图"),
    {{geopro::app::Glyph::Fullscreen, QStringLiteral("全屏")}});

(全屏按钮的 connect 在 Task 8。

  • Step 4: 接三维数据集栏信号 → VtkSceneController
auto* c3 = drawer->col3D();
QObject::connect(c3, &geopro::app::Column3DDataset::axesModeChanged, sceneCtrl, &VtkSceneController::setAxesMode);
QObject::connect(c3, &geopro::app::Column3DDataset::axesUnitChanged, sceneCtrl, &VtkSceneController::setAxesUnit);
QObject::connect(c3, &geopro::app::Column3DDataset::verticalExaggerationChanged, sceneCtrl, &VtkSceneController::setVerticalExaggeration);
QObject::connect(c3, &geopro::app::Column3DDataset::viewRequested, sceneCtrl, &VtkSceneController::applyView);
QObject::connect(c3, &geopro::app::Column3DDataset::zoomInRequested, sceneCtrl, &VtkSceneController::zoomIn);
QObject::connect(c3, &geopro::app::Column3DDataset::zoomOutRequested, sceneCtrl, &VtkSceneController::zoomOut);
QObject::connect(c3, &geopro::app::Column3DDataset::fitRequested, sceneCtrl, &VtkSceneController::fit);
// O点位置/字体本期 stubconnect 到一个提示(可空 lambda)。

类型匹配:Column3DDataset 的枚举即 geopro::controller::AxesMode/AxesUnit/ViewDir(同 I3dSceneView.hppsetAxesMode/setAxesUnit/applyView 形参一致,可直接连。

  • Step 5: 接三维分析栏「切片」→ InteractionManager
auto* ca = drawer->colAnalysis();
QObject::connect(ca, &geopro::app::Column3DAnalysis::sliceRequested, vtkWidget,
                 [interactionMgr](geopro::render::interact::SliceAxis axis){
                     interactionMgr->addSlice(axis);
                 });
QObject::connect(ca, &geopro::app::Column3DAnalysis::detailRequested, &detailCtrl,
                 [&detailCtrl](const QString& dsId, const QString& ddCode, const QString& name){
                     detailCtrl.openDataset(dsId, ddCode, name);
                 });
// colorScale/visibility/slice CRUD 本期 stubconnect 到提示 lambda如 statusBar 显"待实现")。
  • Step 6: 编译(维度过滤接线在 Task 9此处先空列表

Run: cmd.exe /c "chcp 65001 >nul && cd /d D:\Git\lanbingtech\geopro && .\build.bat rebuild" Expected: 全量编译通过exe 刷新。

  • Step 7: 用户实测清单(用户在其终端跑)

    • app 启动中央改名「VTK视图」左侧出现三 tab 抽屉。
    • 旧「二维地图/三维视图」分段按钮已消失;左上/右上/左下三浮层消失。
    • 抽屉折叠开关:点 ◀ 收起、画布变宽;点 ▶ 展开。
    • 三维数据集栏工具条:坐标轴下拉/比例滑块/快捷视图 6 钮/缩放 3 钮可点(功能接通后续 Task 9 验,但点击不崩)。
    • 三维分析栏右键三维体 → 出「切片▸(上下/前后/左右/任意)/色阶/显隐/详情」;右键切片 → 出「保存/保存为/导出/删除/色阶/显隐/详情」。
  • Step 8: 提交

git add src/app/main.cpp
git commit -m "refactor(vtk): 删三浮层+分段切换,改挂三栏抽屉,接信号,中央改名VTK视图"

Task 8: dockState 版本 bump + 全屏按钮 [UI]

Files:

  • Modify: src/app/main.cppdockState 键 1428-1442全屏 connect

  • Step 1: bump dock 布局版本

main.cpp:1430 与 1440ui/dockState_v2 两处改为 ui/dockState_v3dock 名/结构已变,旧布局须丢弃回落默认排布;遵循 1428-1430 注释)。

  • Step 2: 全屏切换实现

全屏 = 隐藏其余 dock仅留目标 dock 充满 dock 区;再点还原。用 ADS CDockWidget::toggleView(bool)。加一个 lambda + 状态:

// 全屏:隐藏其余 dock,仅留 target;再点还原。docks 列表见 hideDockTitleBars(733-740)。
bool* vtkFs = new bool(false);  // 或用 QObject property,避免裸 new:可挂到 window
auto makeFullscreen = [dockManager](ads::CDockWidget* target, const QList<ads::CDockWidget*>& others, bool on){
    for (ads::CDockWidget* d : others) d->toggleView(!on);  // on→隐藏其余
    Q_UNUSED(target);
};

落地建议:用 QToolButton::setCheckable(true) 的全屏按钮 + toggled(bool) 切换;状态存按钮 checked免裸指针。others = 除目标外的全部 dockvtkDock 全屏时 others={leftDock,datasetDock,detailDock,rightDock,propDock}detailDock 全屏时 others={其余})。

  • Step 3: 接全屏按钮

findHeaderAction(box, Glyph::Fullscreen)main.cpp:1016-1020 的 helper取到 VTK视图标题头与 数据详情头里的全屏按钮connect 到 makeFullscreen。VTK视图头是 Step3(Task7) 的 viewHeader;数据详情头是 detailDockwrapWithHeader654-663——给它加 {{Glyph::Fullscreen,"全屏"}} action。

// 数据详情头加全屏 action修改 654-663 的 wrapWithHeader 调用,加 actions 参数)。
// 然后:
auto* vtkFsBtn = findHeaderAction(viewHeader, geopro::app::Glyph::Fullscreen);
auto* detFsBtn = findHeaderAction(detailHeaderBox, geopro::app::Glyph::Fullscreen);
// 各自 setCheckable(true) + connect(&QToolButton::toggled, ... makeFullscreen ...)
  • Step 4: 编译

Run: cmd.exe /c "chcp 65001 >nul && cd /d D:\Git\lanbingtech\geopro && .\build.bat rebuild" Expected: 编译通过。

  • Step 5: 用户实测清单

    • 点 VTK视图标题栏右侧全屏按钮 → VTK视图充满工作区其余 dock 隐藏);再点 → 还原。
    • 点 数据详情标题栏全屏按钮 → 同理。
    • 首次启动旧布局丢弃dock 排布为默认,无错位。
  • Step 6: 提交

git add src/app/main.cpp
git commit -m "feat(vtk): dockState bump v3 + VTK视图/数据详情 全屏按钮(隐藏其余dock)"

Task 9: 维度过滤接线——三栏数据集列表数据驱动 [UI+逻辑]

把"勾选对象 → 取 ds → 按维度分三栏"接通,替换 main.cpp:891-899 的 "grid1" 假实现。

Files:

  • Modify: src/app/main.cpp

  • Step 1: 取勾选对象的 ds 行

现状:checkedTmsChanged(QStringList tmIds) → 假 "grid1"。改为:用 WorkbenchNavController/repo_.loadRowsAsync 对每个勾选 TM 取 DsRow,汇总。nav 已有 datasetsLoaded(tmObjectId, rows, total, append) 信号WorkbenchNavController.hpp:52最简路径:复用 nav 的取数,但 nav 现按"单击对象"取数selectObject勾选多 TM 需逐个取并合并。

实现:在 checkedTmsChanged 槽里,对每个 tmId 调 nav.selectObject(tmId, 2) 不合适(会刷左下列表)。改为直接调 repo

// 汇总所有勾选 TM 的 ds按维度分三栏。projectRepo 是 IAsyncProjectRepository。
QObject::connect(objectTree, &geopro::app::ObjectTreePanel::checkedTmsChanged, &window,
    [&projectRepo, drawer, sceneCtrl, emptyState, &window](const QStringList& tmIds){
        emptyState->setVisible(tmIds.isEmpty());
        auto acc = std::make_shared<std::vector<geopro::data::DsRow>>();
        auto remaining = std::make_shared<int>(tmIds.size());
        if (tmIds.isEmpty()) {
            drawer->col3D()->setDatasets({});
            drawer->col2D()->setDatasets({});
            drawer->colAnalysis()->setDatasets({});
            sceneCtrl->setCheckedDatasets({});
            return;
        }
        for (const QString& tm : tmIds) {
            // classifyType=3, pageNo=1, 大 pageSize 取整树(同 WorkbenchNavController kFetchAllPageSize)
            geopro::data::NavRequest* req = projectRepo.loadRowsAsync(
                currentProjectIdStdString, tm.toStdString(), /*parentConfType*/2, /*classifyType*/3, 1, 100000);
            req->onDone = [acc, remaining, drawer](const geopro::data::DsPage& page){
                acc->insert(acc->end(), page.rows.begin(), page.rows.end());
                if (--(*remaining) == 0) {
                    geopro::app::DimBuckets b = geopro::app::splitByDimension(*acc);
                    drawer->col3D()->setDatasets(b.dim3D);
                    drawer->col2D()->setDatasets(b.dim2D);
                    drawer->colAnalysis()->setDatasets(b.analysis);
                }
            };
            // req->onFail 同样 --remaining 并在归零时刷新(避免一个失败卡死)。
        }
    });
#include "app/DatasetDimension.hpp"

注:NavRequest 的回调字段真名见 src/data/repo/IAsyncProjectRepository.hpp / NavRequest 定义onDone/onFail 或 done/failed——落地按真实字段。currentProjectIdStdString 取当前项目 idmain.cpp 里已有项目 id 来源,搜 currentProjectId/projectId)。

  • Step 2: 勾选数据集 → 渲染

三栏列表勾选 → setCheckedDatasets。汇总三栏勾选的 dsId

auto pushChecked = [drawer, sceneCtrl]{
    QStringList ids;
    // 收集三栏当前勾选(各栏暴露 checkedDatasetsChanged;此处也可各自直接连)
    // 简化:各栏 checkedDatasetsChanged 直接 setCheckedDatasets(合并)。
};
QObject::connect(drawer->col3D(), &geopro::app::Column3DDataset::checkedDatasetsChanged,
                 sceneCtrl, &VtkSceneController::setCheckedDatasets);
// 若需三栏合并,改为聚合后再 setCheckedDatasets;本期可先只接 col3D(3D 渲染主路径)。

本期渲染主路径是 3D 数据集(帘面/体素/地形),故先接 col3D 的勾选 → setCheckedDatasets。2D/分析渲染随各自维度后续完善。

  • Step 3: 编译

Run: cmd.exe /c "chcp 65001 >nul && cd /d D:\Git\lanbingtech\geopro && .\build.bat rebuild" Expected: 编译通过。

  • Step 4: 用户实测清单

    • 勾选含 ds 的对象 → 三维数据集栏列表出现 3D 维度 ds样本 "剖面网格数据1" dd_section
    • 勾选三维数据集栏里的 ds → 中央渲染帘面(原 grid1 路径效果)。
    • 取消全部勾选 → 三栏列表清空、中央清场、引导层 emptyState 显示。
    • (样本数据若无 2D/切片 ds2D/分析栏为空属正常;可后续在 LocalSample 加样本演示。)
  • Step 5: 提交

git add src/app/main.cpp
git commit -m "feat(vtk): 三栏数据集列表按维度过滤数据驱动(替换grid1假实现)"

Task 10: 全量回归 + 收尾 [逻辑]

  • Step 1: 全量 build + ctest

Run: cmd.exe /c "chcp 65001 >nul && cd /d D:\Git\lanbingtech\geopro && .\build.bat rebuild && .\build.bat test" Expected: 编译通过ctest 全绿(≥ 223/223含新 DatasetDimension.* 2 项)。

  • Step 2: 派 cpp-reviewer 审查本分支改动

src/app/panels/columns/*DatasetDimension.*main.cpp diff 跑 cpp-reviewer修 CRITICAL/HIGH重点裸 new/生命周期、信号槽断连、ADS toggleView 还原正确性、role 常量硬编码)。

  • Step 3: 对照 spec 验收

逐条核对 2026-06-16-vtk-3d-three-column-refactor-design.md §0 IN 项全部落地、OUT 项为 stub/禁用。

  • Step 4: 用户最终实测(完整走查实测清单 Task7/8/9

  • Step 5: 提交收尾(如有修改)

git add -A && git commit -m "chore(vtk): 三栏重构 review 修复 + 回归"

Self-Review写计划后自查

Spec 覆盖:

  • A1 三栏 → Task 3-7 ;单一 VTK视图/删切换/改名 → Task 7 ;三浮层收编 → Task 7 ;维度过滤列表 → Task 2+9 ;三维数据集 4 栏位 → Task 3 ;二维 地图/2D视图/自定义Z → Task 4 ;三维分析树+两右键菜单+切片接 SliceTool → Task 5+7 ;全屏 → Task 1+8 自定义Z绝对高程 → Task 4(spec 已记) dock 版本 bump → Task 8
  • OUT 项CRUD/色阶/底图/异常体/详情/任务)→ stub 信号(Task 5),不实现

占位符扫描: 已用真实 API/行号;少数"读真实 role 常量/NavRequest 字段名"是有意指向源文件(避免硬编码错值),非 TODO。

类型一致性: AxesMode/AxesUnit/ViewDir(I3dSceneView.hpp) 跨 Task3/7 一致;SliceAxis(SlicePlaneMath.hpp) 跨 Task5/7 一致;DsRow/DsPage/DimBuckets 跨 Task2/3/9 一致;splitByDimension 签名 Task2 定义、Task9 使用一致。

风险: Task 9 的多 TM 异步汇总 + NavRequest 回调字段名是最大不确定点(落地前先读 IAsyncProjectRepository.hpp 确认);全屏 toggleView 还原需保证 dock 顺序/可见性正确Task8 用户实测把关)。