diff --git a/docs/superpowers/specs/2026-06-18-vtk-3d-volume-slice-detail-dialog-design.md b/docs/superpowers/specs/2026-06-18-vtk-3d-volume-slice-detail-dialog-design.md new file mode 100644 index 0000000..85a5b82 --- /dev/null +++ b/docs/superpowers/specs/2026-06-18-vtk-3d-volume-slice-detail-dialog-design.md @@ -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 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`、`vmin0`、`pointCount>0`。 + - 非体 dsId:返回 false。 +- 对话框为纯只读 UI(无逻辑分支),不做单测,靠 GUI 实测(Claude 无法 GUI 验证,交用户)。 + +## 8. 影响面 / 不变量 + +- 接口 `I3dSceneRepository` 与 `LocalSample3dRepository` 零改动 → 真实后端就绪后切换不受影响。 +- `finalizeVolume` 仅多写一个 `pointCount`,不改插值/渲染行为。 +- 不与 VTK 三维视图交互(详情只读查阅,职责清晰)。 diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index 1b16a6f..b117a56 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -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) diff --git a/src/app/SlicePropertiesDialog.cpp b/src/app/SlicePropertiesDialog.cpp new file mode 100644 index 0000000..3991808 --- /dev/null +++ b/src/app/SlicePropertiesDialog.cpp @@ -0,0 +1,64 @@ +#include "SlicePropertiesDialog.hpp" + +#include +#include +#include +#include + +#include + +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& 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 diff --git a/src/app/SlicePropertiesDialog.hpp b/src/app/SlicePropertiesDialog.hpp new file mode 100644 index 0000000..c89af47 --- /dev/null +++ b/src/app/SlicePropertiesDialog.hpp @@ -0,0 +1,20 @@ +#pragma once +#include +#include + +#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 diff --git a/src/app/VolumePropertiesDialog.cpp b/src/app/VolumePropertiesDialog.cpp new file mode 100644 index 0000000..148b127 --- /dev/null +++ b/src/app/VolumePropertiesDialog.cpp @@ -0,0 +1,84 @@ +#include "VolumePropertiesDialog.hpp" + +#include +#include +#include +#include +#include + +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& 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(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 diff --git a/src/app/VolumePropertiesDialog.hpp b/src/app/VolumePropertiesDialog.hpp new file mode 100644 index 0000000..dc33e21 --- /dev/null +++ b/src/app/VolumePropertiesDialog.hpp @@ -0,0 +1,20 @@ +#pragma once +#include +#include + +#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 diff --git a/src/app/main.cpp b/src/app/main.cpp index a240158..41c125e 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -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, diff --git a/src/data/api/Api3dRepository.cpp b/src/data/api/Api3dRepository.cpp index 689e247..40fd3bb 100644 --- a/src/data/api/Api3dRepository.cpp +++ b/src/data/api/Api3dRepository.cpp @@ -88,6 +88,29 @@ std::vector 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(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) { diff --git a/src/data/api/Api3dRepository.hpp b/src/data/api/Api3dRepository.hpp index 01a17d1..17df72f 100644 --- a/src/data/api/Api3dRepository.hpp +++ b/src/data/api/Api3dRepository.hpp @@ -1,4 +1,5 @@ #pragma once +#include #include #include #include @@ -40,6 +41,20 @@ public: std::string createVolume(VolumeBuildParams params, const std::string& name); // 已创建三维体的列表行(ddCode="dd_voxel"),供三维分析栏合并注入(每次 setDatasets 追加)。 std::vector volumeRows() const; + + // 三维体只读详情(属性对话框用):参数随时可取;统计(值域/网格/测点数/范围)仅 + // loaded(loadVolume 缓存过明细)时有效,未加载 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 sliceRows() const; // 该 dsId 是否为已保存切片(3b:分析栏勾选 dd_slice 走切片重渲染路径,不进控制器帘面/体素路径)。 @@ -98,6 +113,7 @@ private: std::string name; std::optional cachedGrid; core::ColorScale cachedScale; // 与 cachedGrid 同时填(源剖面色阶) + std::optional pointCount; // 聚合散点数(finalizeVolume 时持久化,详情统计用) }; std::map volumes_; // dsId → 体 int volumeCounter_ = 0; diff --git a/tests/data/test_3d_repo.cpp b/tests/data/test_3d_repo.cpp index 3e88973..78127be 100644 --- a/tests/data/test_3d_repo.cpp +++ b/tests/data/test_3d_repo.cpp @@ -1,10 +1,15 @@ #include +#include #include +#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 + +// volumeInfo:createVolume 后、loadVolume 前 → 返回 true,参数/名称正确,loaded=false、无测点数。 +TEST(Api3dRepo, VolumeInfoBeforeLoad) { + StubAsyncRepo dsRepo; + auto frame = std::make_shared(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(22.0, 114.0); + Api3dRepository repo(dsRepo, frame); + + Api3dRepository::VolumeInfo info; + EXPECT_FALSE(repo.volumeInfo("not-a-volume", info)); +}