58 KiB
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 / Qt6(Widgets)/ VTK / CMake + CTest + GoogleTest。
设计依据:docs/superpowers/specs/2026-06-24-vtk-category-view-refactor-design.md(spec 各节在任务中以「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 INVERSION(dsTypeCode,三者 ddCode 同为dd_inversion_data);三维体=dd_voxel、切片=dd_slice(ddCode)。 - 层级
structParentConfType:1=GS/项目根,2=TM。 - 装置类型只电阻率/视电阻率段有,瞬变电磁/三维体/切片无。
- 三维体/切片/异常仍走
Api3dRepositorymock(不切真实后端)。 - 提交信息用 conventional commits(feat/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/.cpp—splitByCategory(替代DatasetDimension的splitByDimension)。src/data/dto/Vtk3dRequests.hpp/.cpp—VoxelGenerateRequest/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.cpp、tests/data/test_vtk3d_requests.cpp、tests/data/test_dataset_field_dictionary.cpp;扩tests/data/test_nav_dto.cpp。
修改:
src/data/repo/RepoTypes.hpp—DsRow加dsTypeCode+properties(原始 KV)。src/data/dto/NavDto.cpp—parseDsRows解析新字段。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.txt(app 与 tests)。
退役(功能迁出后删除引用): Column3DDataset、Column3DAnalysis(拆分到 CategorySection / VtkViewToolbar / AxesSettingsDialog / 三维体段)。
依赖顺序: Phase 1(数据模型+分类)→ Phase 2(DTO)→ Phase 3(对象树)→ Phase 4(字典服务)→ Phase 5(段+容器)→ Phase 6(工具条)→ Phase 7(Api3d 扩参+段重组)→ Phase 8(main 接线总成)。
Phase 1 — 数据模型与分类层
Task 1: DsRow 扩展 + parseDsRows 解析
Files:
- Modify:
src/data/repo/RepoTypes.hpp(DsRow结构) - Modify:
src/data/dto/NavDto.cpp:116-137(parseDsRows) - 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: 扩 DsRow —
src/data/repo/RepoTypes.hpp在DsRow上方加 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 §5;ddCode 粒度不足以区分电阻率/视电阻率)
std::vector<DsPropKV> properties; // 原始 confFieldId→value;装置类型/采集时间经 DatasetFieldDictionary 解析
std::string structParentId; // 上级节点 id(段体容器分组 + 生成三维体归属用)
int structParentConfType = 0; // 1=GS/项目根 2=TM
};
- Step 4: 扩 parseDsRows —
src/data/dto/NavDto.cpp在d.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()});
}
注:
value用toVariant().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.cpp)、tests/.../CMakeLists.txt(注册测试)
Interfaces:
-
Consumes:
DsRow(Task 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.cpp按test_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.jsonschema):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 VoxelGenerateRequest(spec §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 新增 UserRole:
kRoleGsDsOn(GS 自身 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)。
// 无子 TM(totalTmCount==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-125,else { ... 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 层级的项目):
- 勾 GS 复选框 → 变全黑、其下 TM 全勾。
- 再点 GS → 变空、TM 全不勾。
- 右键 GS→选择▸ds 打勾 → GS 变灰(仅自身 ds)。
- 右键 GS→选择▸tm 打勾 → 子 TM 全勾、GS 若 ds 也开则变黑、否则灰。
- 单独勾一个子 TM → GS 变灰、tm 菜单对号消失。
- 无子 TM 的 GS:ds 开/关 → 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.hpp(DataSource) - 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: 加 DataSource —
RepoTypes.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: 加 dedupeSources —
ObjectTreeSelection.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: emitCheckedSources —
ObjectTreePanel.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 固定加入(根节点 id,confType=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 先 recomputeGsState。setAllTmsChecked/invertTmChecks 末尾同样改调 emitCheckedSources。checkedTmsChanged 信号删除(无其它消费者后;main.cpp 接线在 Task 12 改)。
- Step 7: build + 提交
Run: cmake --build build(此时 main.cpp 仍连旧信号会编译错——可在 Task 12 一起绿;若分阶段,本 task 暂保留 checkedTmsChanged 与新信号并存,Task 12 删旧)。
决策:为保持每 task 可编译,本 task 新增
checkedSourcesChanged并存、不删checkedTmsChanged;Task 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
DatasetFieldDictionary:void ensureFor(dsTypeCode, sampleDsId, cb)(异步拉 dynamicForm 缓存)、const DsTypeFields* fields(dsTypeCode) const、std::string arrayValueOf(const DsRow&) const(从 properties 取 arrayType 值)、std::string arrayLabel(dsTypeCode, value) const(value→中文,缺失回退原值)。
-
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 §10)。
struct 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/collectTime)。
DsTypeFields 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 用
optionsObject建arrayTypeLabels。已知风险(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:
CategorySpec(Task 2)、splitByCategory桶里的std::vector<DsRow>、DatasetListPanel::populateDatasetList/applyDatasetFilter、DatasetFieldDictionary(Task 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):
-
段头:标题
QLabel(spec.title)+ 折叠箭头;spec.hasArrayTypeFilter为 true 才加装置类型QComboBox;日期范围两个QDateEdit。 -
段体:
QTreeWidget+applyDatasetCardDelegate;setDatasets调populateDatasetList(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 图)。
CategorySection加void setStructure(const std::vector<geopro::data::StructNode>& nodes)(对象树同源的扁平 GS/TM 节点;main.cpp 传入、Task 8 经 CategoryAnalysisTab 转发、Task 12 接线)。setDatasets据 structure 先建容器节点(项目根/GS;TM 作为 GS 子节点),再把每个 ds 按其structParentId/structParentConfType(Task 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&);
};
ColumnDrawer:col3D_/colAnalysis_两个旧成员替换为CategoryAnalysisTab* analysisTab_;tabs 改为addTab(analysisTab_, "三维分析")+addTab(col2D_, "二维分析")。col3D()/colAnalysis()访问器替换为analysisTab()。
实现要点: CategoryAnalysisTab 内 QScrollArea + QVBoxLayout,遍历 categoryConfigs() 建 5 个 CategorySection(存 std::map<std::string,CategorySection*>);setBuckets 把 b.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/.cpp、src/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 + frame),调createVolume(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: 加
fromRequest(Vtk3dRequests.{hpp,cpp}或 Api3d):VolumeBuildParams fromRequest(const VoxelGenerateRequest&)映射字段(interpModel 字符串→enum)。 - Step 4: 改 createVolume/createSlice 签名 —
createVolume(const VoxelGenerateRequest& req):StoredVolume存req+fromRequest(req);返回 mock id。createSlice加 projectId,内部组装SliceGenerateRequest(qDebug()打印toJson())。 - Step 5: 跑确认通过 + build —
cmake --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 各存 actor);volumeOwnerDs_/currentVolumeImage_= 当前切片操作所基于的体(非"唯一可渲体")。
Files:
- Modify:
src/core/model/Anomaly.hpp(volumeDsId→remarkSourceId+remarkSourceType) - Modify:
src/data/api/Api3dRepository.{hpp,cpp}(saveAnomaly/loadAnomalyTreemock 按 remarkSource 存/查;anomalyRows 供树注入) - Modify:
src/app/main.cpp(创建异常时判断所在切片是否已保存 → 设 remarkSourceId/Type;refreshAnomalies 改注入三维体段树) - Modify:
src/app/panels/columns/CategorySection.cpp/CategoryAnalysisTab.{hpp,cpp}(三维体段三级树 + 切片/异常勾选·详情·删除转发) - Test:
tests/...(remarkSource 归属判定纯函数 + Api3d 异常 mock 存查)
Interfaces / 数据模型:
-
Anomaly:volumeDsId改名为std::string remarkSourceId;(挂载实体 dsId = 体 or 切片)。不加 type 字段——挂体/挂切片由remarkSourceId查isVolume/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 模型 + resolveAnomalyMount 改
Anomaly(remarkSourceId/Type,全量改其引用点:VtkSceneView addAnomaly/removeAnomaly 按 id 跟踪不受影响,仅 main 创建处赋值变);写resolveAnomalyMount纯函数 + 单测(已保存切片挂切片 / 临时切片挂体 两例)。build test 绿。 -
Step 2(逻辑层): Api3dRepository 异常 mock 按归属存查
StoredAnomaly按 remarkSourceId/Type 存;loadAnomalyTree(sourceId)或新增anomalyRows(remarkSourceId)返回该体/切片下异常行(DsRow 形态,ddCode 自定如dd_anomaly,parentId=remarkSourceId)供树注入;saveAnomaly存 remarkSource。单测:挂体/挂切片分别能查回。 -
Step 3: main 创建异常逻辑(main:~502 区) 画异常时从
interactionMgr取当前选中切片状态:已保存切片→其 dsId(selectSavedSlice/selectedSavedSliceId核实接口);临时切片→volumeOwnerDs_。调resolveAnomalyMount设a.remarkSourceId/remarkSourceType。(先核实 interactionMgr 如何区分"当前切片已保存 vs 临时"+取其 dsId,不凭印象。) -
Step 4(UI,需真实验证): 三维体段三级树
refreshAnomalies改:把异常行按 remarkSourceId 注入三维体段树——挂体异常作体节点子、挂切片异常作切片节点子;切片作体节点子(parentId=volumeDsId)。CategoryAnalysisTab/CategorySection(voxel 段)补:体/切片/异常三级建树 + 异常/切片勾选·详情·删除信号转发(迁Column3DAnalysis对应控件逻辑)。 -
Step 5: build + 手动验证(生成体→体节点;体上画异常(切片未存)→异常挂体下;保存切片→切片挂体下;切片上画异常→异常挂该切片下;勾选/详情/删除各级生效)
-
Step 6: 提交(分逻辑层[Step1-3] 与 UI[Step4] 两 commit;前者可 build test 绿,后者 build app + 真实验证)
波及:
Anomaly.volumeDsId改名会触及现有所有读取点(VtkSceneView 渲染按 worldPts/plane,不读 volumeDsId;main 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 引用)
接线改动:
- 对象树勾选:
objectTree::checkedTmsChanged→checkedSourcesChanged(QList<DataSource>)。回调内对每源loadRowsAsync(projId, src.id, src.confType, /*classify*/3, 1, 100000)(第3参src.confType取代字面量2,见 ApiProjectRepository.cpp:81-89 透传),汇总DsRow[](保留现有generation防陈旧 + 多源计数 finish)。 - 分类分发:
finish内splitByCategory(*acc)→drawer->analysisTab()->setBuckets(b)(取代旧col3D/col2D/colAnalysis setDatasets+splitByDimension)。二维数据(dd_trajectory_data 等)仍走drawer->col2D()->setDatasets(...)——注意:splitByCategory 只产 5 个 3D 段,二维数据需单独分出:保留一个dim2D过滤(trajectory 类)喂 col2D。 - 渲染勾选并集:
analysisTab::checkedDatasetsChanged(5 段并集,含帘面源=电阻率/视电阻率/瞬变 + 体素/切片)→ 并入checkedProfiles/checkedAnalysis→pushChecked()(沿用现 :457-462 并集模型)。 - 生成三维体:
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 列表来自对象树结构,按默认规则预选)。 - 切片保存调用点补
nav.currentProjectId()(:581/716+ createSlice 调用)。 - 工具条接入中央视图:实例化
VtkViewToolbar叠加在 QVTK 上,信号接sceneCtrl(axesSettingsRequested→弹 AxesSettingsDialog→应用到坐标轴;viewRequested/zoom*/fit 接现有槽,迁自 Column3DDataset 接线 :898 等);setVerticalExaggeration默认回灌迁到工具条对应控件。 - 删旧信号/访问器/
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: 全绿 + 编译通过。手动回归清单:
- 勾对象树 GS/TM/项目根 → 对应数据进 5 个大类段(电阻率/视电阻率/瞬变/三维体/切片各就各位)。
- 勾电阻率某行 → 中央出帘面(原渲染不丢)。
- GS 节点右键「生成三维体」→ 弹参数对话框 → 生成体进三维体段 → 勾选出体素。
- 工具条:设置弹坐标轴对话框、前后上下左右切视图、放大/缩小/复位生效。
- 日期/装置类型筛选(电阻率段)生效;瞬变段无装置类型下拉。
- 二维分析 tab 不变(足迹照常)。
- 控制台可见 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 统一切换并删旧——保证每次提交可编译。