feat/vtk-3d-view #7

Merged
gaozheng merged 301 commits from feat/vtk-3d-view into main 2026-06-27 18:43:52 +08:00
10 changed files with 419 additions and 3 deletions
Showing only changes of commit b97ea68109 - Show all commits

View File

@ -0,0 +1,116 @@
# 设计:三维体/切片 数据详情(只读属性对话框)
> 日期 2026-06-18。分支 `feat/vtk-3d-view`。收尾/打磨项 #6(见 `docs/superpowers/HANDOFF-vtk-3d.md` §4 末「下一步候选」)。
> 异常详情已用对话框做掉(`AnomalyPropertiesDialog`),本设计为**三维体 / 切片**补同类只读详情。
## 1. 目标与范围
三维分析栏右键「数据详情」时,弹出只读属性对话框展示该三维体 / 切片的元数据与统计。
- **形态**:只读 `QDialog`(仿 `AnomalyPropertiesDialog`),非停靠面板页签。
- 取舍理由:现成 `DatasetDetailController/Panel` 绑定 2D 的 `IAsyncDatasetRepository` + chartRegistry而体/切片数据在 `Api3dRepository`(独立 3D 仓储),硬接需跨仓储桥接 + 新策略/视图,代价大、动共享设施风险高。对话框与刚落地的异常详情 UX 一致、零侵入 2D 管线。
- **内容范围**:参数/位姿随时可取;三维体统计(值域/测点数/范围体被生成loadVolume 缓存)后才显示,未生成显「—(生成/渲染后可见)」。
## 2. 架构与新增文件
仿 `src/app/AnomalyPropertiesDialog.{hpp,cpp}``QFormLayout` + `QLabel` 只读表:
| 文件 | 职责 |
|------|------|
| `src/app/VolumePropertiesDialog.{hpp,cpp}` | 三维体属性(参数 + 统计) |
| `src/app/SlicePropertiesDialog.{hpp,cpp}` | 切片属性(位姿 + 参数) |
两个对话框各自独立、构造即填充、`exec()` 模态,无网络、无加载态。
## 3. 数据获取
只改具体类 `src/data/api/Api3dRepository.{hpp,cpp}`**接口 `I3dSceneRepository``LocalSample3dRepository` 不动**`main.cpp` 持有具体 `scene3dRepo`,见 main.cpp:266全程直接用
### 3.1 三维体 getter新增
```cpp
// Api3dRepository.hpp 内嵌结构 + 方法
struct VolumeInfo {
VolumeBuildParams params;
std::string name;
bool loaded = false; // cachedGrid 是否已就绪(= loadVolume 跑过)
// 以下仅 loaded 时有效:
double vmin = 0.0, vmax = 0.0; // 来自 cachedGrid
int nx = 0, ny = 0, nz = 0; // 网格维度
double dx = 0, dy = 0, dz = 0; // 单元间距(来自 cachedGrid.spacing
std::size_t pointCount = 0; // 聚合后参与插值的散点数
};
bool volumeInfo(const std::string& dsId, VolumeInfo& out) const; // 非体返回 false
```
- `loaded``StoredVolume::cachedGrid.has_value()`;统计字段从 `cachedGrid`vmin/vmax、`vol.nx()/ny()/nz()`、`spacing`)填。
- **测点数持久化**`StoredVolume` 增 `std::optional<std::size_t> pointCount`,在 `finalizeVolume`(散点聚合完成处)写入 `pts.v.size()`。`volumeInfo` 透出。
### 3.2 切片数据
复用已有 `bool sliceSpec(const std::string& dsId, SliceSpec& out) const`main.cpp 已在用)取位姿;名称用 `detailRequested` 信号已携带的 `name`,不新增 getter。
## 4. 触发与接线(`main.cpp`
`detailRequested` 仅来自三维分析栏(`Column3DAnalysis`,项非体即切片;右键菜单「数据详情」已接,无需改 Column3DAnalysis现连接 `detailCtrl.openDataset`(对 3D dsId 会降级失败)。改为按 ddCode 分派:
```cpp
QObject::connect(ca, &Column3DAnalysis::detailRequested, &window,
[&window, scene3dRepo](const QString& dsId, const QString& ddCode, const QString& name) {
if (ddCode == QStringLiteral("dd_slice")) {
I3dSceneRepository::SliceSpec sp;
if (scene3dRepo->sliceSpec(dsId.toStdString(), sp)) {
SlicePropertiesDialog dlg(name, sp, &window); dlg.exec();
}
} else { // dd_voxel
Api3dRepository::VolumeInfo info;
if (scene3dRepo->volumeInfo(dsId.toStdString(), info)) {
VolumePropertiesDialog dlg(name, info, &window); dlg.exec();
}
}
});
```
`src/app/CMakeLists.txt` 加两个新 `.cpp`
## 5. 内容字段
### 三维体(`VolumePropertiesDialog`
- 名称
- 源数据集(`sourceDatasetIds`,逗号连接)
- 插值模型IDW / Kriging+ 幂指数IDW 时显 `power`
- 网格间距(`XY=cellXY m Z=cellZ m`
- 超距(`maxDist m`
- 色阶来源(`colorScaleId`,空显「首个源数据集」)
- **统计**loaded 才有,否则全显「—(生成/渲染后可见)」):
- 值域(`vmin ~ vmax`
- 网格(`nx × ny × nz`
- 测点数(`pointCount`
- 范围(`nx·dx × ny·dy × nz·dz` 米)
### 切片(`SlicePropertiesDialog`
- 名称
- 所属三维体(`volumeDsId`
- 轴向0 上下 / 1 前后 / 2 左右 / 3 任意)
- 平面三点 Origin / Point1 / Point2`(x, y, z)`2 位小数)
- 色阶来源(`colorScaleId`,空显「首个源数据集」)
> 切片**不含统计项**:采样分辨率/值域来自渲染时的切面网格,仓储层不持久化(`StoredSlice` 仅存 `spec`+`name`)。回写渲染产物属额外 plumbing守 YAGNI 不做。位姿/参数已完整。
## 6. 错误处理
- `volumeInfo` / `sliceSpec` 取不到(非体/非切片)→ 返回 false不弹空对话框理论不发生触发来自该行
- 统计未就绪 → 占位「—(生成/渲染后可见)」,不报错。
## 7. 测试
- 新增 gtest`tests/` 内 Api3dRepository 测套,若无则新建)覆盖 `volumeInfo`
- `createVolume` 后、`loadVolume` 前:`volumeInfo` 返回 true、`params`/`name` 正确、`loaded=false`、`pointCount=0`。
- `loadVolume` 成功后:`loaded=true`、`vmin<vmax`、`nx/ny/nz>0`、`pointCount>0`。
- 非体 dsId返回 false。
- 对话框为纯只读 UI无逻辑分支不做单测靠 GUI 实测Claude 无法 GUI 验证,交用户)。
## 8. 影响面 / 不变量
- 接口 `I3dSceneRepository``LocalSample3dRepository` 零改动 → 真实后端就绪后切换不受影响。
- `finalizeVolume` 仅多写一个 `pointCount`,不改插值/渲染行为。
- 不与 VTK 三维视图交互(详情只读查阅,职责清晰)。

View File

@ -68,7 +68,9 @@ add_executable(geopro_desktop WIN32
AnomalyPropertiesDialog.cpp
SettingsDialog.cpp
SliceExport.cpp
SlicePropertiesDialog.cpp
VolumeParamsDialog.cpp
VolumePropertiesDialog.cpp
Logging.cpp
DatasetDimension.cpp
TileBasemap.cpp)

View File

@ -0,0 +1,64 @@
#include "SlicePropertiesDialog.hpp"
#include <QDialogButtonBox>
#include <QFormLayout>
#include <QLabel>
#include <QVBoxLayout>
#include <array>
namespace geopro::app {
namespace {
using SliceSpec = geopro::data::I3dSceneRepository::SliceSpec;
QString axisLabel(int axis) {
switch (axis) {
case 0: return QStringLiteral("上下");
case 1: return QStringLiteral("前后");
case 2: return QStringLiteral("左右");
case 3: return QStringLiteral("任意");
default: return QStringLiteral("");
}
}
QString pointLabel(const std::array<double, 3>& p) {
return QStringLiteral("(%1, %2, %3)")
.arg(p[0], 0, 'f', 2)
.arg(p[1], 0, 'f', 2)
.arg(p[2], 0, 'f', 2);
}
} // namespace
SlicePropertiesDialog::SlicePropertiesDialog(const QString& name, const SliceSpec& spec,
QWidget* parent)
: QDialog(parent) {
setWindowTitle(QStringLiteral("切片属性"));
setModal(true);
auto* root = new QVBoxLayout(this);
auto* form = new QFormLayout();
form->addRow(QStringLiteral("名称"),
new QLabel(name.isEmpty() ? QStringLiteral("") : name));
form->addRow(QStringLiteral("所属三维体"),
new QLabel(spec.volumeDsId.empty() ? QStringLiteral("")
: QString::fromStdString(spec.volumeDsId)));
form->addRow(QStringLiteral("轴向"), new QLabel(axisLabel(spec.axis)));
form->addRow(QStringLiteral("Origin"), new QLabel(pointLabel(spec.origin)));
form->addRow(QStringLiteral("Point1"), new QLabel(pointLabel(spec.point1)));
form->addRow(QStringLiteral("Point2"), new QLabel(pointLabel(spec.point2)));
form->addRow(QStringLiteral("色阶来源"),
new QLabel(spec.colorScaleId.empty()
? QStringLiteral("首个源数据集")
: QString::fromStdString(spec.colorScaleId)));
root->addLayout(form);
auto* buttons = new QDialogButtonBox(QDialogButtonBox::Close);
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
root->addWidget(buttons);
}
} // namespace geopro::app

View File

@ -0,0 +1,20 @@
#pragma once
#include <QDialog>
#include <QString>
#include "repo/I3dSceneRepository.hpp" // I3dSceneRepository::SliceSpec
namespace geopro::app {
// 切片属性对话框(收尾项 #6三维分析栏右键「数据详情」弹出只读展示切片的
// 位姿/参数(所属三维体/轴向/平面三点/色阶)。
// 不含采样分辨率/值域等统计:切面网格来自渲染时计算、仓储层不持久化(守 YAGNI
class SlicePropertiesDialog : public QDialog {
Q_OBJECT
public:
SlicePropertiesDialog(const QString& name,
const geopro::data::I3dSceneRepository::SliceSpec& spec,
QWidget* parent = nullptr);
};
} // namespace geopro::app

View File

@ -0,0 +1,84 @@
#include "VolumePropertiesDialog.hpp"
#include <QDialogButtonBox>
#include <QFormLayout>
#include <QLabel>
#include <QStringList>
#include <QVBoxLayout>
namespace geopro::app {
namespace {
using VolumeInfo = geopro::data::Api3dRepository::VolumeInfo;
using Model = geopro::data::VolumeBuildParams::Model;
constexpr const char* kPending = "—(生成/渲染后可见)";
QString joinSources(const std::vector<std::string>& ids) {
if (ids.empty()) return QStringLiteral("");
QStringList list;
for (const auto& s : ids) list << QString::fromStdString(s);
return list.join(QStringLiteral(", "));
}
QString modelLabel(const geopro::data::VolumeBuildParams& p) {
if (p.interpModel == Model::Idw)
return QStringLiteral("IDW幂=%1").arg(p.power, 0, 'f', 1);
return QStringLiteral("Kriging");
}
} // namespace
VolumePropertiesDialog::VolumePropertiesDialog(const QString& name, const VolumeInfo& info,
QWidget* parent)
: QDialog(parent) {
setWindowTitle(QStringLiteral("三维体属性"));
setModal(true);
auto* root = new QVBoxLayout(this);
auto* form = new QFormLayout();
// ── 参数(随时可取)─────────────────────────────────────────────
form->addRow(QStringLiteral("名称"),
new QLabel(name.isEmpty() ? QStringLiteral("") : name));
form->addRow(QStringLiteral("源数据集"), new QLabel(joinSources(info.params.sourceDatasetIds)));
form->addRow(QStringLiteral("插值模型"), new QLabel(modelLabel(info.params)));
form->addRow(QStringLiteral("网格间距"),
new QLabel(QStringLiteral("XY=%1 m Z=%2 m")
.arg(info.params.cellXY, 0, 'f', 2)
.arg(info.params.cellZ, 0, 'f', 2)));
form->addRow(QStringLiteral("超距"),
new QLabel(QStringLiteral("%1 m").arg(info.params.maxDist, 0, 'f', 2)));
form->addRow(QStringLiteral("色阶来源"),
new QLabel(info.params.colorScaleId.empty()
? QStringLiteral("首个源数据集")
: QString::fromStdString(info.params.colorScaleId)));
// ── 统计(仅 loaded 时有效)──────────────────────────────────────
if (info.loaded) {
form->addRow(QStringLiteral("值域"), new QLabel(QStringLiteral("%1 ~ %2")
.arg(info.vmin, 0, 'f', 2)
.arg(info.vmax, 0, 'f', 2)));
form->addRow(QStringLiteral("网格"), new QLabel(QStringLiteral("%1 × %2 × %3")
.arg(info.nx)
.arg(info.ny)
.arg(info.nz)));
form->addRow(QStringLiteral("测点数"),
new QLabel(QString::number(static_cast<qulonglong>(info.pointCount))));
form->addRow(QStringLiteral("范围"),
new QLabel(QStringLiteral("%1 × %2 × %3 m")
.arg(info.nx * info.dx, 0, 'f', 1)
.arg(info.ny * info.dy, 0, 'f', 1)
.arg(info.nz * info.dz, 0, 'f', 1)));
} else {
form->addRow(QStringLiteral("统计"), new QLabel(QString::fromUtf8(kPending)));
}
root->addLayout(form);
auto* buttons = new QDialogButtonBox(QDialogButtonBox::Close);
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
root->addWidget(buttons);
}
} // namespace geopro::app

View File

@ -0,0 +1,20 @@
#pragma once
#include <QDialog>
#include <QString>
#include "api/Api3dRepository.hpp" // Api3dRepository::VolumeInfo
namespace geopro::app {
// 三维体属性对话框(收尾项 #6三维分析栏右键「数据详情」弹出只读展示三维体的
// 参数(源数据/插值模型/网格/超距/色阶)与统计(值域/网格/测点数/范围)。
// 统计仅在体被生成过loadVolume 缓存明细info.loaded=true时显示否则显占位。
class VolumePropertiesDialog : public QDialog {
Q_OBJECT
public:
VolumePropertiesDialog(const QString& name,
const geopro::data::Api3dRepository::VolumeInfo& info,
QWidget* parent = nullptr);
};
} // namespace geopro::app

View File

@ -100,9 +100,11 @@
#include "AnomalySaveDialog.hpp"
#include "AnomalyPropertiesDialog.hpp"
#include "SettingsDialog.hpp"
#include "SlicePropertiesDialog.hpp"
#include "SliceExport.hpp"
#include "TopBar.hpp"
#include "VolumeParamsDialog.hpp"
#include "VolumePropertiesDialog.hpp"
#include "interact/AnomalyDrawTool.hpp"
#include "ProjectListDialog.hpp"
#include "ObjectFormDialog.hpp"
@ -682,9 +684,24 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
[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);
// 三维分析栏「数据详情」项非体即切片dd_slice / dd_voxel按 ddCode 分派到只读属性
// 对话框(仿异常详情)。数据直接从具体 scene3dRepo 取(体/切片在 3D 仓储,非 detailCtrl 的 2D 管线)。
QObject::connect(ca, &geopro::app::Column3DAnalysis::detailRequested, &window,
[&window, scene3dRepo](const QString& dsId, const QString& ddCode,
const QString& name) {
if (ddCode == QStringLiteral("dd_slice")) {
geopro::data::I3dSceneRepository::SliceSpec sp;
if (scene3dRepo->sliceSpec(dsId.toStdString(), sp)) {
geopro::app::SlicePropertiesDialog dlg(name, sp, &window);
dlg.exec();
}
} else { // dd_voxel三维体
geopro::data::Api3dRepository::VolumeInfo info;
if (scene3dRepo->volumeInfo(dsId.toStdString(), info)) {
geopro::app::VolumePropertiesDialog dlg(name, info, &window);
dlg.exec();
}
}
});
// 三维分析栏切片右键「删除」→ 删除 mock 切片 + 刷新列表(若在渲染,删后行消失→取消勾选→自动移除图元)。
QObject::connect(ca, &geopro::app::Column3DAnalysis::sliceDeleteRequested, &window,

View File

@ -88,6 +88,29 @@ std::vector<DsRow> Api3dRepository::volumeRows() const {
return rows;
}
bool Api3dRepository::volumeInfo(const std::string& dsId, VolumeInfo& out) const {
auto it = volumes_.find(dsId);
if (it == volumes_.end()) return false;
const StoredVolume& sv = it->second;
out = VolumeInfo{};
out.params = sv.params;
out.name = sv.name;
out.loaded = sv.cachedGrid.has_value();
if (out.loaded) {
const VolumeGrid& g = *sv.cachedGrid;
out.vmin = g.vmin;
out.vmax = g.vmax;
out.nx = g.vol.nx();
out.ny = g.vol.ny();
out.nz = g.vol.nz();
out.dx = g.spacing[0];
out.dy = g.spacing[1];
out.dz = g.spacing[2];
out.pointCount = sv.pointCount.value_or(0);
}
return true;
}
void Api3dRepository::appendGridPoints(const core::Grid& g, core::PointSet& pts) const {
const int nx = g.nx(), ny = g.ny();
if (nx < 1 || ny < 1 || g.y.size() < static_cast<std::size_t>(ny)) return;
@ -146,6 +169,7 @@ void Api3dRepository::finalizeVolume(const std::string& dsId, const core::PointS
if (it != volumes_.end()) { // 缓存明细 + 色阶(下次命中即跳重算)
it->second.cachedGrid = out;
it->second.cachedScale = scale;
it->second.pointCount = pts.v.size(); // 持久化聚合散点数(详情统计用)
}
onOk(std::move(out), scale);
} catch (const std::exception& e) {

View File

@ -1,4 +1,5 @@
#pragma once
#include <cstddef>
#include <functional>
#include <map>
#include <memory>
@ -40,6 +41,20 @@ public:
std::string createVolume(VolumeBuildParams params, const std::string& name);
// 已创建三维体的列表行ddCode="dd_voxel"),供三维分析栏合并注入(每次 setDatasets 追加)。
std::vector<DsRow> volumeRows() const;
// 三维体只读详情(属性对话框用):参数随时可取;统计(值域/网格/测点数/范围)仅
// loadedloadVolume 缓存过明细)时有效,未加载 loaded=false、统计字段全 0。
struct VolumeInfo {
VolumeBuildParams params;
std::string name;
bool loaded = false; // cachedGrid 是否就绪(= loadVolume 跑过)
double vmin = 0.0, vmax = 0.0; // 以下仅 loaded 时有效:
int nx = 0, ny = 0, nz = 0; // 网格维度
double dx = 0.0, dy = 0.0, dz = 0.0; // 单元间距
std::size_t pointCount = 0; // 聚合后参与插值的散点数
};
// 取回三维体详情dsId 非三维体返回 false不弹空对话框
bool volumeInfo(const std::string& dsId, VolumeInfo& out) const;
// 已保存切片的列表行ddCode="dd_slice"parentId=所属体 dsId → 树中挂父体下),供三维分析栏合并。
std::vector<DsRow> sliceRows() const;
// 该 dsId 是否为已保存切片3b分析栏勾选 dd_slice 走切片重渲染路径,不进控制器帘面/体素路径)。
@ -98,6 +113,7 @@ private:
std::string name;
std::optional<VolumeGrid> cachedGrid;
core::ColorScale cachedScale; // 与 cachedGrid 同时填(源剖面色阶)
std::optional<std::size_t> pointCount; // 聚合散点数finalizeVolume 时持久化,详情统计用)
};
std::map<std::string, StoredVolume> volumes_; // dsId → 体
int volumeCounter_ = 0;

View File

@ -1,10 +1,15 @@
#include <gtest/gtest.h>
#include <memory>
#include <string>
#include "api/Api3dRepository.hpp"
#include "geo/GeoLocalFrame.hpp"
#include "repo/I3dSceneRepository.hpp"
#include "repo/IAsyncDatasetRepository.hpp"
#include "repo/LocalSample3dRepository.hpp"
#include "repo/LocalSampleRepository.hpp"
#include "repo/VolumeBuildParams.hpp"
using namespace geopro::data;
@ -69,3 +74,51 @@ TEST(LocalSample3dRepo, LoadTerrainPathsCallsBack) {
EXPECT_FALSE(got.demPath.empty());
EXPECT_FALSE(got.imagePath.empty());
}
namespace {
// 极简桩volumeInfo/createVolume 不触碰 dsRepo_loadAsync 直接回空。
struct StubAsyncRepo : IAsyncDatasetRepository {
DetailLoad* loadAsync(const std::string&, const std::string&, int, int) override {
return nullptr;
}
};
} // namespace
// volumeInfocreateVolume 后、loadVolume 前 → 返回 true参数/名称正确loaded=false、无测点数。
TEST(Api3dRepo, VolumeInfoBeforeLoad) {
StubAsyncRepo dsRepo;
auto frame = std::make_shared<geopro::core::GeoLocalFrame>(22.0, 114.0);
Api3dRepository repo(dsRepo, frame);
VolumeBuildParams p;
p.sourceDatasetIds = {"src-a", "src-b"};
p.interpModel = VolumeBuildParams::Model::Idw;
p.cellXY = 2.0;
p.cellZ = 0.5;
p.power = 3.0;
p.maxDist = 5.0;
p.colorScaleId = "src-a";
const std::string id = repo.createVolume(p, "体A");
Api3dRepository::VolumeInfo info;
ASSERT_TRUE(repo.volumeInfo(id, info));
EXPECT_EQ(info.name, "体A");
EXPECT_FALSE(info.loaded);
EXPECT_EQ(info.pointCount, 0u);
ASSERT_EQ(info.params.sourceDatasetIds.size(), 2u);
EXPECT_EQ(info.params.sourceDatasetIds[0], "src-a");
EXPECT_DOUBLE_EQ(info.params.cellXY, 2.0);
EXPECT_DOUBLE_EQ(info.params.power, 3.0);
EXPECT_DOUBLE_EQ(info.params.maxDist, 5.0);
EXPECT_EQ(info.params.colorScaleId, "src-a");
}
// volumeInfo未知 dsId非三维体→ 返回 false不弹空对话框。
TEST(Api3dRepo, VolumeInfoUnknownIdReturnsFalse) {
StubAsyncRepo dsRepo;
auto frame = std::make_shared<geopro::core::GeoLocalFrame>(22.0, 114.0);
Api3dRepository repo(dsRepo, frame);
Api3dRepository::VolumeInfo info;
EXPECT_FALSE(repo.volumeInfo("not-a-volume", info));
}