diff --git a/docs/superpowers/HANDOFF-vtk-3d.md b/docs/superpowers/HANDOFF-vtk-3d.md index 6293089..284fb0e 100644 --- a/docs/superpowers/HANDOFF-vtk-3d.md +++ b/docs/superpowers/HANDOFF-vtk-3d.md @@ -35,7 +35,10 @@ ## 3. 当前状态 - 底图/地形/剖面配准/增量渲染:**完成且可用**。编译绿。所有改动已提交到 `feat/vtk-3d-view`。 -- 下一阶段(三维体/切片/异常):**仅完成设计定稿**,未开始编码。 +- **#1 客户端「生成三维体」流程:已实现,编译链接绿(exit 0),未提交、未 GUI 实测**(Claude 无法验 VTK 渲染,待用户启动 app 实测)。详见计划 `docs/superpowers/plans/2026-06-17-vtk-3d-volume-create-flow.md`。 + - 已落地:`data::VolumeBuildParams`(不冻结 gridSpec,源 ds 锁定不变式);`core::buildVolume` 共享管线(LocalSample/Api 同源,消 TODO 漂移);`Api3dRepository` 内存体存储 + `createVolume/volumeRows/isVolumeDataset` + 多源 `loadVolume`(复用 `loadSection`,竖向=g.y 高程,与帘面构造性对齐);`loadVolume` 回调改交付 `(VolumeGrid, ColorScale)`(体色阶=源剖面色阶);`Column3DDataset`(源数据栏) 多选+右键「生成三维体」+ `VolumeParamsDialog`;**生成的体归三维分析栏**(`Column3DAnalysis`,设计 §2.1,非数据集栏),main.cpp `lastAnalysisRows`+`refreshAnalysis` 合并注入(体行不被后端刷新冲掉)+ **两栏勾选聚合** `pushChecked`(剖面+体/切片并集下发,避免互相清除);`VtkSceneController` 按 `isVolumeDataset` 分流体素/帘面(取代全局 showVoxel/showCurtain)。 + - **待实测**:在**三维数据集栏**多选反演剖面→右键「生成三维体」→参数→新体行出现在**三维分析栏**→在三维分析栏勾选→渲染体;体是否与帘面对齐;色阶是否正确;剖面与体可同时勾选共存。 +- 下一阶段(切片/异常 #2–#6):**仅完成设计定稿**,未开始编码。 ## 4. 下一步计划(三维体 / 切片 / 异常) **权威设计文档**:`docs/superpowers/specs/2026-06-17-vtk-3d-volume-slice-anomaly-design.md`(数据模型 + 交互流 + 后端vs mock + 代码现状 + 实现拆解 + 持久化策略)。已与用户拍板的关键决策: @@ -46,7 +49,7 @@ - 数据模型层级:**三维体(源数据+插值参数) → 切片(属于体,保存后成 dd_slice) → 异常(画在切片平面,圈定保存)**,三者皆可持久化。 **实现拆解(设计文档 §6,按依赖排序)**: -1. 三维体 mock 渲染:`Api3dRepository::loadVolume` 由 stub 改为"取源数据散点 → IDW → VolumeGrid → VoxelActor"。拆 `VolumeBuildParams`(含 GridSpec) 必存 / values 可选。 +1. ~~三维体 mock 渲染~~ **✅ 已实现(编译绿,待 GUI 实测)**——见 §3 与计划 `2026-06-17-vtk-3d-volume-create-flow.md`。`Api3dRepository::loadVolume` 已接通(多源复用 loadSection → IDW → VolumeGrid + 色阶交付);`VolumeBuildParams` 必存参数、values 惰性重算+缓存(**不冻结 gridSpec**,改用源 ds 锁定不变式,留校验 TODO)。 2. 切片交互接通三维体(现有 `SliceTool`/`InteractionManager` 已能切;补滚轮推进、双击正视)。 3. 切片保存/另存/导出/删除(保存删除 mock 内存;导出图片/dat 客户端做)+ VTK 视图切片右键菜单接线。 4. 异常:切片右键创建异常(圈定+保存对话框含截图)→ **接真实端点**。 diff --git a/docs/superpowers/plans/2026-06-17-vtk-3d-volume-create-flow.md b/docs/superpowers/plans/2026-06-17-vtk-3d-volume-create-flow.md new file mode 100644 index 0000000..7d4abd0 --- /dev/null +++ b/docs/superpowers/plans/2026-06-17-vtk-3d-volume-create-flow.md @@ -0,0 +1,138 @@ +# 实现计划:客户端「生成三维体」完整流程(#1) + +- 日期:2026-06-17 +- 分支:`feat/vtk-3d-view` +- 上位设计:`docs/superpowers/specs/2026-06-17-vtk-3d-volume-slice-anomaly-design.md`(§2.4 客户端创建、§6 拆解、§7 持久化策略) +- 决策:用户已拍板「完整生成三维体客户端流程」(非最小 mock)。 +- 原则:缺后端端点 → 内存 mock,保持 `I3dSceneRepository` 接口可替换;客户端能做的先做。 + +## 0. 现状取证(file:line) + +| 事实 | 证据 | +|---|---| +| 运行路径用 `Api3dRepository`(非 LocalSample) | `main.cpp:254` | +| `Api3dRepository::loadVolume` 是 stub | `Api3dRepository.cpp:57-60` | +| 控制器→loadVolume→addVolume 管线已接好,但 voxel 路径靠全局 `showVoxel_=false` 且无 UI 触发 | `VtkSceneController.cpp:70-89`、`VtkSceneController.hpp:70` | +| 取源散点最干净路径 | `dsRepo_.loadAsync("inversion.scatter", dsId)` → `core::ScatterPayload`(`ApiDatasetRepository.cpp:152-165`;散点含 projX/projY + z(hlist=高程) + v,`DatasetChartDto.cpp:39-46`) | +| 帘面/地形竖向 = +elevation(Z=+g.y) | 交接文档 §2;`CurtainActor.cpp` | +| LocalSample loadVolume 参考实现(散点→配准→GridSpec→IDW),但用 `-s.y` 深度且固定样本 | `LocalSample3dRepository.cpp:68-147` | +| 散点→GridSpec→IDW 在 LocalSample/Api 重复,已有 TODO 标记漂移风险 | `LocalSample3dRepository.cpp:31-37` | +| 数据集列表后端全量加载、每次勾选测线变化全量覆盖 | `main.cpp:626-665`;`DatasetListPanel.cpp:187-216`(append=false) | +| `DsRow` 字段 | `RepoTypes.hpp:10-15`(id/dsName/typeName/ddCode/parentId/...) | +| 维度分流 dd_voxel→Dim3D | `DatasetDimension.cpp:8-15`;`Api3dRepository::dimensionOf` | +| Column3DDataset 列表是 checkbox 勾选(渲染),无多选/右键 | `Column3DDataset.cpp:114-128` | +| col3D 信号接线 | `main.cpp:362-386` | + +## 1. 数据模型:VolumeBuildParams(设计 §7.4) + +新增 `src/data/repo/VolumeBuildParams.hpp`(贴近消费它的 `I3dSceneRepository`/`Api3dRepository`): +```cpp +struct VolumeBuildParams { + std::vector sourceDatasetIds; // 源数据集 id(≥1) + enum class Model { Idw, Kriging }; + Model interpModel = Model::Idw; + double cellXY = 1.0, cellZ = 0.5, power = 2.0, maxDist = 4.0; // interpParams + std::string colorScaleId; // 取哪个源的色阶(默认首个源) + double vmin = 0.0, vmax = 0.0; // 派生(缓存) + struct Measure { long points = 0; double volume = 0.0; } measure; // 派生(缓存) +}; +``` + +> **不冻结 gridSpec**(用户决策 2026-06-17):gridSpec 每次从源散点**确定性重算**(IDW 确定 + 源锁定 → 结果必然一致),不存为"冻结锚点"。 +> 切片/异常坐标的稳定性由**源 ds 锁定**不变式保证(见 §9),而非冻结派生网格。 +> 若三维体变了 ⇒ 源 ds 被动过 ⇒ 旧 gridSpec 本身已失效,冻结只会掩盖问题。 + +## 2. 共享管线:core::algo(解决 TODO 漂移) + +新增 `src/core/algo/VolumeBuilder.{hpp,cpp}`(纯 core,不碰 CRS): +```cpp +struct BuiltVolume { core::ScalarVolume vol; core::GridSpec spec; double vmin, vmax; }; +// 入参:世界局部米点集 + cell/power/maxDist。 +// 包络→GridSpec(角点对齐, clampDim)→IDW→ScalarVolume→vmin/vmax(优先外部色阶 stops,否则实测)。 +// 确定性:同点集同参数 → 同 GridSpec 同体(不需冻结,见计划 §1 决策)。 +BuiltVolume buildVolume(const core::PointSet& pts, double cellXY, double cellZ, + double power, double maxDist); +``` +- 把 `LocalSample3dRepository.cpp:95-137` 的「包络→GridSpec→IDW→vmin/vmax」逻辑提进来。 +- `LocalSample3dRepository::loadVolume` 改为调用它(行为不变,回归保护)。 +- `clampDim`/kMaxDim 一并移入。 + +## 3. Api3dRepository:体存储 + loadVolume + createVolume(已实现) + +构造注入:`Api3dRepository(IAsyncDatasetRepository& dsRepo, std::shared_ptr frame)`。 +- **只需 frame**(与帘面/底图同一共享 shared_ptr,含 reanchor);**不需 projectCrs/refElev**——因取源走 `loadSection` 的 `Grid`,其节点已带 lat/lon + 高程轴 g.y,无须 CRS 正变换。 + +内存 mock 存储(成员): +```cpp +struct StoredVolume { VolumeBuildParams params; std::string name; std::optional cachedGrid; }; +std::map volumes_; // dsId → 体 +int volumeCounter_ = 0; +``` + +新增公有方法(concrete,main.cpp 持具体指针调用): +- `std::string createVolume(VolumeBuildParams params, const std::string& name)`:生成 `vol-N` dsId、存入 `volumes_`、返回 id(不立即插值;插值在首次 loadVolume 惰性做 + 缓存)。 +- `std::vector volumeRows() const`:转 `DsRow{id, dsName=name, ddCode="dd_voxel", typeName="三维体"}`,供列表合并。 +- `bool isVolumeDataset(const std::string& dsId) const`:`volumes_.count`。 + +`loadVolume(dsId)` 实现(异步 N 源扇出,**复用 loadSection 保证与帘面同系对齐**): +1. 查 `volumes_[dsId]`;无 → onErr。 +2. 有 `cachedGrid` → 直接 onOk(明细命中)。 +3. 否则:对每个 sourceId 调 `this->loadSection(srcId)`(即 `inversion.grid` 路径),`shared_ptr` 聚合 N 个回调(全到齐再继续;任一 failed → 整体 onErr 一次)。 +4. `appendGridPoints`:对每个源 Grid 的**有限值**节点,按 CurtainActor 同口径定位 → + `frame.toLocal(g.lat[i], g.lon[i])` 作 (x,y);**世界 Z = g.y[j](高程)**;v = g.valueAt(i,j)。跳过 NaN 格(与帘面消隐一致)。 +5. 取首个到达源的色阶定 vmin/vmax(否则 buildVolume 数据实测)。 +6. `core::buildVolume(pts, cellXY/cellZ/power/maxDist)`(每次从源确定性重算 gridSpec)。 +7. `cachedGrid` 缓存;onOk(VolumeGrid)。 + +> 契约:onOk/onErr 主线程回调。`DetailLoad::done` 在主线程发,扇出聚合用主线程共享 `Agg`(无锁)。 +> 路径选择说明:弃用 `inversion.scatter` 端点(其 y=深度/z=高程语义是交接文档警告的坑); +> 改复用已正确的 `loadSection`(`inversion.grid`),与帘面**构造性对齐**,且免 CRS 正变换。 + +`I3dSceneRepository` 接口加纯虚 `virtual bool isVolumeDataset(const std::string& dsId) const = 0;`(LocalSample 恒 false)。 + +## 4. 控制器:按类型分流 curtain vs voxel + +`VtkSceneController::addDatasetAsync`(`VtkSceneController.cpp:49-90`)改: +- `if (sceneRepo_.isVolumeDataset(dsId))` → 走 loadVolume/addVolume 路径; +- `else` → 走 loadSection/addCurtain 路径。 +- 移除对全局 `showVoxel_`/`showCurtain_` 作为分流条件的依赖(保留成员或删,二者均不再 gating 单 ds 分流;图层开关语义后续再议)。 + +## 5. UI:Column3DDataset(源数据栏)多选 + 右键「生成三维体」 + +> 归属修正(2026-06-17,用户指出):**源选择**在三维数据集栏(剖面池);**生成的体**进**三维分析栏**(§7)。 + +`Column3DDataset.{hpp,cpp}`(源数据栏): +- 列表 `setSelectionMode(ExtendedSelection)`(多选高亮,独立于 checkbox 渲染勾选)。 +- `setContextMenuPolicy(CustomContextMenu)` + 槽:右键弹菜单「生成三维体」,仅当选中项 ≥1 且均为可作源的 ddCode(dd_section/dd_inversion_data)时启用。 +- 新信号 `void generateVolumeRequested(const QStringList& sourceDsIds);`(取选中项的 kDsIdRole)。 + +## 6. UI:插值参数对话框 + +新增 `src/app/VolumeParamsDialog.{hpp,cpp}`(QDialog,与现有对话框同放 app 根目录): +- 名称、插值模型(IDW;克里金项 disabled 占位)、cellXY/cellZ/power/maxDist(QDoubleSpinBox,默认同 §1)。 +- `accept` → `volumeName()` / `params()`(不含 sourceDatasetIds,由调用方填)。 + +## 7. 装配:main.cpp 接线(体→三维分析栏 + 两栏勾选聚合) + +- main.cpp 改 `Api3dRepository` 构造,传 `frame`(shared_ptr,§3 不需 projectCrs/refElev)。 +- **体归三维分析栏**:`lastAnalysisRows` 缓存后端 Analysis 行(dd_slice);`refreshAnalysis()` = `colAnalysis()->setDatasets(lastAnalysisRows + scene3dRepo->volumeRows())`。三维数据集栏(col3D)恢复直接 `setDatasets(b.dim3D)`(仅后端剖面,源池)。 +- **渲染勾选聚合**:`checkedProfiles`(col3D 勾选剖面) + `checkedAnalysis`(colAnalysis 勾选体/切片) → `pushChecked()` 并集下发 `setCheckedDatasets`(控制器全量 diff,必须并集,否则一栏清掉另一栏)。 +- 连接 `c3.generateVolumeRequested` → `VolumeParamsDialog` → 填 `params.sourceDatasetIds` → `scene3dRepo->createVolume` → `refreshAnalysis()`(新体出现在三维分析栏)。 +- 连接 `c3.checkedDatasetsChanged`→更新 checkedProfiles;`ca.checkedItemsChanged`→更新 checkedAnalysis;均 `pushChecked()`。 +- 后端加载回调:col3D 直接 `setDatasets(b.dim3D)`;analysis 经 `refreshAnalysis()`,保证体在测线重勾后仍驻留。 + +## 8. 阶段与验收(每阶段编译绿 = build.bat app) + +- **阶段 A(模型+管线+loadVolume)**:§1 §2 §3。可单测 `buildVolume`;loadVolume 单源/多源逻辑成形。无 UI,不可见但编译绿 + 回归 LocalSample 不变。**✅ 编译绿** +- **阶段 B(创建流 UI)**:§5 §6 §7。可见:数据集栏多选剖面→右键生成→参数→新体行出现在**三维分析栏**→勾选渲染体。**✅ 编译绿** +- **阶段 C(分流)**:§4。dd_voxel 渲染为体、dd_section 渲染为帘面,二者可共存。**✅ 编译绿** +- 验收:用户启动 app 实测(Claude 无法 GUI 验证 VTK,按交接铁律用 `build.bat app` 仅编译验证;渲染问题查 `%LOCALAPPDATA%\Geomative\Geopro3\logs`)。 + +## 9. 风险 / 待确认 + +- **散点端点对反演剖面是否真有 hlist(高程)**:若某些数据 z 为空,竖向退化。落地时加日志取证,必要时回退用 grid.elevation(loadSection 路径)。 +- **克里金**:UI 占位 disabled,仅 IDW 实现(设计 §1.1 列了克里金,但 core 仅 IdwInterpolator)。 +- **mock 持久化形态**:本期纯内存(重启丢失),符合设计 §5「次要待确认」;本地文件持久化留后续。 +- **源 ds 锁定不变式**(替代 gridSpec 冻结,用户决策):被三维体引用的源 ds **不可修改/删除**——保证 IDW 重算确定一致、切片/异常坐标稳定。 + - 本期:内存 mock,仅留 TODO(在源 ds 删除/编辑入口校验"是否被某三维体引用,是则禁止/告警")。 + - 推论:不冻结 gridSpec;若源真被改 ⇒ 体应失效重建,而非套旧锚点(旧锚点已是脏数据)。 diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index b44e3f8..4cae2f4 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -64,6 +64,7 @@ add_executable(geopro_desktop WIN32 ImportDatasetDialog.cpp ExportDatasetDialog.cpp SettingsDialog.cpp + VolumeParamsDialog.cpp Logging.cpp DatasetDimension.cpp TileBasemap.cpp) diff --git a/src/app/VolumeParamsDialog.cpp b/src/app/VolumeParamsDialog.cpp new file mode 100644 index 0000000..8560a45 --- /dev/null +++ b/src/app/VolumeParamsDialog.cpp @@ -0,0 +1,87 @@ +#include "VolumeParamsDialog.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace geopro::app { + +namespace { +// 默认值与 data::VolumeBuildParams 同口径(保持单一真相)。 +constexpr double kDefCellXY = 1.0; +constexpr double kDefCellZ = 0.5; +constexpr double kDefPower = 2.0; +constexpr double kDefMaxDist = 4.0; +} // namespace + +VolumeParamsDialog::VolumeParamsDialog(int sourceCount, QWidget* parent) : QDialog(parent) { + setWindowTitle(QStringLiteral("生成三维体")); + setModal(true); + + auto* root = new QVBoxLayout(this); + root->addWidget(new QLabel( + QStringLiteral("由 %1 个源数据集插值生成三维体").arg(sourceCount))); + + auto* form = new QFormLayout(); + + name_ = new QLineEdit(QStringLiteral("三维体")); + form->addRow(QStringLiteral("名称"), name_); + + model_ = new QComboBox(); + model_->addItem(QStringLiteral("反距离加权 (IDW)"), + static_cast(geopro::data::VolumeBuildParams::Model::Idw)); + model_->addItem(QStringLiteral("克里金 (Kriging)"), + static_cast(geopro::data::VolumeBuildParams::Model::Kriging)); + // 克里金本期未实现(core 仅 IDW)→ 禁用该项,默认选 IDW。 + if (auto* m = qobject_cast(model_->model())) { + if (auto* it = m->item(1)) it->setEnabled(false); + } + model_->setCurrentIndex(0); + form->addRow(QStringLiteral("插值模型"), model_); + + auto makeSpin = [this](double val, double min, double max, double step, int decimals) { + auto* s = new QDoubleSpinBox(); + s->setRange(min, max); + s->setSingleStep(step); + s->setDecimals(decimals); + s->setValue(val); + return s; + }; + cellXY_ = makeSpin(kDefCellXY, 0.01, 1000.0, 0.5, 2); + cellZ_ = makeSpin(kDefCellZ, 0.01, 1000.0, 0.5, 2); + power_ = makeSpin(kDefPower, 0.5, 6.0, 0.5, 1); + maxDist_ = makeSpin(kDefMaxDist, 0.1, 10000.0, 1.0, 2); + form->addRow(QStringLiteral("水平间距 (米)"), cellXY_); + form->addRow(QStringLiteral("竖向间距 (米)"), cellZ_); + form->addRow(QStringLiteral("IDW 幂次"), power_); + form->addRow(QStringLiteral("最大影响距离 (米)"), maxDist_); + + root->addLayout(form); + + auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); + connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept); + connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); + root->addWidget(buttons); +} + +QString VolumeParamsDialog::volumeName() const { + const QString n = name_->text().trimmed(); + return n.isEmpty() ? QStringLiteral("三维体") : n; +} + +geopro::data::VolumeBuildParams VolumeParamsDialog::params() const { + geopro::data::VolumeBuildParams p; + p.interpModel = static_cast(model_->currentData().toInt()); + p.cellXY = cellXY_->value(); + p.cellZ = cellZ_->value(); + p.power = power_->value(); + p.maxDist = maxDist_->value(); + return p; +} + +} // namespace geopro::app diff --git a/src/app/VolumeParamsDialog.hpp b/src/app/VolumeParamsDialog.hpp new file mode 100644 index 0000000..ba50cf9 --- /dev/null +++ b/src/app/VolumeParamsDialog.hpp @@ -0,0 +1,33 @@ +#pragma once +#include +#include + +#include "repo/VolumeBuildParams.hpp" + +class QLineEdit; +class QComboBox; +class QDoubleSpinBox; + +namespace geopro::app { + +// 「生成三维体」参数对话框:名称 + 插值模型(IDW;克里金占位禁用)+ cellXY/cellZ/power/maxDist。 +// sourceCount 仅用于提示文案("由 N 个源数据集插值生成")。 +// accept 后经 volumeName()/params() 取结果;params() 不含 sourceDatasetIds(由调用方填充)。 +class VolumeParamsDialog : public QDialog { + Q_OBJECT +public: + explicit VolumeParamsDialog(int sourceCount, QWidget* parent = nullptr); + + QString volumeName() const; + geopro::data::VolumeBuildParams params() const; + +private: + QLineEdit* name_ = nullptr; + QComboBox* model_ = nullptr; + QDoubleSpinBox* cellXY_ = nullptr; + QDoubleSpinBox* cellZ_ = nullptr; + QDoubleSpinBox* power_ = nullptr; + QDoubleSpinBox* maxDist_ = nullptr; +}; + +} // namespace geopro::app diff --git a/src/app/main.cpp b/src/app/main.cpp index 67ff4e5..522fb08 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -93,6 +93,7 @@ #include "Theme.hpp" #include "SettingsDialog.hpp" #include "TopBar.hpp" +#include "VolumeParamsDialog.hpp" #include "ProjectListDialog.hpp" #include "ObjectFormDialog.hpp" #include "ImportDatasetDialog.hpp" @@ -251,7 +252,7 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re // 中央渲染编排(VtkSceneController + VtkSceneView,取代旧 rebuildCentral lambda 与裸 show* 标志)。 // 3D 场景仓储用 Api3dRepository(真实后端:loadSection 走真实 ERT 反演端点,委托 datasetRepo)。 // 视图(VtkSceneView)非 QObject、控制器/3D 仓储亦然:随 scene 一并在 vtkWidget 销毁时清理。 - auto* scene3dRepo = new geopro::data::Api3dRepository(datasetRepo); + auto* scene3dRepo = new geopro::data::Api3dRepository(datasetRepo, frame); auto* sceneView = new geopro::app::VtkSceneView(*scene, renderWindowPtr, frame, refElev); auto* sceneCtrl = new geopro::controller::VtkSceneController(repo, *scene3dRepo, *sceneView, @@ -361,6 +362,27 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re // ── 三栏抽屉信号 → 控制器/交互(Task 7 接线)────────────────────────────── auto* c3 = drawer->col3D(); + + // 三维分析栏 = 后端 Analysis 行(dd_slice) + 客户端创建的三维体(mock)。生成的三维体是"分析产物" + // (设计 §2.1:三维分析栏按 对象/三维体模型/切片 三级树),归三维分析栏(非数据集栏)。 + // 后端列表每次勾选测线全量覆盖,故客户端体单独维护、刷新时合并注入(不被冲掉)。 + auto lastAnalysisRows = std::make_shared>(); + auto refreshAnalysis = [drawer, scene3dRepo, lastAnalysisRows]() { + std::vector rows = *lastAnalysisRows; + for (auto& vr : scene3dRepo->volumeRows()) rows.push_back(std::move(vr)); + drawer->colAnalysis()->setDatasets(rows); + }; + + // 渲染勾选聚合:三维数据集栏(剖面→帘面)+ 三维分析栏(三维体/切片→体素/切片)两套勾选并集 + // 后下发控制器(setCheckedDatasets 全量 diff,须并集;否则一栏勾选会清掉另一栏的图元)。 + auto checkedProfiles = std::make_shared(); + auto checkedAnalysis = std::make_shared(); + auto pushChecked = [sceneCtrl, checkedProfiles, checkedAnalysis]() { + QStringList all = *checkedProfiles; + all += *checkedAnalysis; + sceneCtrl->setCheckedDatasets(all); + }; + QObject::connect(c3, &geopro::app::Column3DDataset::axesModeChanged, sceneCtrl, &geopro::controller::VtkSceneController::setAxesMode); QObject::connect(c3, &geopro::app::Column3DDataset::axesUnitChanged, sceneCtrl, @@ -375,17 +397,39 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re &geopro::controller::VtkSceneController::zoomOut); QObject::connect(c3, &geopro::app::Column3DDataset::fitRequested, sceneCtrl, &geopro::controller::VtkSceneController::fit); - // 渲染勾选的 3D 数据集:真实 ds id 直达控制器异步帘面路径 - // (setCheckedDatasets → Api3dRepository.loadSection(realId) → 真实 ERT 反演端点 → 真实帘面)。 + // 三维数据集栏勾选(反演剖面)→ 并入渲染勾选集(剖面走帘面路径)。 QObject::connect(c3, &geopro::app::Column3DDataset::checkedDatasetsChanged, sceneCtrl, - &geopro::controller::VtkSceneController::setCheckedDatasets); + [checkedProfiles, pushChecked](const QStringList& ids) { + *checkedProfiles = ids; + pushChecked(); + }); // O点位置/字体本期 stub(TODO P4:弹框)。 QObject::connect(c3, &geopro::app::Column3DDataset::oPointClicked, vtkWidget, []() { /* TODO P4: O点位置弹框 */ }); QObject::connect(c3, &geopro::app::Column3DDataset::fontClicked, vtkWidget, []() { /* TODO P4: 字体弹框 */ }); + // 三维数据集栏右键「生成三维体」:弹参数对话框 → 客户端 createVolume(mock)→ 刷新三维分析栏 + // (新三维体作为"分析产物"出现在三维分析栏,勾选即渲染体)。 + QObject::connect(c3, &geopro::app::Column3DDataset::generateVolumeRequested, &window, + [&window, scene3dRepo, refreshAnalysis](const QStringList& sourceIds) { + geopro::app::VolumeParamsDialog dlg(static_cast(sourceIds.size()), + &window); + if (dlg.exec() != QDialog::Accepted) return; + geopro::data::VolumeBuildParams params = dlg.params(); + for (const QString& id : sourceIds) + params.sourceDatasetIds.push_back(id.toStdString()); + scene3dRepo->createVolume(std::move(params), + dlg.volumeName().toStdString()); + refreshAnalysis(); // 新体行进入三维分析栏,勾选即渲染体 + }); auto* ca = drawer->colAnalysis(); + // 三维分析栏勾选(三维体/切片)→ 并入渲染勾选集(体走体素路径,由 isVolumeDataset 分流)。 + QObject::connect(ca, &geopro::app::Column3DAnalysis::checkedItemsChanged, sceneCtrl, + [checkedAnalysis, pushChecked](const QStringList& ids) { + *checkedAnalysis = ids; + pushChecked(); + }); QObject::connect(ca, &geopro::app::Column3DAnalysis::sliceRequested, vtkWidget, [interactionMgr](geopro::render::interact::SliceAxis axis) { interactionMgr->addSlice(axis); @@ -629,24 +673,27 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re auto generation = std::make_shared(0); QObject::connect( objectTree, &geopro::app::ObjectTreePanel::checkedTmsChanged, &window, - [&projectRepo, &nav, drawer, emptyState, generation](const QStringList& tmIds) { + [&projectRepo, &nav, drawer, emptyState, generation, lastAnalysisRows, + refreshAnalysis](const QStringList& tmIds) { const unsigned long long myGen = ++(*generation); emptyState->setVisible(tmIds.isEmpty()); // 有勾选→隐藏引导层,露出中央渲染 if (tmIds.isEmpty()) { drawer->col3D()->setDatasets({}); drawer->col2D()->setDatasets({}); - drawer->colAnalysis()->setDatasets({}); + *lastAnalysisRows = {}; + refreshAnalysis(); // 后端分析行清空,但客户端三维体仍驻留三维分析栏 return; } // 多 TM 异步汇总:每个 TM 取整棵 ds 子树,全部回来后按维度分发到三栏。 auto acc = std::make_shared>(); auto remaining = std::make_shared(tmIds.size()); - auto finish = [acc, drawer, generation, myGen]() { + auto finish = [acc, drawer, generation, myGen, lastAnalysisRows, refreshAnalysis]() { if (*generation != myGen) return; // 已被更新的勾选批次取代→丢弃陈旧结果 geopro::app::DimBuckets b = geopro::app::splitByDimension(*acc); drawer->col3D()->setDatasets(b.dim3D); drawer->col2D()->setDatasets(b.dim2D); - drawer->colAnalysis()->setDatasets(b.analysis); + *lastAnalysisRows = b.analysis; + refreshAnalysis(); // 后端切片 + 客户端三维体合并注入三维分析栏 }; for (const QString& tm : tmIds) { geopro::data::NavRequest* req = projectRepo.loadRowsAsync( diff --git a/src/app/panels/columns/Column3DDataset.cpp b/src/app/panels/columns/Column3DDataset.cpp index 2d5a34b..ecbd81f 100644 --- a/src/app/panels/columns/Column3DDataset.cpp +++ b/src/app/panels/columns/Column3DDataset.cpp @@ -2,11 +2,16 @@ #include +#include +#include #include #include #include #include +#include +#include #include +#include #include #include #include @@ -111,10 +116,12 @@ Column3DDataset::Column3DDataset(QWidget* parent) : QWidget(parent) { root->addLayout(row); } - // 数据集列表(可勾选) + // 数据集列表(可勾选 = 渲染选择;多选高亮 + 右键 = 生成三维体的源选择,两者独立) list_ = new QTreeWidget(); list_->setHeaderHidden(true); list_->setRootIsDecorated(true); + list_->setSelectionMode(QAbstractItemView::ExtendedSelection); // Ctrl/Shift 多选源剖面 + list_->setContextMenuPolicy(Qt::CustomContextMenu); applyDatasetCardDelegate(list_); connect(list_, &QTreeWidget::itemChanged, this, [this](QTreeWidgetItem*, int) { QStringList ids; @@ -124,9 +131,30 @@ Column3DDataset::Column3DDataset(QWidget* parent) : QWidget(parent) { } emit checkedDatasetsChanged(ids); }); + connect(list_, &QTreeWidget::customContextMenuRequested, this, + &Column3DDataset::showListContextMenu); root->addWidget(list_, 1); } +void Column3DDataset::showListContextMenu(const QPoint& pos) { + // 收集选中项中"可作三维体源"的数据集(反演剖面类)。 + static const QSet kSourceDdCodes = {QStringLiteral("dd_section"), + QStringLiteral("dd_inversion_data")}; + QStringList sourceIds; + for (QTreeWidgetItem* item : list_->selectedItems()) { + const QString ddCode = item->data(0, kDsDdCodeRole).toString(); + if (kSourceDdCodes.contains(ddCode)) + sourceIds << item->data(0, kDsIdRole).toString(); + } + + QMenu menu(this); + QAction* gen = menu.addAction(QStringLiteral("生成三维体")); + gen->setEnabled(!sourceIds.isEmpty()); // 无可作源选中 → 灰显(提示需选反演剖面) + QAction* chosen = menu.exec(list_->viewport()->mapToGlobal(pos)); + if (chosen == gen && !sourceIds.isEmpty()) + emit generateVolumeRequested(sourceIds); +} + void Column3DDataset::setVerticalExaggeration(double ve) { const int v = std::max(1, static_cast(ve + 0.5)); QSignalBlocker block(veSlider_); // 仅同步 UI 显示;传播由组合根分发,避免重复发信号 diff --git a/src/app/panels/columns/Column3DDataset.hpp b/src/app/panels/columns/Column3DDataset.hpp index 09fd6bc..90ee445 100644 --- a/src/app/panels/columns/Column3DDataset.hpp +++ b/src/app/panels/columns/Column3DDataset.hpp @@ -30,8 +30,13 @@ signals: void oPointClicked(); void fontClicked(); void checkedDatasetsChanged(const QStringList& dsIds); + // 右键「生成三维体」:选中的源数据集 id(≥1,均为可作源类型)→ 组合根弹参数对话框 + 客户端插值。 + void generateVolumeRequested(const QStringList& sourceDsIds); private: + // 右键菜单:选中可作源数据集(dd_section / dd_inversion_data)时提供「生成三维体」。 + void showListContextMenu(const QPoint& pos); + QTreeWidget* list_ = nullptr; QSlider* veSlider_ = nullptr; // 水平/垂直比例滑块 QLabel* veLabel_ = nullptr; diff --git a/src/controller/VtkSceneController.cpp b/src/controller/VtkSceneController.cpp index e782536..9d5bd1f 100644 --- a/src/controller/VtkSceneController.cpp +++ b/src/controller/VtkSceneController.cpp @@ -49,15 +49,27 @@ void VtkSceneController::setCheckedDatasets(const QStringList& dsIds) { void VtkSceneController::addDatasetAsync(const std::string& dsId, unsigned long long gen) { if (loadingDs_.count(dsId)) return; // 已在加载(重复勾选竞态)→ 不重复请求 QPointer self(this); - if (showCurtain_) { + + // 按数据集类型分流(取代旧全局 showCurtain_/showVoxel_ 开关): + // 三维体(dd_voxel,客户端创建)→ 体素渲染;其余剖面(dd_section 等)→ 帘面渲染。 + if (sceneRepo_.isVolumeDataset(dsId)) { + auto cachedGrid = volumeCache_.find(dsId); + auto cachedScale = volumeScaleCache_.find(dsId); + if (cachedGrid != volumeCache_.end() && cachedScale != volumeScaleCache_.end()) { + view_.addVolume(dsId, cachedGrid->second, cachedScale->second); // 缓存命中(色阶随体缓存) + onDatasetArrived(); + return; + } loadingDs_.insert(dsId); - sceneRepo_.loadSection( + sceneRepo_.loadVolume( dsId, - [self, gen, dsId](data::SectionData s) { + [self, gen, dsId](data::VolumeGrid g, core::ColorScale cs) { if (!self) return; self->loadingDs_.erase(dsId); - if (gen != self->rebuildGeneration_ || !self->isChecked(dsId)) return; // 作废/已取消 - self->view_.addCurtain(dsId, s.grid, s.scale); + if (gen != self->rebuildGeneration_ || !self->isChecked(dsId)) return; + self->volumeScaleCache_[dsId] = cs; // 色阶随体一起缓存(mock 体在 dsRepo_ 无条目) + auto it = self->volumeCache_.emplace(dsId, std::move(g)).first; + self->view_.addVolume(dsId, it->second, self->volumeScaleCache_[dsId]); self->onDatasetArrived(); }, [self, gen, dsId](const std::string& m) { @@ -66,27 +78,26 @@ void VtkSceneController::addDatasetAsync(const std::string& dsId, unsigned long if (gen != self->rebuildGeneration_) return; emit self->loadFailed(QString::fromStdString(m)); }); + return; } - if (showVoxel_) { - auto cached = volumeCache_.find(dsId); - if (cached != volumeCache_.end()) { - view_.addVolume(dsId, cached->second, colorScale(dsId)); - onDatasetArrived(); - } else { - sceneRepo_.loadVolume( - dsId, - [self, gen, dsId](data::VolumeGrid g) { - if (!self || gen != self->rebuildGeneration_ || !self->isChecked(dsId)) return; - auto it = self->volumeCache_.emplace(dsId, std::move(g)).first; - self->view_.addVolume(dsId, it->second, self->colorScale(dsId)); - self->onDatasetArrived(); - }, - [self, gen](const std::string& m) { - if (!self || gen != self->rebuildGeneration_) return; - emit self->loadFailed(QString::fromStdString(m)); - }); - } - } + + // 剖面 → 帘面(着色用 loadSection 返回的 s.scale,与体的源色阶同源)。 + loadingDs_.insert(dsId); + sceneRepo_.loadSection( + dsId, + [self, gen, dsId](data::SectionData s) { + if (!self) return; + self->loadingDs_.erase(dsId); + if (gen != self->rebuildGeneration_ || !self->isChecked(dsId)) return; // 作废/已取消 + self->view_.addCurtain(dsId, s.grid, s.scale); + self->onDatasetArrived(); + }, + [self, gen, dsId](const std::string& m) { + if (!self) return; + self->loadingDs_.erase(dsId); + if (gen != self->rebuildGeneration_) return; + emit self->loadFailed(QString::fromStdString(m)); + }); } void VtkSceneController::onDatasetArrived() { diff --git a/src/controller/VtkSceneController.hpp b/src/controller/VtkSceneController.hpp index 28c4804..d645402 100644 --- a/src/controller/VtkSceneController.hpp +++ b/src/controller/VtkSceneController.hpp @@ -80,6 +80,8 @@ private: std::map gridCache_; std::map colorScaleCache_; std::map volumeCache_; + // 三维体色阶缓存:mock 体在 dsRepo_ 无条目,色阶随 loadVolume 一起交付并缓存于此。 + std::map volumeScaleCache_; // 异步回灌防护:每次全量 rebuild 自增,回调比对丢弃迟到结果。 unsigned long long rebuildGeneration_ = 0; diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt index 6312581..1bcba77 100644 --- a/src/core/CMakeLists.txt +++ b/src/core/CMakeLists.txt @@ -7,6 +7,7 @@ add_library(geopro_core STATIC geo/CrsTransform.cpp model/ColorScale.cpp algo/IdwInterpolator.cpp + algo/VolumeBuilder.cpp ) target_include_directories(geopro_core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) diff --git a/src/core/algo/VolumeBuilder.cpp b/src/core/algo/VolumeBuilder.cpp new file mode 100644 index 0000000..6a35198 --- /dev/null +++ b/src/core/algo/VolumeBuilder.cpp @@ -0,0 +1,65 @@ +#include "algo/VolumeBuilder.hpp" + +#include +#include +#include +#include +#include + +#include "algo/IdwInterpolator.hpp" + +namespace geopro::core { + +namespace { +// ext(包络长度)/ cell(间距)→ 网格点数,限幅 [1, kMaxVolumeDim]。 +int clampDim(double ext, double cell) { + int n = static_cast(ext / cell) + 1; + if (n < 1) n = 1; + if (n > kMaxVolumeDim) n = kMaxVolumeDim; + return n; +} +} // namespace + +BuiltVolume buildVolume(const PointSet& pts, double cellXY, double cellZ, + double power, double maxDist) { + if (pts.v.empty()) { + throw std::invalid_argument("buildVolume: empty point set"); + } + + // 1) 点集包络。 + double minx = pts.x[0], maxx = pts.x[0]; + double miny = pts.y[0], maxy = pts.y[0]; + double minz = pts.z[0], maxz = pts.z[0]; + for (std::size_t i = 1; i < pts.v.size(); ++i) { + minx = std::min(minx, pts.x[i]); maxx = std::max(maxx, pts.x[i]); + miny = std::min(miny, pts.y[i]); maxy = std::max(maxy, pts.y[i]); + minz = std::min(minz, pts.z[i]); maxz = std::max(maxz, pts.z[i]); + } + + // 2) GridSpec(角点对齐 = 原点取包络最小角)。 + GridSpec spec{}; + spec.ox = minx; spec.oy = miny; spec.oz = minz; + spec.dx = cellXY; spec.dy = cellXY; spec.dz = cellZ; + spec.nx = clampDim(maxx - minx, cellXY); + spec.ny = clampDim(maxy - miny, cellXY); + spec.nz = clampDim(maxz - minz, cellZ); + spec.power = power; + spec.maxDist = maxDist; + + // 3) IDW(maxDist 外 NaN 留空)。 + const IdwInterpolator idw; + ScalarVolume vol = idw.interpolate(pts, spec); + + // 4) 数据实测值域(仅有限值)。无有限值 → 退化 {0,1}。 + double vmin = std::numeric_limits::infinity(); + double vmax = -std::numeric_limits::infinity(); + for (double v : vol.data()) { + if (std::isnan(v)) continue; + vmin = std::min(vmin, v); vmax = std::max(vmax, v); + } + if (!(vmin < vmax)) { vmin = 0.0; vmax = 1.0; } + + return BuiltVolume{std::move(vol), spec, vmin, vmax}; +} + +} // namespace geopro::core diff --git a/src/core/algo/VolumeBuilder.hpp b/src/core/algo/VolumeBuilder.hpp new file mode 100644 index 0000000..cee1c23 --- /dev/null +++ b/src/core/algo/VolumeBuilder.hpp @@ -0,0 +1,26 @@ +#pragma once +#include "algo/IInterpolator.hpp" +#include "model/Field.hpp" + +namespace geopro::core { + +// 网格维度上限(与原 LocalSample3dRepository kMaxDim 同口径,防超大体素爆内存)。 +constexpr int kMaxVolumeDim = 400; + +// buildVolume 产物:插值体 + 网格规格 + 数据实测值域。 +// ScalarVolume 无默认构造 ⇒ BuiltVolume 亦无默认构造,须聚合初始化全部成员。 +struct BuiltVolume { + ScalarVolume vol; + GridSpec spec; + double vmin, vmax; // 数据实测有限值范围(无有限值时退化为 {0,1}) +}; + +// 散点(世界局部米)→ 包络盒角点对齐 GridSpec(维度按 ext/cell 限幅到 [1, kMaxVolumeDim]) +// → IDW → ScalarVolume(maxDist 外 NaN 留空)→ 数据实测 vmin/vmax。 +// 前置:pts 须含 ≥1 点(空集抛 std::invalid_argument)。 +// 确定性:同点集同参数 → 同 GridSpec 同体(不冻结 spec,见计划 §1 决策)。 +// 提取自 LocalSample3dRepository::loadVolume,供本地样本 / 真实 Api 共享,消除调参漂移。 +BuiltVolume buildVolume(const PointSet& pts, double cellXY, double cellZ, + double power, double maxDist); + +} // namespace geopro::core diff --git a/src/data/api/Api3dRepository.cpp b/src/data/api/Api3dRepository.cpp index 8bf329f..ab0c6b8 100644 --- a/src/data/api/Api3dRepository.cpp +++ b/src/data/api/Api3dRepository.cpp @@ -3,9 +3,16 @@ #include #include #include + +#include +#include +#include +#include #include +#include "algo/VolumeBuilder.hpp" // core::PointSet / BuiltVolume / buildVolume(含 Field.hpp) #include "api/DatasetLoadHandles.hpp" +#include "model/ColorScale.hpp" #include "model/detail/DetailPayloads.hpp" #include "repo/IAsyncDatasetRepository.hpp" @@ -15,7 +22,9 @@ namespace { constexpr const char* kNotReady = "后端三维端点未就绪"; } // namespace -Api3dRepository::Api3dRepository(IAsyncDatasetRepository& dsRepo) : dsRepo_(dsRepo) {} +Api3dRepository::Api3dRepository(IAsyncDatasetRepository& dsRepo, + std::shared_ptr frame) + : dsRepo_(dsRepo), frame_(std::move(frame)) {} DsDimension Api3dRepository::dimensionOf(const DsRow& ds) const { // 与 LocalSample3dRepository::dimensionOf 同口径(spec §6.1 ddCode→维度)。 @@ -54,9 +63,141 @@ void Api3dRepository::loadSection(const std::string& dsId, std::function /*onOk*/, OnError onErr) { - onErr(kNotReady); // 后端三维体端点未就绪 +bool Api3dRepository::isVolumeDataset(const std::string& dsId) const { + return volumes_.find(dsId) != volumes_.end(); +} + +std::string Api3dRepository::createVolume(VolumeBuildParams params, const std::string& name) { + const std::string id = "vol-" + std::to_string(++volumeCounter_); + volumes_[id] = StoredVolume{std::move(params), name, std::nullopt}; + return id; +} + +std::vector Api3dRepository::volumeRows() const { + std::vector rows; + rows.reserve(volumes_.size()); + for (const auto& [id, sv] : volumes_) { + DsRow r; + r.id = id; + r.dsName = sv.name; + r.ddCode = "dd_voxel"; + r.typeName = "三维体"; + rows.push_back(std::move(r)); + } + return rows; +} + +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; + // 与 CurtainActor::buildCurtain 同口径:有 lat/lon 用 frame.toLocal,否则退化用 g.x/0。 + const bool hasLatLon = g.lat.size() >= static_cast(nx) && + g.lon.size() >= static_cast(nx); + for (int j = 0; j < ny; ++j) { + for (int i = 0; i < nx; ++i) { + const double val = g.valueAt(i, j); + if (!std::isfinite(val)) continue; // 跳过无数据格(与帘面消隐一致,避免 NaN 入 IDW) + double px, py; + if (hasLatLon) { + const auto p = frame_->toLocal(g.lat[i], g.lon[i]); + px = p.x; + py = p.y; + } else { + px = (g.x.size() > static_cast(i)) ? g.x[i] : static_cast(i); + py = 0.0; + } + pts.x.push_back(px); + pts.y.push_back(py); + pts.z.push_back(g.y[j]); // 世界 Z = 高程(与 CurtainActor 一致) + pts.v.push_back(val); + } + } +} + +void Api3dRepository::finalizeVolume(const std::string& dsId, const core::PointSet& pts, + const core::ColorScale& scale, + const VolumeBuildParams& params, + std::function onOk, + OnError onErr) { + if (pts.v.empty()) { + onErr("Api3dRepository::loadVolume: 配准后无有效散点(源数据为空或全无数据)"); + return; + } + try { + geopro::core::BuiltVolume bv = + geopro::core::buildVolume(pts, params.cellXY, params.cellZ, params.power, params.maxDist); + // 值域:优先色阶分段值,否则 buildVolume 的数据实测范围。 + double vmin = bv.vmin, vmax = bv.vmax; + const std::vector stops = scale.stopValues(); + if (stops.size() >= 2) { + vmin = stops.front(); + vmax = stops.back(); + } + VolumeGrid out{std::move(bv.vol), + {{bv.spec.ox, bv.spec.oy, bv.spec.oz}}, + {{bv.spec.dx, bv.spec.dy, bv.spec.dz}}, + vmin, vmax}; + auto it = volumes_.find(dsId); + if (it != volumes_.end()) { // 缓存明细 + 色阶(下次命中即跳重算) + it->second.cachedGrid = out; + it->second.cachedScale = scale; + } + onOk(std::move(out), scale); + } catch (const std::exception& e) { + onErr(std::string("Api3dRepository::loadVolume: ") + e.what()); + } +} + +void Api3dRepository::loadVolume(const std::string& dsId, + std::function onOk, + OnError onErr) { + auto it = volumes_.find(dsId); + if (it == volumes_.end()) { + onErr("Api3dRepository::loadVolume: 未知三维体 " + dsId); + return; + } + StoredVolume& sv = it->second; + if (sv.cachedGrid) { // 明细命中 → 直接渲染(不重算) + onOk(*sv.cachedGrid, sv.cachedScale); + return; + } + const VolumeBuildParams params = sv.params; // 拷贝:异步回调期间存储可能变动 + if (params.sourceDatasetIds.empty()) { + onErr("Api3dRepository::loadVolume: 三维体无源数据集"); + return; + } + + // 多源扇出:每个源走 loadSection(与帘面同一 inversion.grid 路径 → 同系对齐), + // 主线程聚合(loadSection 回调在主线程)。任一源失败 → 整体失败(只回一次)。 + struct Agg { + int pending; + bool failed = false; + core::PointSet pts; + core::ColorScale scale; // 取首个到达源的色阶定值域 + bool haveScale = false; + }; + auto agg = std::make_shared(); + agg->pending = static_cast(params.sourceDatasetIds.size()); + + for (const std::string& srcId : params.sourceDatasetIds) { + loadSection( + srcId, + [this, dsId, params, agg, onOk, onErr](SectionData s) { + if (agg->failed) return; + appendGridPoints(s.grid, agg->pts); + if (!agg->haveScale) { + agg->scale = s.scale; + agg->haveScale = true; + } + if (--agg->pending > 0) return; // 还有源未到齐 + finalizeVolume(dsId, agg->pts, agg->scale, params, onOk, onErr); + }, + [agg, onErr](const std::string& m) { + if (agg->failed) return; + agg->failed = true; + onErr("Api3dRepository::loadVolume 源加载失败: " + m); + }); + } } void Api3dRepository::loadTerrainPaths(std::function /*onOk*/, OnError onErr) { diff --git a/src/data/api/Api3dRepository.hpp b/src/data/api/Api3dRepository.hpp index 5e9819d..06651f1 100644 --- a/src/data/api/Api3dRepository.hpp +++ b/src/data/api/Api3dRepository.hpp @@ -1,9 +1,18 @@ #pragma once #include +#include +#include +#include #include #include +#include "geo/GeoLocalFrame.hpp" #include "repo/I3dSceneRepository.hpp" +#include "repo/VolumeBuildParams.hpp" + +namespace geopro::core { +struct PointSet; // algo/IInterpolator.hpp(Grid/ColorScale 经 I3dSceneRepository.hpp 已可见) +} // namespace geopro::core namespace geopro::data { @@ -19,11 +28,21 @@ class IAsyncDatasetRepository; // 给用户明确"未实现"而非假成功。 class Api3dRepository : public I3dSceneRepository { public: - explicit Api3dRepository(IAsyncDatasetRepository& dsRepo); + // frame:全项目共享 GeoLocalFrame(与帘面/底图同一对象)——三维体散点按其 lat/lon→局部米 + // 配准,保证与帘面构造性对齐(含运行期 reanchor)。 + Api3dRepository(IAsyncDatasetRepository& dsRepo, std::shared_ptr frame); DsDimension dimensionOf(const DsRow& ds) const override; + bool isVolumeDataset(const std::string& dsId) const override; - void loadVolume(const std::string& dsId, std::function onOk, + // ── 客户端创建三维体(mock 持久化:内存;端点就绪后换实现)────────────────── + // 登记新三维体(仅存参数,不立即插值)→ 返回新 dsId("vol-N")。插值在首次 loadVolume 惰性做并缓存。 + std::string createVolume(VolumeBuildParams params, const std::string& name); + // 已创建三维体的列表行(ddCode="dd_voxel"),供三维数据集栏合并注入(每次 setDatasets 追加)。 + std::vector volumeRows() const; + + void loadVolume(const std::string& dsId, + std::function onOk, OnError onErr) override; void loadSection(const std::string& dsId, std::function onOk, OnError onErr) override; @@ -56,7 +75,26 @@ public: OnError onErr) override; private: + // 把一条剖面 section grid 的有限值节点按 CurtainActor 同口径定位(lat/lon→frame.toLocal, + // 世界 Z=grid.y 高程)追加为 IDW 散点 → 保证体与帘面同系对齐。 + void appendGridPoints(const core::Grid& g, core::PointSet& pts) const; + // 多源散点聚合完成 → buildVolume + 值域 + 缓存明细/色阶 → onOk(体, 色阶)。 + void finalizeVolume(const std::string& dsId, const core::PointSet& pts, + const core::ColorScale& scale, const VolumeBuildParams& params, + std::function onOk, OnError onErr); + IAsyncDatasetRepository& dsRepo_; + std::shared_ptr frame_; + + // 内存态三维体存储(mock;重启清空)。cachedGrid = 已插值明细(命中即跳过重算)。 + struct StoredVolume { + VolumeBuildParams params; + std::string name; + std::optional cachedGrid; + core::ColorScale cachedScale; // 与 cachedGrid 同时填(源剖面色阶) + }; + std::map volumes_; // dsId → 体 + int volumeCounter_ = 0; }; } // namespace geopro::data diff --git a/src/data/repo/I3dSceneRepository.hpp b/src/data/repo/I3dSceneRepository.hpp index 2042ddc..1e5563b 100644 --- a/src/data/repo/I3dSceneRepository.hpp +++ b/src/data/repo/I3dSceneRepository.hpp @@ -51,9 +51,16 @@ public: // 同步纯函数:ds 类型 → 维度(spec §6.1 映射表)。 virtual DsDimension dimensionOf(const DsRow& ds) const = 0; - // 异步:加载三维体模型(成功回调 VolumeGrid,失败回调消息)。 + // 同步纯函数:该 dsId 是否为三维体数据集 → 控制器据此选「体素」而非「帘面」渲染路径 + // (取代旧的全局 showVoxel/showCurtain 图层开关,按数据集类型分流)。 + virtual bool isVolumeDataset(const std::string& dsId) const = 0; + + // 异步:加载三维体模型(成功回调 VolumeGrid + 其色阶,失败回调消息)。 + // 色阶随体交付(= 源剖面色阶,与帘面一致):体的着色是其加载表示的固有部分, + // 不可由 dsId 经普通数据集仓储取(客户端 mock 体在后端无条目)。 virtual void loadVolume(const std::string& dsId, - std::function onOk, OnError onErr) = 0; + std::function onOk, + OnError onErr) = 0; // 异步:加载剖面(帘面)着色数据(Grid+色阶)。本地样本同步回调;Api 实现走 ERT 反演端点异步回调。 virtual void loadSection(const std::string& dsId, diff --git a/src/data/repo/LocalSample3dRepository.cpp b/src/data/repo/LocalSample3dRepository.cpp index 183fe03..9d61fbf 100644 --- a/src/data/repo/LocalSample3dRepository.cpp +++ b/src/data/repo/LocalSample3dRepository.cpp @@ -8,7 +8,7 @@ #include #include -#include "algo/IdwInterpolator.hpp" +#include "algo/VolumeBuilder.hpp" #include "geo/CrsTransform.hpp" #include "geo/GeoLocalFrame.hpp" #include "model/ColorScale.hpp" @@ -20,31 +20,20 @@ namespace geopro::data { using geopro::core::ColorScale; using geopro::core::CrsTransform; using geopro::core::GeoLocalFrame; -using geopro::core::GridSpec; -using geopro::core::IdwInterpolator; using geopro::core::PointSet; -using geopro::core::ScalarVolume; using geopro::core::ScatterField; namespace { // 与 render::VoxelFromScatters 的默认参数同口径(保持渲染/切片纵向一致)。 -// TODO(P2/P3): 与 render::buildVoxelFromScatters 的 cellXY/cellZ/power/maxDist 默认值重复, -// 宜把"散点→配准→GridSpec→IDW→ScalarVolume"提到 core::algo 共享,避免单方调参静默不一致。 +// 「散点→GridSpec→IDW→ScalarVolume」已提到 core::buildVolume 共享(与 Api3dRepository 同源, +// 消除调参漂移);此处仅保留默认参数 + 本地样本配准。 constexpr double kCellXY = 1.0; constexpr double kCellZ = 0.5; constexpr double kPower = 2.0; constexpr double kMaxDist = 4.0; -constexpr int kMaxDim = 400; constexpr const char* kWgs84 = "EPSG:4326"; -int clampDim(double ext, double cell) { - int n = static_cast(ext / cell) + 1; - if (n < 1) n = 1; - if (n > kMaxDim) n = kMaxDim; - return n; -} - } // namespace LocalSample3dRepository::LocalSample3dRepository(LocalSampleRepository& base, std::string projectCrs, @@ -65,8 +54,9 @@ DsDimension LocalSample3dRepository::dimensionOf(const DsRow& ds) const { return DsDimension::Other; } -void LocalSample3dRepository::loadVolume(const std::string& /*dsId*/, - std::function onOk, OnError onErr) { +void LocalSample3dRepository::loadVolume( + const std::string& /*dsId*/, + std::function onOk, OnError onErr) { // P1 样本:dsId 暂未使用,固定读同一组交叉剖面散点→体素(真实 Api 实现按 dsId 取)。 try { // 1) 读两条交叉剖面散点 + 色阶;配准到世界局部米 + 深度,组装 IDW 输入点集。 @@ -92,55 +82,28 @@ void LocalSample3dRepository::loadVolume(const std::string& /*dsId*/, return; } - // 2) 点集包络 → GridSpec(角点对齐)。 - double minx = pts.x[0], maxx = pts.x[0]; - double miny = pts.y[0], maxy = pts.y[0]; - double minz = pts.z[0], maxz = pts.z[0]; - for (std::size_t i = 1; i < pts.v.size(); ++i) { - minx = std::min(minx, pts.x[i]); maxx = std::max(maxx, pts.x[i]); - miny = std::min(miny, pts.y[i]); maxy = std::max(maxy, pts.y[i]); - minz = std::min(minz, pts.z[i]); maxz = std::max(maxz, pts.z[i]); - } + // 2) 点集 → GridSpec → IDW → 数据实测值域(共享 core::buildVolume)。 + geopro::core::BuiltVolume bv = + geopro::core::buildVolume(pts, kCellXY, kCellZ, kPower, kMaxDist); - GridSpec spec{}; - spec.ox = minx; spec.oy = miny; spec.oz = minz; - spec.dx = kCellXY; spec.dy = kCellXY; spec.dz = kCellZ; - spec.nx = clampDim(maxx - minx, kCellXY); - spec.ny = clampDim(maxy - miny, kCellXY); - spec.nz = clampDim(maxz - minz, kCellZ); - spec.power = kPower; - spec.maxDist = kMaxDist; - - // 3) IDW → ScalarVolume(maxDist 外 NaN 留空)。 - const IdwInterpolator idw; - ScalarVolume vol = idw.interpolate(pts, spec); - - // 4) 值域:优先 colorBar 真实分段值,否则数据实测。 - double vmin, vmax; + // 3) 值域:优先 colorBar 真实分段值,否则 buildVolume 的数据实测范围。 + double vmin = bv.vmin, vmax = bv.vmax; ColorScale cs; try { cs = base_.loadScatterColorScale("grid1"); } catch (const std::exception&) { - // 色阶缺失 → 退化为数据实测范围。 + // 色阶缺失 → 沿用数据实测范围。 } const std::vector stops = cs.stopValues(); if (stops.size() >= 2) { vmin = stops.front(); vmax = stops.back(); - } else { - vmin = std::numeric_limits::infinity(); - vmax = -std::numeric_limits::infinity(); - for (double v : vol.data()) { - if (std::isnan(v)) continue; - vmin = std::min(vmin, v); vmax = std::max(vmax, v); - } - if (!(vmin < vmax)) { vmin = 0.0; vmax = 1.0; } } - VolumeGrid out{std::move(vol), - {{spec.ox, spec.oy, spec.oz}}, - {{spec.dx, spec.dy, spec.dz}}, + VolumeGrid out{std::move(bv.vol), + {{bv.spec.ox, bv.spec.oy, bv.spec.oz}}, + {{bv.spec.dx, bv.spec.dy, bv.spec.dz}}, vmin, vmax}; - onOk(std::move(out)); + onOk(std::move(out), std::move(cs)); } catch (const std::exception& e) { onErr(std::string("LocalSample3dRepository::loadVolume: ") + e.what()); } diff --git a/src/data/repo/LocalSample3dRepository.hpp b/src/data/repo/LocalSample3dRepository.hpp index 414a923..1b2f7d4 100644 --- a/src/data/repo/LocalSample3dRepository.hpp +++ b/src/data/repo/LocalSample3dRepository.hpp @@ -23,7 +23,10 @@ public: double baseLat, double baseLon); DsDimension dimensionOf(const DsRow& ds) const override; - void loadVolume(const std::string& dsId, std::function onOk, + // 本地样本无客户端创建的三维体(样本体经旧 showVoxel 路径,非按 ds 类型分流)→ 恒 false。 + bool isVolumeDataset(const std::string& /*dsId*/) const override { return false; } + void loadVolume(const std::string& dsId, + std::function onOk, OnError onErr) override; void loadSection(const std::string& dsId, std::function onOk, OnError onErr) override; diff --git a/src/data/repo/VolumeBuildParams.hpp b/src/data/repo/VolumeBuildParams.hpp new file mode 100644 index 0000000..630eb3f --- /dev/null +++ b/src/data/repo/VolumeBuildParams.hpp @@ -0,0 +1,23 @@ +#pragma once +#include +#include + +namespace geopro::data { + +// 三维体构建参数(设计文档 §7.4;用户决策 2026-06-17:不冻结 gridSpec)。 +// 必存元数据 = 源数据集 + 插值模型/参数 + 色阶来源;它们小且可复现(详情面板展示用)。 +// gridSpec / values(明细)为派生:每次按源散点确定性重算(IDW 确定 + 源 ds 锁定 → +// 结果必然一致),故不存为"冻结锚点",由 Api3dRepository 缓存即可(见计划 §1)。 +struct VolumeBuildParams { + enum class Model { Idw, Kriging }; // 本期仅 Idw 实现;Kriging 为占位(core 暂无)。 + + std::vector sourceDatasetIds; // 源数据集 id(≥1;被引用即应锁定不可改) + Model interpModel = Model::Idw; + double cellXY = 1.0; // 水平网格间距(米) + double cellZ = 0.5; // 竖向网格间距(米) + double power = 2.0; // IDW 幂 + double maxDist = 4.0; // 超距 blank(约束插值域,外为 NaN) + std::string colorScaleId; // 色阶来源 ds(空 = 取首个源的色阶) +}; + +} // namespace geopro::data