geopro/docs/superpowers/plans/2026-06-24-vtk-category-vie...

58 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: 把 VTK 视图左侧从「三维数据集/二维数据集/三维分析」三 tab 重构为「按数据类型大类分组」的两 tab 视图并改造对象树联动、装置类型筛选、VTK 画布工具条、请求体 DTO 组装。

Architecture: 自底向上分层:先建纯逻辑/服务层DsRow 扩展、分类映射表 splitByCategory、请求体 DTO、装置字典服务、对象树三态状态机全部 GoogleTest 单测;再建 UI 层CategorySection 段组件、QScrollArea 容器、VtkViewToolbar 工具条),靠 cmake build + 手动验证;最后在 main.cpp 总成接线。沿用现有信号槽 + 仓储抽象 + DatasetListPanel 复用,退役 Column3DDataset/Column3DAnalysis。

Tech Stack: C++17 / Qt6Widgets/ VTK / CMake + CTest + GoogleTest。

设计依据:docs/superpowers/specs/2026-06-24-vtk-category-view-refactor-design.mdspec 各节在任务中以「spec §N」引用

Global Constraints

  • C++17类型 PascalCase、方法 camelCase、成员 snake_case_ 尾下划线(随现有 src/ 风格)。
  • 不可变优先UI 组件单一职责、文件 <800 行。
  • 所有新增逻辑/服务必须有 GoogleTest 单测(tests/CMake 注册UI 组件以「cmake build 通过 + 现有 ctest 全绿 + 手动验证清单」为验收。
  • 后端基址 http://tenant.geomative.cn/pop-api;响应信封 {code,msg,data}code==200 成功,列表在 data.value/data.list
  • 大类分类键:电阻率=ERT platform inversion data、视电阻率=visual resistivity data、瞬变电磁=DD TRANSIENT ELECTROMAGNETIC INVERSIONdsTypeCode三者 ddCode 同为 dd_inversion_data);三维体=dd_voxel、切片=dd_sliceddCode
  • 层级 structParentConfType1=GS/项目根2=TM。
  • 装置类型只电阻率/视电阻率段有,瞬变电磁/三维体/切片无。
  • 三维体/切片/异常仍走 Api3dRepository mock不切真实后端
  • 提交信息用 conventional commitsfeat/refactor/test/docs署名全局已禁用勿加。
  • 构建/测试命令Windows配置 cmake --preset <repo默认> 后,cmake --build build 构建、ctest --test-dir build --output-on-failure -R <name> 跑指定测试。若仓库用 build.bat,按其 rebuild。先确认 tests/ 现有用例如何注册(参考 tests/data/test_nav_dto.cpp 与对应 CMakeLists.txt)。

文件结构(决策锁定)

新建:

  • src/data/repo/CategoryConfig.hpp — 大类映射表 + 段元数据(纯数据/纯函数)。
  • src/app/DatasetCategory.hpp / .cppsplitByCategory(替代 DatasetDimensionsplitByDimension)。
  • src/data/dto/Vtk3dRequests.hpp / .cppVoxelGenerateRequest / SliceGenerateRequest + toJson
  • src/data/repo/DatasetFieldDictionary.hpp / .cpp — 按 dsType 缓存 dynamicForm 的 confFieldId↔fieldCode 映射 + 装置类型 value→中文字典。
  • src/app/panels/columns/CategorySection.hpp / .cpp — 单个类型段(段头筛选/操作 + 段体树)。
  • src/app/panels/columns/CategoryAnalysisTab.hpp / .cpp — 「三维分析」tab 容器QScrollArea 堆叠 5 段)。
  • src/app/VtkViewToolbar.hpp / .cpp — VTK 画布竖排工具条。
  • src/app/AxesSettingsDialog.hpp / .cpp — 坐标轴设置对话框。
  • 测试:tests/data/test_dataset_category.cpptests/data/test_vtk3d_requests.cpptests/data/test_dataset_field_dictionary.cpp;扩 tests/data/test_nav_dto.cpp

修改:

  • src/data/repo/RepoTypes.hppDsRowdsTypeCode + properties(原始 KV
  • src/data/dto/NavDto.cppparseDsRows 解析新字段。
  • src/app/panels/ObjectTreePanel.{hpp,cpp} — GS 三态状态机(停 AutoTristate+ 右键 ds/tm + checkedSourcesChanged 信号。
  • src/data/repo/RepoTypes.hpp 或新文件 — DataSource{id,confType} 类型。
  • src/app/panels/columns/ColumnDrawer.{hpp,cpp} — 三 tab → 两 tab。
  • src/data/api/Api3dRepository.{hpp,cpp}createVolume/createSlice 扩参 + DTO 组装。
  • src/app/main.cpp — 数据流接线(分流拉取/分类分发/勾选并集/生成入口归属)。
  • 相关 CMakeLists.txtapp 与 tests

退役(功能迁出后删除引用): Column3DDatasetColumn3DAnalysis(拆分到 CategorySection / VtkViewToolbar / AxesSettingsDialog / 三维体段)。

依赖顺序: Phase 1数据模型+分类)→ Phase 2DTO→ Phase 3对象树→ Phase 4字典服务→ Phase 5段+容器)→ Phase 6工具条→ Phase 7Api3d 扩参+段重组)→ Phase 8main 接线总成)。


Phase 1 — 数据模型与分类层

Task 1: DsRow 扩展 + parseDsRows 解析

Files:

  • Modify: src/data/repo/RepoTypes.hppDsRow 结构)
  • Modify: src/data/dto/NavDto.cpp:116-137parseDsRows
  • Test: tests/data/test_nav_dto.cpp(新增用例)

Interfaces:

  • Produces:

    • struct DsPropKV { std::string confFieldId, value; };
    • DsRow 新成员:std::string dsTypeCode;std::vector<DsPropKV> properties;
    • parseDsRows 填充上述字段(其余字段不变)。
  • Step 1: 写失败测试 — 在 tests/data/test_nav_dto.cpp 增用例(喂带 dsTypeCode + properties 数组的 ds 行 JSON

TEST(NavDtoTest, ParseDsRowsExtractsTypeCodeAndProperties) {
    const QString json = R"({"list":[{
        "id":"d1","dsName":"ERT1-WS","name":"电阻率数据",
        "ddCode":"dd_inversion_data","dsTypeCode":"ERT platform inversion data",
        "createTime":"2026-03-25 16:48:57","structParentId":"tm1","structParentConfType":2,
        "properties":[
            {"confFieldId":"1450495001706500","value":"1429468249448449"},
            {"confFieldId":"1455083478786048","value":"2026-03-25 16:48:57"}
        ]
    }]})";
    const QJsonObject data = QJsonDocument::fromJson(json.toUtf8()).object();
    const auto page = geopro::data::dto::parseDsPage(data);
    ASSERT_EQ(page.rows.size(), 1u);
    const auto& r = page.rows[0];
    EXPECT_EQ(r.dsTypeCode, "ERT platform inversion data");
    EXPECT_EQ(r.structParentId, "tm1");
    EXPECT_EQ(r.structParentConfType, 2);
    EXPECT_EQ(r.ddCode, "dd_inversion_data");
    ASSERT_EQ(r.properties.size(), 2u);
    EXPECT_EQ(r.properties[0].confFieldId, "1450495001706500");
    EXPECT_EQ(r.properties[0].value, "1429468249448449");
}
  • Step 2: 跑测试确认失败

Run: ctest --test-dir build --output-on-failure -R NavDtoTest Expected: 编译失败(dsTypeCode/properties 成员不存在)。

  • Step 3: 扩 DsRowsrc/data/repo/RepoTypes.hppDsRow 上方加 KV 结构、DsRow 内加两成员:
// ds 属性键值data/page 的 properties[] 项confFieldId→value 原始对)。
struct DsPropKV { std::string confFieldId, value; };

struct DsRow {
    std::string id, dsName, typeName, ddCode, createTime;
    std::string parentId;
    std::string fileName, fileUrl;
    long long fileSize = 0;
    std::string dsTypeCode;            // 大类分类主键spec §5ddCode 粒度不足以区分电阻率/视电阻率)
    std::vector<DsPropKV> properties;  // 原始 confFieldId→value装置类型/采集时间经 DatasetFieldDictionary 解析
    std::string structParentId;        // 上级节点 id段体容器分组 + 生成三维体归属用)
    int structParentConfType = 0;      // 1=GS/项目根 2=TM
};
  • Step 4: 扩 parseDsRowssrc/data/dto/NavDto.cppd.ddCode = str(o, "ddCode"); 后加:
        d.dsTypeCode = str(o, "dsTypeCode");
        d.structParentId = str(o, "structParentId");
        d.structParentConfType = o.value(QStringLiteral("structParentConfType")).toInt();
        const QJsonArray props = o.value(QStringLiteral("properties")).toArray();
        d.properties.reserve(static_cast<size_t>(props.size()));
        for (const QJsonValue& pv : props) {
            const QJsonObject po = pv.toObject();
            d.properties.push_back(
                {str(po, "confFieldId"), po.value(QStringLiteral("value")).toVariant().toString().toStdString()});
        }

注:valuetoVariant().toString() 兼容字符串/数值/时间(与 parseDynamicForm:208 同口径)。properties 也可能是对象(接地电阻/文件型 ds§3.2 实测),此处 .toArray() 对非数组安全返回空——分类不依赖这些类型的 properties可接受。

  • Step 5: 跑测试确认通过

Run: ctest --test-dir build --output-on-failure -R NavDtoTest Expected: PASS全部 NavDtoTest 用例)。

  • Step 6: 提交
git add src/data/repo/RepoTypes.hpp src/data/dto/NavDto.cpp tests/data/test_nav_dto.cpp
git commit -m "feat(data): DsRow 加 dsTypeCode/properties + parseDsRows 解析"

Task 2: CategoryConfig 映射表 + splitByCategory

Files:

  • Create: src/data/repo/CategoryConfig.hpp
  • Create: src/app/DatasetCategory.hpp / src/app/DatasetCategory.cpp
  • Test: tests/data/test_dataset_category.cpp
  • Modify: src/app/CMakeLists.txt(加 DatasetCategory.cpptests/.../CMakeLists.txt(注册测试)

Interfaces:

  • Consumes: DsRowTask 1

  • Produces:

    • struct CategorySpec { std::string id, title, dsTypeCode, ddCode; bool canGenerateVolume; bool hasArrayTypeFilter; };
    • const std::vector<CategorySpec>& categoryConfigs();5 段有序)
    • struct CategoryBuckets { std::vector<std::vector<DsRow>> segments; };(与 configs 同序、同长)
    • CategoryBuckets splitByCategory(const std::vector<DsRow>& rows);
  • Step 1: 写失败测试tests/data/test_dataset_category.cpp

#include <gtest/gtest.h>
#include "DatasetCategory.hpp"
using geopro::data::DsRow;
using namespace geopro::app;

static DsRow row(const std::string& id, const std::string& ddCode, const std::string& dsTypeCode) {
    DsRow r; r.id = id; r.ddCode = ddCode; r.dsTypeCode = dsTypeCode; return r;
}

TEST(SplitByCategory, RoutesByDsTypeCodeAndDdCode) {
    std::vector<DsRow> rows = {
        row("a", "dd_inversion_data", "ERT platform inversion data"),       // 电阻率
        row("b", "dd_inversion_data", "visual resistivity data"),           // 视电阻率
        row("c", "dd_inversion_data", "DD TRANSIENT ELECTROMAGNETIC INVERSION"), // 瞬变
        row("v", "dd_voxel", ""),                                           // 三维体(按 ddCode)
        row("s", "dd_slice", ""),                                           // 切片(按 ddCode)
        row("x", "dd_ert_measurement_gr_data", "earth resistance"),         // 接地电阻 → 丢弃
    };
    const CategoryBuckets b = splitByCategory(rows);
    ASSERT_EQ(b.segments.size(), categoryConfigs().size());
    EXPECT_EQ(b.segments[0].size(), 1u);  EXPECT_EQ(b.segments[0][0].id, "a");
    EXPECT_EQ(b.segments[1].size(), 1u);  EXPECT_EQ(b.segments[1][0].id, "b");
    EXPECT_EQ(b.segments[2].size(), 1u);  EXPECT_EQ(b.segments[2][0].id, "c");
    EXPECT_EQ(b.segments[3].size(), 1u);  EXPECT_EQ(b.segments[3][0].id, "v");
    EXPECT_EQ(b.segments[4].size(), 1u);  EXPECT_EQ(b.segments[4][0].id, "s");
    // 接地电阻不进任何段
    std::size_t total = 0; for (auto& s : b.segments) total += s.size();
    EXPECT_EQ(total, 5u);
}
  • Step 2: 跑测试确认失败

Run: ctest --test-dir build --output-on-failure -R SplitByCategory Expected: 编译失败(头文件/符号缺失)。

  • Step 3: 写 CategoryConfig.hpp
#pragma once
#include <string>
#include <vector>

namespace geopro::app {

// 一个数据类型大类段的配置spec §5。识别键二选一dsTypeCode 优先ddCode 用于三维体/切片。
struct CategorySpec {
    std::string id;             // 段稳定 id
    std::string title;          // 段标题UI 显示)
    std::string dsTypeCode;     // 主识别键(空=不按 dsTypeCode
    std::string ddCode;         // 次识别键dd_voxel/dd_slice空=不按 ddCode
    bool canGenerateVolume;     // 段内 GS/项目根是否提供「生成三维体」
    bool hasArrayTypeFilter;    // 段头是否显示装置类型筛选(仅 ERT 类)
};

// 5 段固定有序spec §5 表)。
inline const std::vector<CategorySpec>& categoryConfigs() {
    static const std::vector<CategorySpec> kCfg = {
        {"resistivity",        "电阻率数据",   "ERT platform inversion data",            "",         true,  true},
        {"apparent",           "视电阻率数据", "visual resistivity data",                "",         true,  true},
        {"transient",          "瞬变电磁数据", "DD TRANSIENT ELECTROMAGNETIC INVERSION", "",         true,  false},
        {"voxel",              "三维体",       "",                                       "dd_voxel", false, false},
        {"slice",              "切片",         "",                                       "dd_slice", false, false},
    };
    return kCfg;
}

}  // namespace geopro::app
  • Step 4: 写 DatasetCategory.hpp / .cpp

DatasetCategory.hpp:

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

namespace geopro::app {

struct CategoryBuckets {
    std::vector<std::vector<geopro::data::DsRow>> segments;  // 与 categoryConfigs() 同序同长
};

// 按 CategoryConfig 把 ds 分入大类段:先判 ddCode 白名单(三维体/切片),否则按 dsTypeCode 匹配;
// 不在表内的丢弃(接地电阻/原始数据/白化/坐标等)。保留原顺序。
CategoryBuckets splitByCategory(const std::vector<geopro::data::DsRow>& rows);

}  // namespace geopro::app

DatasetCategory.cpp:

#include "DatasetCategory.hpp"

namespace geopro::app {

CategoryBuckets splitByCategory(const std::vector<geopro::data::DsRow>& rows) {
    const auto& cfg = categoryConfigs();
    CategoryBuckets b;
    b.segments.resize(cfg.size());
    for (const auto& r : rows) {
        int hit = -1;
        // 先按 ddCode三维体/切片)——它们无 dsTypeCode来自 Api3dRepository mock 行)。
        for (std::size_t i = 0; i < cfg.size() && hit < 0; ++i)
            if (!cfg[i].ddCode.empty() && r.ddCode == cfg[i].ddCode) hit = static_cast<int>(i);
        // 再按 dsTypeCode。
        for (std::size_t i = 0; i < cfg.size() && hit < 0; ++i)
            if (!cfg[i].dsTypeCode.empty() && r.dsTypeCode == cfg[i].dsTypeCode) hit = static_cast<int>(i);
        if (hit >= 0) b.segments[static_cast<std::size_t>(hit)].push_back(r);
    }
    return b;
}

}  // namespace geopro::app
  • Step 5: 注册到 CMake — 把 src/app/DatasetCategory.cpp 加入 app 目标源;把 tests/data/test_dataset_category.cpptest_dataset_dimension/test_nav_dto 同样式注册(确认现有测试在哪个 CMakeLists、用何宏注册照抄

  • Step 6: 跑测试确认通过

Run: cmake --build build && ctest --test-dir build --output-on-failure -R SplitByCategory Expected: PASS。

  • Step 7: 提交
git add src/data/repo/CategoryConfig.hpp src/app/DatasetCategory.hpp src/app/DatasetCategory.cpp tests/data/test_dataset_category.cpp src/app/CMakeLists.txt tests/data/CMakeLists.txt
git commit -m "feat(app): CategoryConfig 映射表 + splitByCategory 按 dsTypeCode 分大类"

Phase 2 — 请求体 DTO

Task 3: VoxelGenerateRequest / SliceGenerateRequest + toJson

Files:

  • Create: src/data/dto/Vtk3dRequests.hpp / .cpp
  • Test: tests/data/test_vtk3d_requests.cpp
  • Modify: src/data/CMakeLists.txt(或 app、tests CMake

Interfaces:

  • Produces对齐 docs/api/vtk-3d-openapi.json schema:

    • struct VoxelGenerateRequest { std::string projectId, structParentId; int structParentConfType=1; std::string name; std::vector<std::string> sourceDatasetIds; std::string interpModel="Idw"; double cellXY=1.0,cellZ=0.5,power=2.0,maxDist=4.0; std::string colorScaleId; QJsonObject toJson() const; };
    • struct SliceGenerateRequest { std::string projectId, volumeDsId, name; int axis=3; std::array<double,3> origin{},point1{},point2{}; std::string colorScaleId; QJsonObject toJson() const; };
  • Step 1: 写失败测试tests/data/test_vtk3d_requests.cpp

#include <gtest/gtest.h>
#include <QJsonArray>
#include "dto/Vtk3dRequests.hpp"
using namespace geopro::data;

TEST(Vtk3dRequests, VoxelToJsonMatchesContract) {
    VoxelGenerateRequest q;
    q.projectId = "p1"; q.structParentId = "g1"; q.structParentConfType = 1;
    q.name = "体A"; q.sourceDatasetIds = {"d1", "d2"};
    const QJsonObject j = q.toJson();
    EXPECT_EQ(j["projectId"].toString(), "p1");
    EXPECT_EQ(j["structParentId"].toString(), "g1");
    EXPECT_EQ(j["structParentConfType"].toInt(), 1);
    EXPECT_EQ(j["name"].toString(), "体A");
    ASSERT_TRUE(j["sourceDatasetIds"].isArray());
    EXPECT_EQ(j["sourceDatasetIds"].toArray().size(), 2);
    EXPECT_EQ(j["interpModel"].toString(), "Idw");
    EXPECT_DOUBLE_EQ(j["cellXY"].toDouble(), 1.0);
}

TEST(Vtk3dRequests, SliceToJsonMatchesContract) {
    SliceGenerateRequest q;
    q.projectId = "p1"; q.volumeDsId = "v1"; q.name = "切片1"; q.axis = 3;
    q.origin = {0, 0, -10}; q.point1 = {100, 0, -10}; q.point2 = {0, 50, -10};
    const QJsonObject j = q.toJson();
    EXPECT_EQ(j["volumeDsId"].toString(), "v1");
    EXPECT_EQ(j["axis"].toInt(), 3);
    ASSERT_TRUE(j["origin"].isArray());
    EXPECT_EQ(j["origin"].toArray().size(), 3);
    EXPECT_DOUBLE_EQ(j["point1"].toArray()[0].toDouble(), 100.0);
}
  • Step 2: 跑测试确认失败

Run: ctest --test-dir build --output-on-failure -R Vtk3dRequests Expected: 编译失败(头缺失)。

  • Step 3: 写 Vtk3dRequests.hpp
#pragma once
#include <array>
#include <string>
#include <vector>
#include <QJsonObject>

namespace geopro::data {

// 对齐 docs/api/vtk-3d-openapi.json VoxelGenerateRequestspec §8 请求体组装)。
struct VoxelGenerateRequest {
    std::string projectId;
    std::string structParentId;          // GS/项目根容器节点 id
    int structParentConfType = 1;        // 1=GS/项目根
    std::string name;
    std::vector<std::string> sourceDatasetIds;
    std::string interpModel = "Idw";     // Idw|Kriging
    double cellXY = 1.0, cellZ = 0.5, power = 2.0, maxDist = 4.0;
    std::string colorScaleId;            // 空=取首源色阶
    QJsonObject toJson() const;
};

// 对齐 SliceGenerateRequest。
struct SliceGenerateRequest {
    std::string projectId;
    std::string volumeDsId;              // 所属三维体 dsObjectId
    std::string name;
    int axis = 3;                        // 0上下/1前后/2左右/3任意
    std::array<double, 3> origin{{0, 0, 0}};
    std::array<double, 3> point1{{0, 0, 0}};
    std::array<double, 3> point2{{0, 0, 0}};
    std::string colorScaleId;
    QJsonObject toJson() const;
};

}  // namespace geopro::data
  • Step 4: 写 Vtk3dRequests.cpp
#include "dto/Vtk3dRequests.hpp"
#include <QJsonArray>
#include <QString>

namespace geopro::data {

namespace {
QJsonArray vec3(const std::array<double, 3>& v) {
    return QJsonArray{v[0], v[1], v[2]};
}
QString qs(const std::string& s) { return QString::fromStdString(s); }
}  // namespace

QJsonObject VoxelGenerateRequest::toJson() const {
    QJsonArray ids;
    for (const auto& s : sourceDatasetIds) ids.append(qs(s));
    QJsonObject o{
        {"projectId", qs(projectId)},
        {"structParentId", qs(structParentId)},
        {"structParentConfType", structParentConfType},
        {"name", qs(name)},
        {"sourceDatasetIds", ids},
        {"interpModel", qs(interpModel)},
        {"cellXY", cellXY}, {"cellZ", cellZ}, {"power", power}, {"maxDist", maxDist},
    };
    if (!colorScaleId.empty()) o.insert("colorScaleId", qs(colorScaleId));
    return o;
}

QJsonObject SliceGenerateRequest::toJson() const {
    QJsonObject o{
        {"projectId", qs(projectId)},
        {"volumeDsId", qs(volumeDsId)},
        {"name", qs(name)},
        {"axis", axis},
        {"origin", vec3(origin)}, {"point1", vec3(point1)}, {"point2", vec3(point2)},
    };
    if (!colorScaleId.empty()) o.insert("colorScaleId", qs(colorScaleId));
    return o;
}

}  // namespace geopro::data
  • Step 5: 注册 CMake + 跑测试

Run: cmake --build build && ctest --test-dir build --output-on-failure -R Vtk3dRequests Expected: PASS。

  • Step 6: 提交
git add src/data/dto/Vtk3dRequests.hpp src/data/dto/Vtk3dRequests.cpp tests/data/test_vtk3d_requests.cpp src/data/CMakeLists.txt tests/data/CMakeLists.txt
git commit -m "feat(data): VoxelGenerateRequest/SliceGenerateRequest DTO + toJson"

Phase 3 — 对象树联动改造

Task 4: GS 三态状态机(停 AutoTristate+ 右键 ds/tm

Files:

  • Create: src/app/panels/ObjectTreeSelection.hpp(纯逻辑,可单测)
  • Modify: src/app/panels/ObjectTreePanel.cpp:123 停 AutoTristate、:174-191 itemChanged、:207-242 右键菜单)、.hpp
  • Test: tests/app/test_object_tree_selection.cpp
  • Modify: app/tests CMake

Interfaces:

  • Produces:

    • enum class GsCheck { Unchecked, Partial, Checked };
    • GsCheck aggregateGsState(bool dsOn, int checkedTmCount, int totalTmCount);
    • ObjectTreePanel 新增 UserRolekRoleGsDsOnGS 自身 ds 开关 bool
  • Step 1: 写失败测试tests/app/test_object_tree_selection.cpp

#include <gtest/gtest.h>
#include "panels/ObjectTreeSelection.hpp"
using namespace geopro::app;

TEST(AggregateGsState, AllOnIsChecked) {
    EXPECT_EQ(aggregateGsState(true, 3, 3), GsCheck::Checked);
}
TEST(AggregateGsState, AllOffIsUnchecked) {
    EXPECT_EQ(aggregateGsState(false, 0, 3), GsCheck::Unchecked);
}
TEST(AggregateGsState, DsOnTmNoneIsPartial) {
    EXPECT_EQ(aggregateGsState(true, 0, 3), GsCheck::Partial);   // 只 GS 自身 ds
}
TEST(AggregateGsState, DsOffSomeTmIsPartial) {
    EXPECT_EQ(aggregateGsState(false, 1, 3), GsCheck::Partial);  // 部分子 TM
}
TEST(AggregateGsState, NoTmFallsBackToDsOnly) {
    EXPECT_EQ(aggregateGsState(true, 0, 0), GsCheck::Checked);   // 无子 TM → 仅看 ds 开关
    EXPECT_EQ(aggregateGsState(false, 0, 0), GsCheck::Unchecked);
}
  • Step 2: 跑测试确认失败

Run: ctest --test-dir build --output-on-failure -R AggregateGsState Expected: 编译失败。

  • Step 3: 写 ObjectTreeSelection.hpp
#pragma once
namespace geopro::app {

enum class GsCheck { Unchecked, Partial, Checked };

// GS 复选框三态 = [自身 ds 开关]  [子 TM 勾选] 的聚合spec §6// 无子 TMtotalTmCount==0时退化为仅看 dsOn。
inline GsCheck aggregateGsState(bool dsOn, int checkedTmCount, int totalTmCount) {
    const bool anyOn = dsOn || checkedTmCount > 0;
    if (!anyOn) return GsCheck::Unchecked;
    const bool tmAll = (totalTmCount == 0) || (checkedTmCount == totalTmCount);
    if (dsOn && tmAll) return GsCheck::Checked;
    return GsCheck::Partial;
}

}  // namespace geopro::app
  • Step 4: 跑测试确认通过

Run: cmake --build build && ctest --test-dir build --output-on-failure -R AggregateGsState Expected: PASS。

  • Step 5: 改 ObjectTreePanel —— 停用 AutoTristate + 手动三态

ObjectTreePanel.cpp 顶部常量区加 UserRole

constexpr int kRoleGsDsOn = Qt::UserRole + 7;  // GS 自身 ds 开关bool

addNodes 里 GS 分支(现 :122-125else { ... ItemIsAutoTristate ... })改为不设 AutoTristate、初始化 ds 开关:

} else {
    item->setData(0, kRoleConfType, kConfTypeGs);  // GS
    item->setFlags(item->flags() | Qt::ItemIsUserCheckable);  // 去掉 ItemIsAutoTristate
    item->setData(0, kRoleGsDsOn, false);
    item->setCheckState(0, Qt::Unchecked);
    item->setIcon(0, makeGlyph(Glyph::WorkArea, iconColor, iconPx));
}

新增私有方法 void recomputeGsState(QTreeWidgetItem* gs).hpp 声明,.cpp 实现):

void ObjectTreePanel::recomputeGsState(QTreeWidgetItem* gs) {
    if (!gs || gs->data(0, kRoleConfType).toInt() != kConfTypeGs) return;
    const bool dsOn = gs->data(0, kRoleGsDsOn).toBool();
    int total = 0, checked = 0;
    for (int i = 0; i < gs->childCount(); ++i) {
        QTreeWidgetItem* c = gs->child(i);
        if (c->data(0, kRoleConfType).toInt() != kConfTypeTm) continue;
        ++total;
        if (c->checkState(0) == Qt::Checked) ++checked;
    }
    const GsCheck s = aggregateGsState(dsOn, checked, total);
    const QSignalBlocker block(tree_);  // 不再触发 itemChanged 递归
    gs->setCheckState(0, s == GsCheck::Checked ? Qt::Checked
                       : s == GsCheck::Partial ? Qt::PartiallyChecked : Qt::Unchecked);
}

在现有 itemChanged 合并回调(:174-191遍历收集后对每个 GS 调 recomputeGsState,再发 checkedSourcesChanged(见 Task 5。点 GS 复选框的「任一开→全关 / 全关→全开」在 itemClicked(或复选框命中分支)里处理:读当前 aggregateGsState,若非 Unchecked → 置 dsOn=false + 子 TM 全 Unchecked若 Unchecked → dsOn=true + 子 TM 全 Checked然后 recomputeGsState

  • Step 6: 右键 ds/tm 菜单 — 在右键菜单 GS 分支(现 :230-234 if (isGs))加:
if (isGs) {
    QMenu* sel = menu.addMenu(QStringLiteral("选择"));
    const bool dsOn = item->data(0, kRoleGsDsOn).toBool();
    bool hasOwnDs = /* 该 GS 是否有直挂 ds由上层 setStructure 时标记,或暂以 true */ true;
    int tmCount = 0;
    for (int i = 0; i < item->childCount(); ++i)
        if (item->child(i)->data(0, kRoleConfType).toInt() == kConfTypeTm) ++tmCount;
    QAction* dsAct = sel->addAction(QStringLiteral("ds"));
    dsAct->setCheckable(true); dsAct->setChecked(dsOn); dsAct->setEnabled(hasOwnDs);
    QObject::connect(dsAct, &QAction::triggered, this, [this, item](bool on) {
        item->setData(0, kRoleGsDsOn, on); recomputeGsState(item); emitCheckedSources();
    });
    QAction* tmAct = sel->addAction(QStringLiteral("tm"));
    tmAct->setCheckable(true);
    tmAct->setChecked(tmCount > 0 && allTmChecked(item));  // allTmChecked: 私有辅助
    tmAct->setEnabled(tmCount > 0);
    QObject::connect(tmAct, &QAction::triggered, this, [this, item](bool on) {
        setAllChildTmChecked(item, on); recomputeGsState(item); emitCheckedSources();
    });
    // 保留原「新建检测对象 / 新建方法对象」
    add(QStringLiteral("新建检测对象"), QStringLiteral("newGs"));
    add(QStringLiteral("新建方法对象"), QStringLiteral("newTm"));
}

allTmChecked / setAllChildTmChecked / emitCheckedSources 为私有辅助,签名在 .hpp 声明;emitCheckedSources 见 Task 5。hasOwnDs 的真值来源setStructure 时若 StructNode 标记了直挂 ds 则置 true无标记暂保守 true——不影响正确性仅菜单项是否灰显。

  • Step 7: build + 手动验证

Run: cmake --build build 手动清单(启动 app进 VTK 视图,选有 GS 层级的项目):

  1. 勾 GS 复选框 → 变全黑、其下 TM 全勾。
  2. 再点 GS → 变空、TM 全不勾。
  3. 右键 GS→选择▸ds 打勾 → GS 变灰(仅自身 ds
  4. 右键 GS→选择▸tm 打勾 → 子 TM 全勾、GS 若 ds 也开则变黑、否则灰。
  5. 单独勾一个子 TM → GS 变灰、tm 菜单对号消失。
  6. 无子 TM 的 GSds 开/关 → GS 黑/空(不出现灰)。
  • Step 8: 提交
git add src/app/panels/ObjectTreeSelection.hpp src/app/panels/ObjectTreePanel.hpp src/app/panels/ObjectTreePanel.cpp tests/app/test_object_tree_selection.cpp src/app/CMakeLists.txt tests/app/CMakeLists.txt
git commit -m "feat(tree): GS 三态状态机(停 AutoTristate)+右键 ds/tm 选择"

Task 5: DataSource 去重 + checkedSourcesChanged 信号

Files:

  • Modify: src/data/repo/RepoTypes.hppDataSource
  • Create: src/app/panels/ObjectTreeSelection.hpp(追加 dedupeSources
  • Modify: src/app/panels/ObjectTreePanel.{hpp,cpp}emitCheckedSources + 信号)
  • Test: tests/app/test_object_tree_selection.cpp(追加)

Interfaces:

  • Produces:

    • struct DataSource { std::string id; int confType; };confType: 1=GS/项目, 2=TM
    • std::vector<DataSource> dedupeSources(std::vector<DataSource> in);(按 {id,confType} 去重保序)
    • ObjectTreePanel 信号 void checkedSourcesChanged(const QList<geopro::data::DataSource>& sources);
    • 私有 void emitCheckedSources();
  • Step 1: 写失败测试 — 追加到 test_object_tree_selection.cpp

#include "repo/RepoTypes.hpp"
using geopro::data::DataSource;

TEST(DedupeSources, RemovesDuplicateByIdAndConfType) {
    std::vector<DataSource> in = {{"t1",2},{"g1",1},{"t1",2},{"g1",1},{"t2",2}};
    const auto out = geopro::app::dedupeSources(in);
    ASSERT_EQ(out.size(), 3u);
    EXPECT_EQ(out[0].id, "t1"); EXPECT_EQ(out[0].confType, 2);
    EXPECT_EQ(out[1].id, "g1"); EXPECT_EQ(out[1].confType, 1);
    EXPECT_EQ(out[2].id, "t2");
}
TEST(DedupeSources, SameIdDifferentConfTypeKept) {
    std::vector<DataSource> in = {{"x",1},{"x",2}};
    EXPECT_EQ(geopro::app::dedupeSources(in).size(), 2u);
}
  • Step 2: 跑确认失败ctest -R DedupeSources → 编译失败。

  • Step 3: 加 DataSourceRepoTypes.hpp

// 对象树勾选产出的数据源spec §6。confType: 1=GS/项目根, 2=TM。
struct DataSource { std::string id; int confType = 0; };

(若放 QList<DataSource> 过信号,需 Q_DECLARE_METATYPE(geopro::data::DataSource) + 注册,照 DsPage 现有 metatype 注册方式。)

  • Step 4: 加 dedupeSourcesObjectTreeSelection.hpp 追加:
#include <vector>
#include "repo/RepoTypes.hpp"
namespace geopro::app {
inline std::vector<geopro::data::DataSource> dedupeSources(std::vector<geopro::data::DataSource> in) {
    std::vector<geopro::data::DataSource> out;
    for (const auto& s : in) {
        bool dup = false;
        for (const auto& o : out) if (o.id == s.id && o.confType == s.confType) { dup = true; break; }
        if (!dup) out.push_back(s);
    }
    return out;
}
}  // namespace geopro::app
  • Step 5: 跑确认通过cmake --build build && ctest -R DedupeSources → PASS。

  • Step 6: emitCheckedSourcesObjectTreePanel.cpp 实现(替代旧 checkedTmsChanged 收集):

void ObjectTreePanel::emitCheckedSources() {
    std::vector<geopro::data::DataSource> src;
    std::function<void(QTreeWidgetItem*)> walk = [&](QTreeWidgetItem* node) {
        for (int i = 0; i < node->childCount(); ++i) {
            QTreeWidgetItem* c = node->child(i);
            const int ct = c->data(0, kRoleConfType).toInt();
            if (ct == kConfTypeTm && c->checkState(0) == Qt::Checked)
                src.push_back({c->data(0, kRoleObjId).toString().toStdString(), 2});
            if (ct == kConfTypeGs && c->data(0, kRoleGsDsOn).toBool())
                src.push_back({c->data(0, kRoleObjId).toString().toStdString(), 1});
            walk(c);
        }
    };
    walk(tree_->invisibleRootItem());
    // 项目根直挂 ds 固定加入(根节点 idconfType=1    if (tree_->topLevelItemCount() > 0) {
        QTreeWidgetItem* root = tree_->topLevelItem(0);
        if (root->data(0, kRoleIsRoot).toBool())
            src.push_back({root->data(0, kRoleObjId).toString().toStdString(), 1});
    }
    const auto deduped = geopro::app::dedupeSources(std::move(src));
    QList<geopro::data::DataSource> list;
    for (const auto& s : deduped) list.push_back(s);
    emit checkedSourcesChanged(list);
}

把现有 itemChanged 0ms 合并回调(:177-190末尾的 emit checkedTmsChanged(...) 改为 emitCheckedSources(),并对涉及 GS 先 recomputeGsStatesetAllTmsChecked/invertTmChecks 末尾同样改调 emitCheckedSourcescheckedTmsChanged 信号删除无其它消费者后main.cpp 接线在 Task 12 改)。

  • Step 7: build + 提交

Run: cmake --build build(此时 main.cpp 仍连旧信号会编译错——可在 Task 12 一起绿;若分阶段,本 task 暂保留 checkedTmsChanged 与新信号并存Task 12 删旧)。

决策:为保持每 task 可编译,本 task 新增 checkedSourcesChanged 并存、不删 checkedTmsChangedTask 12 接线切换后再删旧信号。

git add src/data/repo/RepoTypes.hpp src/app/panels/ObjectTreeSelection.hpp src/app/panels/ObjectTreePanel.hpp src/app/panels/ObjectTreePanel.cpp tests/app/test_object_tree_selection.cpp
git commit -m "feat(tree): checkedSourcesChanged 带 confType 源集合(去重并集)"

Phase 4 — 装置类型 / 采集时间字典服务

Task 6: DatasetFieldDictionary

Files:

  • Create: src/data/repo/DatasetFieldDictionary.hpp / .cpp
  • Test: tests/data/test_dataset_field_dictionary.cpp
  • Modify: data/tests CMake

Interfaces:

  • Produces:

    • struct DsTypeFields { std::string arrayTypeConfFieldId, collectTimeConfFieldId; std::map<std::string,std::string> arrayTypeLabels; };
    • DsTypeFields parseFieldMapping(const QJsonObject& dynamicFormData);(纯函数,解析 formList
    • class DatasetFieldDictionaryvoid ensureFor(dsTypeCode, sampleDsId, cb)(异步拉 dynamicForm 缓存)、const DsTypeFields* fields(dsTypeCode) conststd::string arrayValueOf(const DsRow&) const(从 properties 取 arrayType 值)、std::string arrayLabel(dsTypeCode, value) constvalue→中文缺失回退原值
  • Step 1: 写失败测试tests/data/test_dataset_field_dictionary.cpp(喂真实 dynamicForm 结构):

#include <gtest/gtest.h>
#include <QJsonDocument>
#include "repo/DatasetFieldDictionary.hpp"
using namespace geopro::data;

TEST(ParseFieldMapping, ExtractsArrayTypeAndCollectTimeConfFieldIds) {
    const QString js = R"({"formList":[{"groupName":"基本信息","values":[
        {"confFieldId":"f_ct","fieldCode":"collectTime","fieldName":"采集时间","optionsObject":null},
        {"confFieldId":"f_at","fieldCode":"arrayType","fieldName":"装置类型","optionsObject":[
            {"label":"温纳-施伦贝尔排列","value":"v1"},{"label":"全梯度","value":"v2"}]}
    ]}]})";
    const QJsonObject data = QJsonDocument::fromJson(js.toUtf8()).object();
    const DsTypeFields f = parseFieldMapping(data);
    EXPECT_EQ(f.arrayTypeConfFieldId, "f_at");
    EXPECT_EQ(f.collectTimeConfFieldId, "f_ct");
    ASSERT_EQ(f.arrayTypeLabels.count("v1"), 1u);
    EXPECT_EQ(f.arrayTypeLabels.at("v1"), "温纳-施伦贝尔排列");
}
  • Step 2: 跑确认失败ctest -R ParseFieldMapping → 编译失败。

  • Step 3: 写 DatasetFieldDictionary.hpp

#pragma once
#include <functional>
#include <map>
#include <string>
#include <QJsonObject>
#include "repo/RepoTypes.hpp"

namespace geopro::data {

// 某 dsType 的字段映射spec §10struct DsTypeFields {
    std::string arrayTypeConfFieldId;     // ds 行 properties 里装置类型项的 confFieldId
    std::string collectTimeConfFieldId;   // 采集时间项的 confFieldId
    std::map<std::string, std::string> arrayTypeLabels;  // value→中文来自 optionsObject
};

// 纯函数:从 dsObject/dynamicForm 的 data 解析字段映射formList → fieldCode==arrayType/collectTimeDsTypeFields parseFieldMapping(const QJsonObject& dynamicFormData);

// ds 行的装置类型原始值properties 中 confFieldId==arrayTypeConfFieldId 的 value缺=空)。
std::string arrayValueOf(const DsRow& row, const DsTypeFields& f);
// ds 行的采集时间properties 中 confFieldId==collectTimeConfFieldId 的 value缺=空)。
std::string collectTimeOf(const DsRow& row, const DsTypeFields& f);

}  // namespace geopro::data
  • Step 4: 写 DatasetFieldDictionary.cpp(纯解析部分)
#include "repo/DatasetFieldDictionary.hpp"
#include <QJsonArray>

namespace geopro::data {

DsTypeFields parseFieldMapping(const QJsonObject& d) {
    DsTypeFields f;
    for (const QJsonValue& gv : d.value(QStringLiteral("formList")).toArray()) {
        for (const QJsonValue& vv : gv.toObject().value(QStringLiteral("values")).toArray()) {
            const QJsonObject fo = vv.toObject();
            const QString code = fo.value(QStringLiteral("fieldCode")).toString();
            const std::string cfid = fo.value(QStringLiteral("confFieldId")).toString().toStdString();
            if (code == QStringLiteral("arrayType")) {
                f.arrayTypeConfFieldId = cfid;
                for (const QJsonValue& ov : fo.value(QStringLiteral("optionsObject")).toArray()) {
                    const QJsonObject oo = ov.toObject();
                    f.arrayTypeLabels[oo.value(QStringLiteral("value")).toString().toStdString()] =
                        oo.value(QStringLiteral("label")).toString().toStdString();
                }
            } else if (code == QStringLiteral("collectTime")) {
                f.collectTimeConfFieldId = cfid;
            }
        }
    }
    return f;
}

static std::string propValue(const DsRow& row, const std::string& cfid) {
    if (cfid.empty()) return {};
    for (const auto& kv : row.properties) if (kv.confFieldId == cfid) return kv.value;
    return {};
}
std::string arrayValueOf(const DsRow& row, const DsTypeFields& f) { return propValue(row, f.arrayTypeConfFieldId); }
std::string collectTimeOf(const DsRow& row, const DsTypeFields& f) { return propValue(row, f.collectTimeConfFieldId); }

}  // namespace geopro::data

装置 value→中文本 task 用 optionsObjectarrayTypeLabels已知风险spec §11:实测某些 ds 的 arrayType 原始值不在 optionsObject 里——此时 arrayLabel 回退显示原值,待坐实正确字典源后只换 arrayTypeLabels 的数据来源、接口不变。

  • Step 5: 跑确认通过cmake --build build && ctest -R ParseFieldMapping → PASS。

  • Step 6: 异步缓存壳(无独立单测,随 Task 7 集成)— 在同文件加按 dsTypeCode 缓存的 DatasetFieldDictionary 类:构造收 IAsyncProjectRepository&(或现有 loadDatasetFormAsync 提供者);ensureFor(dsTypeCode, sampleDsId, cb) 若未缓存则 loadDatasetFormAsync(sampleDsId)parseFieldMapping → 存 std::map<std::string,DsTypeFields> → cb。fields(dsTypeCode) 返回缓存指针或 nullptr。

  • Step 7: 提交

git add src/data/repo/DatasetFieldDictionary.hpp src/data/repo/DatasetFieldDictionary.cpp tests/data/test_dataset_field_dictionary.cpp src/data/CMakeLists.txt tests/data/CMakeLists.txt
git commit -m "feat(data): DatasetFieldDictionary 解析 arrayType/collectTime 映射+装置字典"

Phase 5 — 类型段组件与容器

Task 7: CategorySection段头筛选 + 段体树 + 生成入口)

Files:

  • Create: src/app/panels/columns/CategorySection.hpp / .cpp
  • Modify: app CMake

Interfaces:

  • Consumes: CategorySpecTask 2splitByCategory 桶里的 std::vector<DsRow>DatasetListPanel::populateDatasetList/applyDatasetFilterDatasetFieldDictionaryTask 6
  • Produces:
class CategorySection : public QWidget {
  Q_OBJECT
public:
  CategorySection(const geopro::app::CategorySpec& spec,
                  geopro::data::DatasetFieldDictionary* dict, QWidget* parent=nullptr);
  void setDatasets(const std::vector<geopro::data::DsRow>& rows);
signals:
  void checkedDatasetsChanged(const QStringList& dsIds);            // 数据行勾选=渲染
  void generateVolumeRequested(const QString& dsTypeCode, const QStringList& sourceDsIds);  // 段头「+新增三维体」→带勾选源
  void detailRequested(const QString& dsId, const QString& ddCode, const QString& name);
};

实现要点(.cpp,参照现有 Column3DDataset.cpp 的列表+右键 pattern

  • 段头:标题 QLabelspec.title+ 折叠箭头;spec.hasArrayTypeFilter 为 true 才加装置类型 QComboBox;日期范围两个 QDateEdit

  • 段体:QTreeWidget + applyDatasetCardDelegatesetDatasetspopulateDatasetList(tree_, rows, false) + 让数据行可勾选(同现 Column3DDataset.cpp:170-185

  • 勾选 → checkedDatasetsChanged(同 Column3DDataset.cpp:128-135

  • 筛选:日期/装置类型变化 → 调 applyDatasetFilter(日期比较改 collectTime——经 dict 取值;装置类型按 arrayValueOf 过滤)。装置类型下拉项 = 当前数据经 dict->arrayLabel 去重集合。

  • 生成入口(仅 spec.canGenerateVolume段头「+新增三维体」按钮;点击 → 收集本段当前勾选的源 ds → emit generateVolumeRequested(spec.dsTypeCode, checkedSourceDsIds)(归属「生成位置」与插值参数都在 main 的 VolumeParamsDialog 里选,见 Task 12

  • 段体树(核心实现):段体呈现「项目根 / GS / TM 容器节点 → ds 行」层级spec §4 图)。CategorySectionvoid setStructure(const std::vector<geopro::data::StructNode>& nodes)(对象树同源的扁平 GS/TM 节点main.cpp 传入、Task 8 经 CategoryAnalysisTab 转发、Task 12 接线)。setDatasets 据 structure 先建容器节点(项目根/GSTM 作为 GS 子节点),再把每个 ds 按其 structParentId/structParentConfTypeTask 1 已解析)挂到对应容器下;容器内 ds 间再按 ds.parentId 派生建树(复用 populateDatasetList 逻辑)。(生成三维体不在段体右键——改段头按钮,见上「生成入口」;归属由对话框「生成位置」选,与段体容器节点无关。)

  • Step 1: 写 CategorySection.hpp(按上 Interfaces 完整声明)

  • Step 2: 写 CategorySection.cpp(按实现要点;段头/段体/筛选/右键)

  • Step 3: 注册 CMake

  • Step 4: build

Run: cmake --build build Expected: 编译通过CategorySection 暂未接入 ColumnDrawer仅编译

  • Step 5: 提交
git add src/app/panels/columns/CategorySection.hpp src/app/panels/columns/CategorySection.cpp src/data/repo/RepoTypes.hpp src/data/dto/NavDto.cpp src/app/CMakeLists.txt
git commit -m "feat(ui): CategorySection 类型段组件(段头筛选+段体树+生成入口)"

Task 8: CategoryAnalysisTab 容器 + ColumnDrawer 两 tab

Files:

  • Create: src/app/panels/columns/CategoryAnalysisTab.hpp / .cpp
  • Modify: src/app/panels/columns/ColumnDrawer.{hpp,cpp}
  • Modify: app CMake

Interfaces:

  • Produces:
class CategoryAnalysisTab : public QWidget {  // QScrollArea 堆叠 5 段
  Q_OBJECT
public:
  CategoryAnalysisTab(geopro::data::DatasetFieldDictionary* dict, QWidget* parent=nullptr);
  void setBuckets(const geopro::app::CategoryBuckets& b);   // 分发到 5 段
  CategorySection* section(const std::string& id) const;    // 按 CategorySpec.id 取段
signals:
  void checkedDatasetsChanged(const QStringList& dsIds);     // 5 段勾选合并(并集)上抛
  void generateVolumeRequested(const QString& dsTypeCode, const QStringList& sourceDsIds);
  void detailRequested(const QString&, const QString&, const QString&);
};
  • ColumnDrawercol3D_/colAnalysis_ 两个旧成员替换为 CategoryAnalysisTab* analysisTab_tabs 改为 addTab(analysisTab_, "三维分析") + addTab(col2D_, "二维分析")col3D()/colAnalysis() 访问器替换为 analysisTab()

实现要点: CategoryAnalysisTabQScrollArea + QVBoxLayout,遍历 categoryConfigs() 建 5 个 CategorySection(存 std::map<std::string,CategorySection*>setBucketsb.segments[i] 给第 i 段;把各段 checkedDatasetsChanged 收集成并集再上抛(每段维护各自勾选集,合并)。

  • Step 1: 写 CategoryAnalysisTab.hpp/.cpp
  • Step 2: 改 ColumnDrawer(两 tab删 col3D_/colAnalysis_留 col2D_
  • Step 3: 注册 CMake + build

Run: cmake --build build Expected: ColumnDrawer 编译通过main.cpp 仍引用旧 col3D()/colAnalysis() 会编译错——Task 12 切换。为保持可编译:本 task 暂保留旧 col3D()/colAnalysis() 返回 nullptr 或保留旧成员并存Task 12 删。决策:保留 col2D() + 新增 analysisTab(),旧 col3D()/colAnalysis() 暂留空实现直到 Task 12。

  • Step 4: 提交
git add src/app/panels/columns/CategoryAnalysisTab.hpp src/app/panels/columns/CategoryAnalysisTab.cpp src/app/panels/columns/ColumnDrawer.hpp src/app/panels/columns/ColumnDrawer.cpp src/app/CMakeLists.txt
git commit -m "feat(ui): CategoryAnalysisTab(QScrollArea 5段)+ColumnDrawer 两tab"

Phase 6 — VTK 画布工具条

Task 9: VtkViewToolbar + AxesSettingsDialog

Files:

  • Create: src/app/VtkViewToolbar.hpp / .cppsrc/app/AxesSettingsDialog.hpp / .cpp
  • Modify: app CMake

Interfaces:

  • Produces:
class VtkViewToolbar : public QWidget {  // 竖排:设置/前后上下左右/放大缩小复位
  Q_OBJECT
public:
  explicit VtkViewToolbar(QWidget* parent=nullptr);
signals:
  void axesSettingsRequested();                                  // 设置→弹 AxesSettingsDialog
  void viewRequested(geopro::controller::ViewDir dir);           // 前后上下左右
  void zoomInRequested(); void zoomOutRequested(); void fitRequested();  // 复位=适配
};
struct AxisRange { bool show=true; double min=-500, max=500; };
class AxesSettingsDialog : public QDialog {
  Q_OBJECT
public:
  AxesSettingsDialog(AxisRange x, AxisRange y, AxisRange z, QWidget* parent=nullptr);
  AxisRange x() const; AxisRange y() const; AxisRange z() const;  // 应用后读取
};

实现要点: 工具条三组按钮QToolButton 竖排),图标用现有 makeGlyph/Glyph;信号直接转发。AxesSettingsDialog三组X/Y/深度)各 QCheckBox 显示 + 两 QDoubleSpinBox min/max,底部 取消/应用。视图方向按钮文字「前/后/上/下/左/右」对应 ViewDir::Front/Back/Top/Bottom/Left/Right

  • Step 1: 写 AxesSettingsDialog.hpp/.cpp
  • Step 2: 写 VtkViewToolbar.hpp/.cpp
  • Step 3: 注册 CMake + build

Run: cmake --build build Expected: 编译通过(暂未接入中央视图)。

  • Step 4: 提交
git add src/app/VtkViewToolbar.hpp src/app/VtkViewToolbar.cpp src/app/AxesSettingsDialog.hpp src/app/AxesSettingsDialog.cpp src/app/CMakeLists.txt
git commit -m "feat(ui): VtkViewToolbar 画布工具条 + AxesSettingsDialog"

Phase 7 — Api3dRepository 扩参 + 段重组

Task 10: createVolume/createSlice 扩参 + 请求体 DTO 组装

Files:

  • Modify: src/data/api/Api3dRepository.{hpp,cpp}
  • Test: tests/data/test_api3d_requests.cpp(新增;验证组装的请求体)

Interfaces:

  • Changed:

    • std::string createVolume(const VoxelGenerateRequest& req);(替代旧 (VolumeBuildParams,name);内部 StoredVolume 同时存 req 与从 req 派生的 VolumeBuildParams
    • void createSlice(const SliceSpec& spec, const std::string& name, const std::string& projectId, OnOk, OnErr);(加 projectId
    • const VoxelGenerateRequest* lastVoxelRequest(const std::string& dsId) const;(测试/调试取回组装请求体)
  • Step 1: 写失败测试tests/data/test_api3d_requests.cpp:构造 Api3dRepository(喂 stub dsRepo + framecreateVolume(req),取回 lastVoxelRequest(id)->toJson() 断言含 structParentId/structParentConfType/sourceDatasetIds。 (若 Api3dRepository 构造依赖重改为只测「VolumeBuildParams ← VoxelGenerateRequest 的派生函数」纯函数 + toJson把派生逻辑抽 VolumeBuildParams fromRequest(const VoxelGenerateRequest&) 纯函数单测。)

TEST(Api3dRequestAssembly, VolumeBuildParamsFromRequest) {
    geopro::data::VoxelGenerateRequest q;
    q.sourceDatasetIds = {"d1"}; q.cellXY = 2.0; q.power = 3.0; q.colorScaleId = "cs1";
    const auto p = geopro::data::fromRequest(q);
    EXPECT_EQ(p.sourceDatasetIds.size(), 1u);
    EXPECT_DOUBLE_EQ(p.cellXY, 2.0);
    EXPECT_DOUBLE_EQ(p.power, 3.0);
    EXPECT_EQ(p.colorScaleId, "cs1");
}
  • Step 2: 跑确认失败ctest -R Api3dRequestAssembly → 失败。
  • Step 3: 加 fromRequestVtk3dRequests.{hpp,cpp} 或 Api3dVolumeBuildParams fromRequest(const VoxelGenerateRequest&) 映射字段interpModel 字符串→enum
  • Step 4: 改 createVolume/createSlice 签名createVolume(const VoxelGenerateRequest& req)StoredVolumereq + fromRequest(req);返回 mock id。createSlice 加 projectId内部组装 SliceGenerateRequestqDebug() 打印 toJson())。
  • Step 5: 跑确认通过 + buildcmake --build build && ctest -R Api3dRequestAssembly → PASS。
  • Step 6: 提交
git add src/data/api/Api3dRepository.hpp src/data/api/Api3dRepository.cpp src/data/dto/Vtk3dRequests.hpp src/data/dto/Vtk3dRequests.cpp tests/data/test_api3d_requests.cpp tests/data/CMakeLists.txt
git commit -m "feat(data): createVolume/createSlice 扩参+请求体DTO组装(mock)"

Task 11: 三维体段「体→切片/异常」三级树 + 异常按归属挂体/切片spec §8

设计修订2026-06-24:取消「独立异常区」的旧设计。异常不再单列、不再"随当前活动体"展示,而是作为叶子挂在它归属的实体节点(体 或 切片)下。归属按 异常→所在切片→切片所属体 链确定,挂载目标由「切片是否已保存成 dd_slice」决定见 spec §8。多体渲染本就支持VtkSceneView::dsProps_ 按 dsId 各存 actorvolumeOwnerDs_/currentVolumeImage_ = 当前切片操作所基于的体(非"唯一可渲体")。

Files:

  • Modify: src/core/model/Anomaly.hppvolumeDsIdremarkSourceId + remarkSourceType
  • Modify: src/data/api/Api3dRepository.{hpp,cpp}saveAnomaly/loadAnomalyTree mock 按 remarkSource 存/查anomalyRows 供树注入)
  • Modify: src/app/main.cpp(创建异常时判断所在切片是否已保存 → 设 remarkSourceId/TyperefreshAnomalies 改注入三维体段树)
  • Modify: src/app/panels/columns/CategorySection.cpp / CategoryAnalysisTab.{hpp,cpp}(三维体段三级树 + 切片/异常勾选·详情·删除转发)
  • Test: tests/...remarkSource 归属判定纯函数 + Api3d 异常 mock 存查)

Interfaces / 数据模型:

  • AnomalyvolumeDsId 改名为 std::string remarkSourceId;(挂载实体 dsId = 体 or 切片)。不加 type 字段——挂体/挂切片由 remarkSourceIdisVolume/isSlice 区分,展示树按 parentId=remarkSourceId 自动挂载。(⚠️ 后端 remarkSourceType 是标注几何形态 1-4 = markType,勿混。)

  • 纯函数(可单测):std::string resolveAnomalyMount(bool sliceIsSaved, const std::string& savedSliceDsId, const std::string& volumeDsId); —— 已保存切片→savedSliceDsId;否则→volumeDsId。返回挂载实体 dsId= remarkSourceId

  • Step 1逻辑层可单测: Anomaly 模型 + resolveAnomalyMountAnomalyremarkSourceId/Type全量改其引用点VtkSceneView addAnomaly/removeAnomaly 按 id 跟踪不受影响,仅 main 创建处赋值变);写 resolveAnomalyMount 纯函数 + 单测(已保存切片挂切片 / 临时切片挂体 两例)。build test 绿

  • Step 2逻辑层: Api3dRepository 异常 mock 按归属存查 StoredAnomaly 按 remarkSourceId/Type 存;loadAnomalyTree(sourceId) 或新增 anomalyRows(remarkSourceId) 返回该体/切片下异常行DsRow 形态ddCode 自定如 dd_anomalyparentId=remarkSourceId供树注入saveAnomaly 存 remarkSource。单测挂体/挂切片分别能查回。

  • Step 3: main 创建异常逻辑main:~502 区) 画异常时从 interactionMgr 取当前选中切片状态:已保存切片→其 dsIdselectSavedSlice/selectedSavedSliceId 核实接口);临时切片→volumeOwnerDs_。调 resolveAnomalyMounta.remarkSourceId/remarkSourceType。(先核实 interactionMgr 如何区分"当前切片已保存 vs 临时"+取其 dsId,不凭印象。)

  • Step 4UI需真实验证: 三维体段三级树 refreshAnomalies 改:把异常行按 remarkSourceId 注入三维体段树——挂体异常作体节点子、挂切片异常作切片节点子切片作体节点子parentId=volumeDsIdCategoryAnalysisTab/CategorySectionvoxel 段)补:体/切片/异常三级建树 + 异常/切片勾选·详情·删除信号转发(迁 Column3DAnalysis 对应控件逻辑)。

  • Step 5: build + 手动验证(生成体→体节点;体上画异常(切片未存)→异常挂体下;保存切片→切片挂体下;切片上画异常→异常挂该切片下;勾选/详情/删除各级生效)

  • Step 6: 提交(分逻辑层[Step1-3] 与 UI[Step4] 两 commit前者可 build test 绿,后者 build app + 真实验证)

波及Anomaly.volumeDsId 改名会触及现有所有读取点VtkSceneView 渲染按 worldPts/plane不读 volumeDsIdmain saveAnomaly 赋值Api3dRepository StoredAnomaly——Step 1 一并改全。切片保存/关闭main setItemChecked随三级树勾选 API 一并迁。


Phase 8 — main.cpp 接线总成

Task 12: 数据流接线 + 退役旧栏

Files:

  • Modify: src/app/main.cpp:388-460 异常/勾选聚合、:1113-1225 对象树→分类分发)
  • Modify: src/app/panels/ObjectTreePanel.{hpp,cpp}(删旧 checkedTmsChanged
  • Modify: src/app/panels/columns/ColumnDrawer.{hpp,cpp}(删旧 col3D()/colAnalysis()
  • Delete refs: Column3DDataset / Column3DAnalysis(源文件可留待后续清理,先解除 main 引用)

接线改动:

  1. 对象树勾选:objectTree::checkedTmsChangedcheckedSourcesChanged(QList<DataSource>)。回调内对每源 loadRowsAsync(projId, src.id, src.confType, /*classify*/3, 1, 100000)第3参 src.confType 取代字面量 2,见 ApiProjectRepository.cpp:81-89 透传),汇总 DsRow[](保留现有 generation 防陈旧 + 多源计数 finish
  2. 分类分发:finishsplitByCategory(*acc)drawer->analysisTab()->setBuckets(b)(取代旧 col3D/col2D/colAnalysis setDatasets + splitByDimension。二维数据dd_trajectory_data 等)仍走 drawer->col2D()->setDatasets(...)——注意splitByCategory 只产 5 个 3D 段,二维数据需单独分出:保留一个 dim2D 过滤trajectory 类)喂 col2D。
  3. 渲染勾选并集:analysisTab::checkedDatasetsChanged5 段并集,含帘面源=电阻率/视电阻率/瞬变 + 体素/切片)→ 并入 checkedProfiles/checkedAnalysispushChecked()(沿用现 :457-462 并集模型)。
  4. 生成三维体:analysisTab::generateVolumeRequested(dsTypeCode, sourceDsIds) → 弹 VolumeParamsDialog左侧勾选源 ds 树·按 GS 分组·可二次增删确认;右侧参数含「生成位置」下拉 = 项目内 GS/TM 列表,默认源单 GS→该 GS、跨 GS→项目根→ 用户确认 → 组装 VoxelGenerateRequest{projectId, structParentId=所选生成位置, structParentConfType=1或2, name, sourceDatasetIds=对话框最终勾选, params}scene3dRepo->createVolume(req)refreshAnalysis()VolumeParamsDialog 需扩:左侧源列表(含二次增删)+ 「生成位置」下拉(项目 GS/TM 列表来自对象树结构,按默认规则预选)。
  5. 切片保存调用点补 nav.currentProjectId():581/716+ createSlice 调用)。
  6. 工具条接入中央视图:实例化 VtkViewToolbar 叠加在 QVTK 上,信号接 sceneCtrlaxesSettingsRequested→弹 AxesSettingsDialog→应用到坐标轴viewRequested/zoom*/fit 接现有槽,迁自 Column3DDataset 接线 :898 等);setVerticalExaggeration 默认回灌迁到工具条对应控件。
  7. 删旧信号/访问器/splitByDimension 引用。
  • Step 1: 改对象树勾选接线confType 分流拉取 + splitByCategory 分发)
  • Step 2: 改渲染勾选并集analysisTab 并集→pushChecked
  • Step 3: 改生成三维体接线(组装 VoxelGenerateRequest
  • Step 4: 接入工具条 + 坐标轴对话框
  • Step 5: 切片保存补 projectId
  • Step 6: 删旧信号/访问器/Column3D 引用 + splitByDimension*
  • Step 7: build + 全量手动回归

Run: cmake --build build && ctest --test-dir build --output-on-failure Expected: 全绿 + 编译通过。手动回归清单:

  1. 勾对象树 GS/TM/项目根 → 对应数据进 5 个大类段(电阻率/视电阻率/瞬变/三维体/切片各就各位)。
  2. 勾电阻率某行 → 中央出帘面(原渲染不丢)。
  3. GS 节点右键「生成三维体」→ 弹参数对话框 → 生成体进三维体段 → 勾选出体素。
  4. 工具条:设置弹坐标轴对话框、前后上下左右切视图、放大/缩小/复位生效。
  5. 日期/装置类型筛选(电阻率段)生效;瞬变段无装置类型下拉。
  6. 二维分析 tab 不变(足迹照常)。
  7. 控制台可见 createVolume/createSlice 打印的真实请求体 JSON。
  • Step 8: 提交
git add src/app/main.cpp src/app/panels/ObjectTreePanel.hpp src/app/panels/ObjectTreePanel.cpp src/app/panels/columns/ColumnDrawer.hpp src/app/panels/columns/ColumnDrawer.cpp
git commit -m "refactor(app): main 接线总成-分类分发/勾选并集/生成归属/工具条+退役旧栏"

自审spec 覆盖核对)

spec 节 覆盖任务
§4 整体架构(两 tab/5 段/工具条) Task 8, 9, 12
§5 DsRow 扩展 + CategoryConfig + splitByCategory Task 1, 2
§6 对象树 GS 三态 + 信号 + 数据流 Task 4, 5, 12
§7 CategorySection段头/段体/生成入口/勾选承接/双重过滤) Task 7, 12
§8 三维体/切片/异常段 + 请求体组装 Task 10, 11, 12
§9 VtkViewToolbar + AxesSettingsDialog + setVE 回灌 Task 9, 12
§10 DatasetFieldDictionary + collectTime 筛选 Task 6, 7
§11 装置 value→中文待坐实 Task 6回退原值 + 注明)
§12 组件/文件边界 全部

待坐实(不阻塞实施,实现中坐实): 装置类型 value→中文 字典源Task 6 注明,待属性面板截图)。

Phase 间编译连续性: Task 5/8 采用「新旧并存」过渡(保留旧信号/访问器Task 12 统一切换并删旧——保证每次提交可编译。