diff --git a/src/app/VolumeParamsDialog.cpp b/src/app/VolumeParamsDialog.cpp index b68f505..fd1f131 100644 --- a/src/app/VolumeParamsDialog.cpp +++ b/src/app/VolumeParamsDialog.cpp @@ -31,7 +31,7 @@ namespace { constexpr double kDefCellXY = 1.0; constexpr double kDefCellZ = 0.5; constexpr double kDefPower = 2.0; -constexpr double kDefMaxDist = 4.0; +constexpr double kDefMaxDist = 0.0; // 0=自动「覆盖测区」(全数据 IDW + 凸包足迹裁剪,对齐 Surfer) constexpr int kRoleDsId = Qt::UserRole + 1; // 源树项存 dsId constexpr int kRoleMountId = Qt::UserRole + 1; // 生成位置树项存 id constexpr int kRoleMountConfType = Qt::UserRole + 2; // 生成位置树项存 confType @@ -187,11 +187,14 @@ VolumeParamsDialog::VolumeParamsDialog(const QVector& sources, 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); + maxDist_ = makeSpin(kDefMaxDist, 0.0, 10000.0, 1.0, 2); + // maxDist=0(=最小值)→ 显示「自动」:全数据 IDW + 凸包足迹裁剪填满测区(对齐客户 Surfer); + // >0 → 局部 IDW 半径(剖面附近清晰、跨大空隙可能填不满)。 + maxDist_->setSpecialValueText(QStringLiteral("自动 (覆盖测区)")); form2->addRow(formkit::editLabel(QStringLiteral("水平间距 (米)")), cellXY_); form2->addRow(formkit::editLabel(QStringLiteral("竖向间距 (米)")), cellZ_); form2->addRow(formkit::editLabel(QStringLiteral("IDW 幂次")), power_); - form2->addRow(formkit::editLabel(QStringLiteral("最大影响距离 (米)")), maxDist_); + form2->addRow(formkit::editLabel(QStringLiteral("最大影响距离 (米, 0=自动)")), maxDist_); cardLay->addLayout(form2); cols->addWidget(card, 1); diff --git a/src/app/VolumePropertiesDialog.cpp b/src/app/VolumePropertiesDialog.cpp index 3818caf..27893ec 100644 --- a/src/app/VolumePropertiesDialog.cpp +++ b/src/app/VolumePropertiesDialog.cpp @@ -40,7 +40,9 @@ VolumePropertiesDialog::VolumePropertiesDialog(const QString& name, const Volume .row(QStringLiteral("网格间距"), QStringLiteral("XY=%1 m Z=%2 m") .arg(info.params.cellXY, 0, 'f', 2) .arg(info.params.cellZ, 0, 'f', 2)) - .row(QStringLiteral("超距"), QStringLiteral("%1 m").arg(info.params.maxDist, 0, 'f', 2)) + .row(QStringLiteral("超距"), info.params.maxDist > 0.0 + ? QStringLiteral("%1 m").arg(info.params.maxDist, 0, 'f', 2) + : QStringLiteral("自动 (覆盖测区)")) .row(QStringLiteral("色阶来源"), info.params.colorScaleId.empty() ? QStringLiteral("首个源数据集") : QString::fromStdString(info.params.colorScaleId)); diff --git a/src/app/VtkSceneView.cpp b/src/app/VtkSceneView.cpp index e7f26e7..e27753c 100644 --- a/src/app/VtkSceneView.cpp +++ b/src/app/VtkSceneView.cpp @@ -15,9 +15,11 @@ #include #include #include +#include #include #include #include +#include #include "CameraPreset.hpp" #include "Scene.hpp" @@ -33,6 +35,20 @@ namespace geopro::app { namespace { +// 运行时改某体的最大不透明度:把其不透明度传递函数「最高 x 的点」(=vmax/qmax 处的最大不透明度点) +// 的不透明度值设为 maxOpacity。不重建体、不动颜色与留空透明点,实时生效。 +void applyVolumeOpacity(vtkVolume* v, double maxOpacity) { + if (!v || !v->GetProperty()) return; + vtkPiecewiseFunction* op = v->GetProperty()->GetScalarOpacity(); + if (!op) return; + const int n = op->GetSize(); + if (n <= 0) return; + double node[4]; // {x, y(opacity), midpoint, sharpness} + op->GetNodeValue(n - 1, node); + node[1] = maxOpacity; + op->SetNodeValue(n - 1, node); +} + // 控制器层枚举 → render 层枚举(保持控制器不依赖 render)。 geopro::render::AxesMode toRenderMode(geopro::controller::AxesMode m) { switch (m) { @@ -124,6 +140,13 @@ void VtkSceneView::clear() { void VtkSceneView::setVerticalExaggeration(double ve) { verticalExaggeration_ = ve; } +void VtkSceneView::setVolumeOpacity(double maxOpacity) { + volumeOpacity_ = std::clamp(maxOpacity, 0.0, 1.0); // 记为后续新体默认 + for (auto& kv : volumes_) // 实时更新所有已渲染体(不重建) + applyVolumeOpacity(kv.second.volume, volumeOpacity_); + if (renderWindow_) renderWindow_->Render(); +} + void VtkSceneView::addSurveyLine(const geopro::core::Grid& grid) { auto line = geopro::render::buildSurveyLine(grid, *frame_); if (line) { @@ -175,6 +198,7 @@ void VtkSceneView::addVolume(const std::string& dsId, const geopro::data::Volume // picker 命中体、worldPoint 落体内 → nearestSlice 按平面距离选错切片(用户 ④ 串选)。 volume->PickableOff(); volume->SetVisibility(analysisMode2D_ ? 0 : 1); // 体=3D内容:二维分析下隐藏 + applyVolumeOpacity(volume, volumeOpacity_); // 套用当前透明度(工具条调过则新体跟随) scene_.addViewProp(volume); dsProps_[dsId].push_back(volume); currentVolumeImage_ = image; @@ -182,7 +206,17 @@ void VtkSceneView::addVolume(const std::string& dsId, const geopro::data::Volume currentVmin_ = vol.vmin; currentVmax_ = vol.vmax; volumeOwnerDs_ = dsId; - volumes_[dsId] = VolumeRec{image, cs, vol.vmin, vol.vmax}; // 多体并发:登记本体 image + volumes_[dsId] = VolumeRec{image, cs, vol.vmin, vol.vmax, volume}; // 多体并发:登记本体 image+actor + + // G3 等值面:在值域高段(0.7)抽不透明实心异常体(参考图红块)。挂同一 dsProps_ → 随体一并移除。 + const double isoVal = vol.vmin + 0.7 * (vol.vmax - vol.vmin); + auto iso = geopro::render::buildIsosurface(image, cs, vol.vmin, vol.vmax, isoVal); + if (iso) { + iso->PickableOff(); // 不参与拾取(同体 actor,避免串选) + iso->SetVisibility(analysisMode2D_ ? 0 : 1); // 3D 内容:二维分析下隐藏 + scene_.addActor(iso); + dsProps_[dsId].push_back(iso); + } if (onVolumeChanged) onVolumeChanged(); } } diff --git a/src/app/VtkSceneView.hpp b/src/app/VtkSceneView.hpp index 1b2eaba..b7e67cf 100644 --- a/src/app/VtkSceneView.hpp +++ b/src/app/VtkSceneView.hpp @@ -19,6 +19,7 @@ class vtkRenderer; class vtkRenderWindow; class vtkProp; class vtkActor; +class vtkVolume; namespace geopro::app { @@ -34,6 +35,7 @@ public: void clear() override; void setVerticalExaggeration(double ve) override; + void setVolumeOpacity(double maxOpacity) override; // 运行时调已渲染体 + 后续新体的最大不透明度 double zRefElev() const override { return zRefElev_; } void addSurveyLine(const geopro::core::Grid& grid) override; void addCurtain(const std::string& dsId, const geopro::core::Grid& grid, @@ -127,6 +129,7 @@ private: std::shared_ptr frame_; double zRefElev_; double verticalExaggeration_ = 1.0; + double volumeOpacity_ = 0.30; // 三维体体绘制最大不透明度(默认 0.30,工具条可调);新体建好即套用 // 是否已按真实剖面 lat/lon 重锚 frame 原点(每次 clear 重置):默认原点取自样本、可能离真实数据 // 很远→局部坐标巨大、轴刻度无意义;首个带经纬剖面到达时重锚到其中心,同选择内多剖面共用配准。 bool frameAnchoredToData_ = false; @@ -151,6 +154,7 @@ private: vtkSmartPointer image; geopro::core::ColorScale cs; double vmin = 0.0, vmax = 0.0; + vtkSmartPointer volume; // 体 actor(运行时调不透明度:改其 property 的不透明度传递函数) }; std::map volumes_; diff --git a/src/app/VtkViewToolbar.cpp b/src/app/VtkViewToolbar.cpp index 36f5124..0796058 100644 --- a/src/app/VtkViewToolbar.cpp +++ b/src/app/VtkViewToolbar.cpp @@ -1,7 +1,11 @@ #include "VtkViewToolbar.hpp" #include +#include +#include +#include #include +#include #include #include @@ -66,7 +70,10 @@ VtkViewToolbar::VtkViewToolbar(QWidget* parent) : QWidget(parent) { viewDirButtons_.push_back(b); // 二维分析下禁用(会改朝向、破坏近俯视锁定) } sep(); - // ── 段3:缩放 / 复位 ── + // ── 段3:透明度(缩放段顶部,放大上面)+ 缩放 / 复位 ── + opacityBtn_ = textBtn(QStringLiteral("透")); + opacityBtn_->setToolTip(QStringLiteral("三维体透明度")); + connect(opacityBtn_, &QToolButton::clicked, this, &VtkViewToolbar::showOpacityPopup); connect(iconBtn(Glyph::Plus, QStringLiteral("放大")), &QToolButton::clicked, this, &VtkViewToolbar::zoomInRequested); connect(iconBtn(Glyph::Minus, QStringLiteral("缩小")), &QToolButton::clicked, this, @@ -83,6 +90,37 @@ VtkViewToolbar::VtkViewToolbar(QWidget* parent) : QWidget(parent) { "QToolButton:hover{background:{{bg/hover}};color:{{accent/primary}};}")); setFixedWidth(44); adjustSize(); + + // 透明度弹出面板(Qt::Popup → 点击外部自动关闭):横向滑块 0~100(%),默认 30(=0.30)。 + opacityPopup_ = new QWidget(this, Qt::Popup); + opacityPopup_->setAttribute(Qt::WA_StyledBackground, true); + applyTokenizedStyleSheet( + opacityPopup_, + QStringLiteral("QWidget{background:{{bg/panel-subtle}};border:1px solid {{border/default}};" + "border-radius:8px;}QLabel{border:none;color:{{text/primary}};}")); + auto* pl = new QVBoxLayout(opacityPopup_); + pl->setContentsMargins(space::kMd, space::kSm, space::kMd, space::kSm); + pl->setSpacing(space::kSm); + opacityLabel_ = new QLabel(QStringLiteral("透明度 30%"), opacityPopup_); + opacitySlider_ = new QSlider(Qt::Horizontal, opacityPopup_); + opacitySlider_->setRange(0, 100); + opacitySlider_->setValue(30); // 默认 0.30,与体绘制默认一致 + opacitySlider_->setFixedWidth(scaledPx(160)); + pl->addWidget(opacityLabel_); + pl->addWidget(opacitySlider_); + connect(opacitySlider_, &QSlider::valueChanged, this, [this](int v) { + opacityLabel_->setText(QStringLiteral("透明度 %1%").arg(v)); + emit opacityChanged(v / 100.0); // 实时下发 + }); + opacityPopup_->adjustSize(); +} + +void VtkViewToolbar::showOpacityPopup() { + if (!opacityPopup_ || !opacityBtn_) return; + // 弹在「透」按钮右侧,与按钮顶对齐(全局坐标,Qt::Popup 顶层窗口)。 + opacityPopup_->move(opacityBtn_->mapToGlobal(QPoint(opacityBtn_->width() + 6, 0))); + opacityPopup_->show(); + opacityPopup_->raise(); } void VtkViewToolbar::setAnalysisMode2D(bool is2D) { diff --git a/src/app/VtkViewToolbar.hpp b/src/app/VtkViewToolbar.hpp index 05e02d6..493ae21 100644 --- a/src/app/VtkViewToolbar.hpp +++ b/src/app/VtkViewToolbar.hpp @@ -4,6 +4,8 @@ #include "I3dSceneView.hpp" // geopro::controller::ViewDir class QToolButton; +class QSlider; +class QLabel; namespace geopro::app { @@ -25,9 +27,16 @@ signals: void zoomInRequested(); void zoomOutRequested(); void fitRequested(); // 复位=适配 + void opacityChanged(double maxOpacity); // 三维体透明度滑块(0~1,实时) private: + void showOpacityPopup(); // 在透明度按钮旁弹出滑块面板 + std::vector viewDirButtons_; // 6 向快捷视图按钮:二维分析下禁用 + QToolButton* opacityBtn_ = nullptr; // 「透」透明度按钮(缩放段顶部) + QWidget* opacityPopup_ = nullptr; // 弹出滑块面板(Qt::Popup,点外即关) + QSlider* opacitySlider_ = nullptr; // 0~100(% → 0~1) + QLabel* opacityLabel_ = nullptr; // 「透明度 N%」读数 }; } // namespace geopro::app diff --git a/src/app/main.cpp b/src/app/main.cpp index 8ef5986..bb7fdeb 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -107,6 +107,7 @@ #include "SlicePropertiesDialog.hpp" #include "SliceExport.hpp" #include "ToastOverlay.hpp" +#include "panels/LoadingOverlay.hpp" #include "TopBar.hpp" #include "VolumeParamsDialog.hpp" #include "VolumePropertiesDialog.hpp" @@ -179,6 +180,8 @@ #include #include #include +#include +#include #include #include #include @@ -199,6 +202,8 @@ public: { host_->installEventFilter(this); } + // overlay 定位/置顶后,再把这些控件 raise 到 overlay 之上(如工具条/提示常驻最上层)。 + void setRaiseAfter(std::vector w) { raiseAfter_ = std::move(w); } void reposition() { overlay_->adjustSize(); @@ -211,8 +216,18 @@ public: // 偏移取非负:保证浮层矩形始终 ⊆ host 矩形(不跑到 host 外)。 const int dx = std::max(0, (h.width() - o.width()) / 2); const int dy = std::max(0, (h.height() - o.height()) / 2); - overlay_->move(host_->x() + dx, host_->y() + dy); - overlay_->raise(); + if (overlay_->parentWidget() == host_) { + // overlay 是 host 的子级:本地坐标居中。须 raise 到 GL 之上才可见(QVTKOpenGLStereoWidget + // 的子控件 lower 会落到 GL 之下→不可见),再把工具条/提示 raise 回它之上→工具条永在最上层。 + overlay_->move(dx, dy); + overlay_->raise(); + for (QWidget* w : raiseAfter_) + if (w) w->raise(); + } else { + // overlay 与 host 同级:换算到共同父坐标系并置顶。 + overlay_->move(host_->x() + dx, host_->y() + dy); + overlay_->raise(); + } } protected: @@ -226,6 +241,7 @@ protected: private: QWidget* overlay_; QWidget* host_; + std::vector raiseAfter_; // 定位后再 raise 到 overlay 之上的常驻控件(工具条/提示) }; // 取 vector 中位数(用于由测线 lat/lon 推世界系原点)。空则返回 0。 @@ -399,6 +415,13 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re split->addWidget(vtkWidget); split->setStretchFactor(0, 0); split->setStretchFactor(1, 1); + // 折叠/展开抽屉 → 同步 QSplitter 尺寸:收起时把抽屉栏压到按钮宽(18)、余量全给画布(否则残留空白区); + // 展开恢复约 280。setSizes 为相对比例,splitter 按 min/max 钳制后铺满。 + QObject::connect(drawer, &geopro::app::ColumnDrawer::collapsedChanged, split, + [split](bool collapsed) { + split->setSizes(collapsed ? QList{18, 100000} + : QList{280, 100000}); + }); centerLayout->addWidget(viewHeader); centerLayout->addWidget(split, 1); // 工具条悬浮于画布左上角(overlay;左上固定,画布 resize 无需重定位)。 @@ -418,6 +441,10 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re "border:1px solid {{accent/primary}};padding:8px 12px;}")); anomalyHint->hide(); + // 保存三维体等待蒙版(公共组件 LoadingOverlay):挂 centerWidget → showOver 铺满整个「VTK视图」 + // 子视图(表头+左树+画布)并 raise 到顶层;保存阶段显示,完成即撤。复用于其他需要整窗等待的场景。 + auto* vtkLoading = new geopro::app::LoadingOverlay(centerWidget); + // ── 二维分析 B 期:高程 Z 拖动读数浮层(顶部居中)+ 足迹拾取/拖动回调注入交互样式 ────────── // 拖动选中足迹时显示其当前世界 Z,松开隐藏;不挡画布鼠标。深底方角(同异常提示坑规避)。 auto* elevHint = new QLabel(vtkWidget); @@ -811,6 +838,13 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re &geopro::controller::VtkSceneController::zoomOut); QObject::connect(viewToolbar, &geopro::app::VtkViewToolbar::fitRequested, sceneCtrl, &geopro::controller::VtkSceneController::fit); + // 透明度滑块 → 运行时调三维体不透明度(实时)。vtkWidget->update() 保证离屏渲染呈现到屏 + // (滑块在弹出面板上,vtkWidget 自身无 paint 事件,需显式请求重绘,同 volumeRendered 修复)。 + QObject::connect(viewToolbar, &geopro::app::VtkViewToolbar::opacityChanged, vtkWidget, + [sceneCtrl, vtkWidget](double op) { + sceneCtrl->setVolumeOpacity(op); + vtkWidget->update(); + }); // 设置(⚙)→ 工具条右侧 toggle 抽屉面板(非模态弹窗)。 QObject::connect(viewToolbar, &geopro::app::VtkViewToolbar::axesSettingsRequested, &window, [axesPanel, viewToolbar]() { @@ -852,8 +886,8 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re }); // 段头「+新增三维体」:弹参数对话框 → 组装真实 VoxelGenerateRequest → createVolume(mock) → 刷新。 QObject::connect(analysisTab, &geopro::app::CategoryAnalysisTab::generateVolumeRequested, &window, - [&window, &nav, scene3dRepo, refreshAnalysis, lastSourceRows, - lastStructNodes](const QString& /*dsTypeCode*/, const QStringList& sourceIds) { + [&window, &nav, scene3dRepo, refreshAnalysis, lastSourceRows, lastStructNodes, + analysisTab, vtkLoading](const QString& /*dsTypeCode*/, const QStringList& sourceIds) { if (sourceIds.isEmpty()) return; // 源 ds(id,名称,结构归属):名称/structParentId 从最近拉取的行查(缺则用 id)。 QVector sources; @@ -889,8 +923,47 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re req.power = p.power; req.maxDist = p.maxDist; req.colorScaleId = p.colorScaleId; - scene3dRepo->createVolume(req); - refreshAnalysis(); + // 保存(目前 mock,瞬时同步):直接建体+入树,不弹等待蒙版——mock 没有耗时,蒙版 + // 会 showOver 后立刻 hide 在渲染区一闪而过。等待蒙版只在「真有耗时的后端保存」 + // 才有意义:接真实异步保存端点后,在那个调用外用 LoadingOverlay 包蒙版即可。 + // 保存阶段:VTK 整体加等待蒙版(挂 centerWidget→盖整个 VTK 子视图)。singleShot 让 + // 蒙版先绘出再干活,避免 mock 即时完成时蒙版根本没画出来。 + vtkLoading->showOver(QStringLiteral("正在保存三维体…")); + QTimer::singleShot(0, &window, [=]() { + const std::string newId = scene3dRepo->createVolume(req); // 保存 + 注册(mock) + { + // refreshAnalysis 重建列表会让各段重发"勾选变化"→ 触发场景重算 → 已渲染 + // 剖面被删了又加。保存时屏蔽 analysisTab 渲染信号 → 渲染区一动不动。 + const QSignalBlocker block(analysisTab); + refreshAnalysis(); // 入三维体树(仅刷列表,不触发渲染) + } + vtkLoading->hide(); // 保存阶段结束 → 撤蒙版 + const QString qid = QString::fromStdString(newId); + // 渲染阶段无蒙版:自动勾选新体 → 增量加体(剖面不动) + 标题前等待 spinner; + // 渲染完成由 volumeRendered 撤 spinner、失败由 loadFailed 兜底。 + analysisTab->setItemChecked(qid, true); + analysisTab->setItemBusy(qid, true); + analysisTab->scrollItemToTop(qid); // 新三维体行尽量滚到分析栏顶部 + }); + }); + // 任一数据集(剖面/体)异步加载开始 → 列表项复选框转等待 spinner;渲染完成 → 复原复选框。 + // 覆盖非三维体:勾选剖面首次渲染较慢时也有等待反馈(用户反馈)。 + QObject::connect(sceneCtrl, &geopro::controller::VtkSceneController::datasetLoading, analysisTab, + [analysisTab](const QString& dsId) { analysisTab->setItemBusy(dsId, true); }); + QObject::connect(sceneCtrl, &geopro::controller::VtkSceneController::datasetRendered, analysisTab, + [analysisTab](const QString& dsId) { analysisTab->setItemBusy(dsId, false); }); + // 根因修复:异步建体的渲染发生在后台线程触发的投递事件里,renderWindow->Render() 渲到离屏 FBO, + // 但 QVTKOpenGLStereoWidget 把 FBO「呈现到屏」绑定 Qt 的 paint;建体完成后 app 空闲、无后续 paint, + // FBO 渲好却没贴到屏 → 体偶发不可见(动鼠标产生 paint 才出来)。显式请求一次 Qt 重绘补上呈现步骤。 + QObject::connect(sceneCtrl, &geopro::controller::VtkSceneController::volumeRendered, vtkWidget, + [vtkWidget](const QString&) { vtkWidget->update(); }); + // 加载失败 → 兜底撤回所有 spinner(避免标题卡在等待态)。 + QObject::connect(sceneCtrl, &geopro::controller::VtkSceneController::loadFailed, analysisTab, + [analysisTab, &window](const QString& msg) { + analysisTab->clearAllBusy(); + // 明确提示而非静默:源数据加载失败(如后端 502)时用户能区分"后端没给数据"与"渲染问题"。 + geopro::app::showToast( + &window, QStringLiteral("数据加载失败,未生成三维体:%1").arg(msg)); }); // 双击数据详情:dd_slice→切片属性;dd_voxel→三维体属性(同 colAnalysis 详情口径)。 QObject::connect(analysisTab, &geopro::app::CategoryAnalysisTab::detailRequested, &window, @@ -1197,7 +1270,10 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re // ── 中央“空状态”引导浮层:未接入真实 sections 时,引导首次使用者从左侧入手。── // 透明背景 + 鼠标穿透(不挡 QVTK 交互);CenterOverlay 随视口尺寸保持居中; // 接入真实中央数据后改成依 sections 是否为空调 setVisible 即可。 - auto* emptyState = new QFrame(centerWidget); + // 挂在 vtkWidget 下(而非 centerWidget):使其与工具条/提示同属 vtkWidget 子级,CenterOverlay 会把它 + // 压到子级最底(在 GL 之上、工具条/提示之下)→ 工具条永远在最上层、引导层在最下层(修视图缩小时 + // 引导层挡住工具条)。 + auto* emptyState = new QFrame(vtkWidget); emptyState->setObjectName(QStringLiteral("centralEmpty")); emptyState->setAttribute(Qt::WA_TransparentForMouseEvents); // 背景取 canvas/bg(#0B1320)——与画布同色:在原生 GL 上覆盖透明无法生效(会回退成不透明浅底), @@ -1240,6 +1316,8 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re esLay->addWidget(esHint); auto* emptyCentering = new CenterOverlay(emptyState, vtkWidget); + // 引导层定位后,把工具条与提示浮层 raise 到其上 → 工具条永在最上层(修:缩小渲染区时引导层挡工具条)。 + emptyCentering->setRaiseAfter({viewToolbar, anomalyHint, elevHint}); emptyCentering->reposition(); auto* vtkDock = new ads::CDockWidget(QStringLiteral("VTK视图")); @@ -1522,8 +1600,10 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re // 仅真正换项目用(delete-refresh 等 switchProject(currentProjectId) 不走此处,避免误清)。 auto clearCentral = [sceneCtrl, emptyState, checkedProfiles, checkedAnalysis, pushChecked, lastSourceRows, refreshAnalysis, checkedSliceIds, - syncSlices, basemap, sceneView]() { - // 数据源清空 → 5 段 + col2D 清空(refreshAnalysis 内 setBuckets/dim2D;客户端三维体仍驻留)。 + syncSlices, basemap, sceneView, scene3dRepo]() { + // 切项目:先清内存态三维体/切片/异常(否则上个项目的产物残留进新项目列表),再 refreshAnalysis。 + scene3dRepo->clearMockData(); + // 数据源清空 → 5 段 + col2D 清空(refreshAnalysis 内 setBuckets/dim2D)。 *lastSourceRows = {}; refreshAnalysis(); // 勾选集清空并下发空到 VTK(帘面/体素/切片/2D 足迹全部撤场)。 @@ -2095,6 +2175,17 @@ public: return false; } }; + +// VTK 警告/错误输出窗口:转 Qt 日志(qWarning),不弹独立窗口;并把 VTK 报错落进 geopro 日志便于排查。 +class QtVtkOutputWindow : public vtkOutputWindow { +public: + static QtVtkOutputWindow* New(); + vtkTypeMacro(QtVtkOutputWindow, vtkOutputWindow); + void DisplayText(const char* txt) override { + if (txt && *txt) qWarning().noquote() << "[vtk]" << QString::fromUtf8(txt).trimmed(); + } +}; +vtkStandardNewMacro(QtVtkOutputWindow); } // namespace int main(int argc, char* argv[]) @@ -2114,6 +2205,15 @@ int main(int argc, char* argv[]) QSurfaceFormat::setDefaultFormat(QVTKOpenGLStereoWidget::defaultFormat()); GuardedApplication app(argc, argv); // 顶层异常护栏:slot/事件里的异常不致客户端崩溃 + // VTK 警告/错误转 Qt 日志(qWarning → geopro 日志),彻底不弹独立 vtkOutputWindow 窗口 + // (Windows 默认 vtkWin32OutputWindow 不认 DisplayMode,仍会弹空窗;故直接替换实例)。 + // 同时把 VTK 报错落进日志,便于排查(如体绘制偶发不渲染的真因)。 + { + auto* vtkOut = QtVtkOutputWindow::New(); + vtkOutputWindow::SetInstance(vtkOut); + vtkOut->Delete(); // SetInstance 已持引用 + } + // 异步 ApiCall::finished 等信号携带 ApiResponse;注册元类型以支持跨 QueuedConnection 传递 // (当前详情链路为同线程 DirectConnection,非严格必需,但作防御性注册,见 spec §5.1)。 qRegisterMetaType(); diff --git a/src/app/panels/DatasetListPanel.cpp b/src/app/panels/DatasetListPanel.cpp index b396344..76c7461 100644 --- a/src/app/panels/DatasetListPanel.cpp +++ b/src/app/panels/DatasetListPanel.cpp @@ -90,14 +90,26 @@ public: const bool isContainer = idx.data(kDsDdCodeRole).toString() == QStringLiteral("container"); if (checkable) { QRect checkRect(r.left() + 12, r.top() + (r.height() - box) / 2, box, box); - const auto cs = static_cast(idx.data(Qt::CheckStateRole).toInt()); - QStyleOptionViewItem o(opt); - o.rect = checkRect; - o.state &= ~QStyle::State_HasFocus; - o.state |= (cs == Qt::Checked ? QStyle::State_On : QStyle::State_Off); - const QWidget* w = opt.widget; - QStyle* st = w ? w->style() : QApplication::style(); - st->drawPrimitive(QStyle::PE_IndicatorItemViewItemCheck, &o, p, w); + if (idx.data(kDsBusyRole).toBool()) { + // 渲染中:复选框位置画旋转 spinner(等待动画,角度由 kDsSpinAngleRole 驱动)。 + const int angle = idx.data(kDsSpinAngleRole).toInt(); + p->save(); + p->setRenderHint(QPainter::Antialiasing, true); + QPen pen(geopro::app::tokenColor("accent/primary"), 2.0); + pen.setCapStyle(Qt::RoundCap); + p->setPen(pen); + p->drawArc(QRectF(checkRect).adjusted(2, 2, -2, -2), -angle * 16, 270 * 16); + p->restore(); + } else { + const auto cs = static_cast(idx.data(Qt::CheckStateRole).toInt()); + QStyleOptionViewItem o(opt); + o.rect = checkRect; + o.state &= ~QStyle::State_HasFocus; + o.state |= (cs == Qt::Checked ? QStyle::State_On : QStyle::State_Off); + const QWidget* w = opt.widget; + QStyle* st = w ? w->style() : QApplication::style(); + st->drawPrimitive(QStyle::PE_IndicatorItemViewItemCheck, &o, p, w); + } textLeftPad = 12 + box + 8; // 复选框右侧留白后再放文本 } else if (isContainer) { // 容器文本左缘对齐子级复选框的左缘(r.left()+12)——使「容器→带框子级」的视觉缩进 = 一个树级 @@ -143,6 +155,7 @@ public: const QModelIndex& idx) override { if (!(idx.flags() & Qt::ItemIsUserCheckable)) return QStyledItemDelegate::editorEvent(ev, model, opt, idx); + const bool busy = idx.data(kDsBusyRole).toBool(); // 渲染中 → 吞掉勾选交互 const QRect r = opt.rect.adjusted(4, 2, -4, -2); const int box = 16; // 命中区放宽到复选框左侧整段(含一点文本起始),便于点击。 @@ -154,12 +167,14 @@ public: if (ev->type() == QEvent::MouseButtonRelease) { auto* me = static_cast(ev); if (me->button() == Qt::LeftButton && hit.contains(me->pos())) { + if (busy) return true; // 渲染中:不切换,仅消费 toggle(); return true; } } else if (ev->type() == QEvent::KeyPress) { auto* ke = static_cast(ev); if (ke->key() == Qt::Key_Space || ke->key() == Qt::Key_Select) { + if (busy) return true; toggle(); return true; } diff --git a/src/app/panels/DatasetListPanel.hpp b/src/app/panels/DatasetListPanel.hpp index 2e3f8d6..aa2d89b 100644 --- a/src/app/panels/DatasetListPanel.hpp +++ b/src/app/panels/DatasetListPanel.hpp @@ -23,6 +23,8 @@ constexpr int kDsNameRole = 0x0105; // Qt::UserRole + 5(dsName,详情页 constexpr int kDsTypeNameRole = 0x0106; // Qt::UserRole + 6(类型名,快速筛选用) constexpr int kDsCreateTimeRole = 0x0107; // Qt::UserRole + 7(创建时间,按日期筛选用) constexpr int kDsTmObjectIdRole = 0x0108; // Qt::UserRole + 8(所属 TM 对象 id=白化 structParentId) +constexpr int kDsBusyRole = 0x0109; // Qt::UserRole + 9(true=该行渲染中,复选框位置画等待 spinner) +constexpr int kDsSpinAngleRole = 0x010A; // Qt::UserRole + 10(spinner 角度,定时器驱动,delegate 据此旋转) // 数据页签:树形(按 DsRow.parentId 嵌套,源数据为根、派生数据挂其下,对齐原版 el-table 树)。 // 每项列0:文本 = dsName +「创建时间 · 类型名」;data(0,角色) 存 dsId/ddCode/dsName。 diff --git a/src/app/panels/LoadingOverlay.cpp b/src/app/panels/LoadingOverlay.cpp index 095c1f9..b7f2460 100644 --- a/src/app/panels/LoadingOverlay.cpp +++ b/src/app/panels/LoadingOverlay.cpp @@ -30,7 +30,10 @@ LoadingOverlay::LoadingOverlay(QWidget* parent) : QWidget(parent), label_(new QL hide(); } -void LoadingOverlay::showOver() { +void LoadingOverlay::showOver() { showOver(QStringLiteral("加载中…")); } + +void LoadingOverlay::showOver(const QString& message) { + label_->setText(message); if (parentWidget()) setGeometry(parentWidget()->rect()); raise(); show(); diff --git a/src/app/panels/LoadingOverlay.hpp b/src/app/panels/LoadingOverlay.hpp index 624b4eb..fc33499 100644 --- a/src/app/panels/LoadingOverlay.hpp +++ b/src/app/panels/LoadingOverlay.hpp @@ -3,12 +3,14 @@ class QLabel; namespace geopro::app { -// 半透明「加载中…」遮罩。贴在目标视图上层,showOver()/hide() 切换,几何随父 resize 跟随。 +// 半透明「等待」遮罩(公共组件)。贴在任意目标视图上层(含 VTK QVTKOpenGLStereoWidget), +// showOver()/hide() 切换,几何随父 resize 跟随。可传自定义文案在不同场景复用。 class LoadingOverlay : public QWidget { Q_OBJECT public: explicit LoadingOverlay(QWidget* parent); - void showOver(); // 铺满父尺寸、置顶、显示 + void showOver(); // 铺满父尺寸、置顶、显示(默认「加载中…」) + void showOver(const QString& message); // 同上,自定义提示文案(如「正在保存三维体…」) protected: bool eventFilter(QObject* obj, QEvent* ev) override; // 跟随父 resize private: diff --git a/src/app/panels/columns/CategoryAnalysisTab.cpp b/src/app/panels/columns/CategoryAnalysisTab.cpp index d4efa73..7f5959c 100644 --- a/src/app/panels/columns/CategoryAnalysisTab.cpp +++ b/src/app/panels/columns/CategoryAnalysisTab.cpp @@ -1,6 +1,11 @@ #include "panels/columns/CategoryAnalysisTab.hpp" +#include +#include #include +#include +#include +#include #include #include "Theme.hpp" @@ -15,12 +20,14 @@ CategoryAnalysisTab::CategoryAnalysisTab(geopro::data::DatasetFieldDictionary* d outer->setSpacing(0); auto* scroll = new QScrollArea(this); + scroll_ = scroll; scroll->setWidgetResizable(true); scroll->setFrameShape(QFrame::NoFrame); scroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); // 内容随面板宽自适应,不出横向滚动条 outer->addWidget(scroll, 1); auto* content = new QWidget(scroll); + content_ = content; auto* col = new QVBoxLayout(content); col_ = col; col->setContentsMargins(0, space::kSm, 0, space::kSm); // 顶部留白:首段段头不贴顶 @@ -98,6 +105,50 @@ CategorySection* CategoryAnalysisTab::section(const std::string& id) const { return it != sections_.end() ? it->second : nullptr; } +// ds 归属唯一段未知 → 广播到各段,仅含该 ds 的段会命中生效(其余 no-op)。 +void CategoryAnalysisTab::setItemChecked(const QString& dsId, bool on) { + for (auto* sec : ordered_) sec->setChecked(dsId, on); +} +void CategoryAnalysisTab::setItemBusy(const QString& dsId, bool busy) { + for (auto* sec : ordered_) sec->setBusy(dsId, busy); +} +void CategoryAnalysisTab::clearAllBusy() { + for (auto* sec : ordered_) sec->clearAllBusy(); +} + +void CategoryAnalysisTab::scrollItemToTop(const QString& dsId) { + // 先就地展开所在段(同步),再进入多拍重试定位(等布局/滚动条范围结算)。 + for (auto* sec : ordered_) + if (sec->itemFor(dsId)) { sec->ensureExpanded(); break; } + scrollItemToTopRetry(dsId, /*attemptsLeft=*/5); +} + +void CategoryAnalysisTab::scrollItemToTopRetry(const QString& dsId, int attemptsLeft) { + if (!scroll_ || !content_) return; + CategorySection* sec = nullptr; + QTreeWidgetItem* item = nullptr; + for (auto* s : ordered_) + if ((item = s->itemFor(dsId)) != nullptr) { sec = s; break; } + if (sec && item) { + sec->ensureExpanded(); + for (QTreeWidgetItem* p = item->parent(); p; p = p->parent()) + p->setExpanded(true); // 展开树内父节点,使目标行有有效几何 + QTreeWidget* tree = sec->listWidget(); + tree->scrollToItem(item, QAbstractItemView::PositionAtTop); // 内层树(若有内滚动) + // 行顶映射到滚动内容坐标 → 设外层滚动条把该行顶到面板最上方。 + const int y = tree->viewport()->mapTo(content_, tree->visualItemRect(item).topLeft()).y(); + scroll_->verticalScrollBar()->setValue(y); + } + // 多拍重试:每拍布局更趋稳定(滚动条 range 长够、行几何更新),末拍稳定到位 → 根治"有时滚不到位"。 + if (attemptsLeft > 0) { + QPointer self(this); + const QString id = dsId; + QTimer::singleShot(16, this, [self, id, attemptsLeft]() { + if (self) self->scrollItemToTopRetry(id, attemptsLeft - 1); + }); + } +} + void CategoryAnalysisTab::recomputeCheckedUnion() { QStringList all; // ds 归属唯一段,跨段不重复,直接拼接 for (const auto& [id, ids] : checkedBySeg_) all += ids; diff --git a/src/app/panels/columns/CategoryAnalysisTab.hpp b/src/app/panels/columns/CategoryAnalysisTab.hpp index b9a7273..2f927d0 100644 --- a/src/app/panels/columns/CategoryAnalysisTab.hpp +++ b/src/app/panels/columns/CategoryAnalysisTab.hpp @@ -6,6 +6,7 @@ #include class QVBoxLayout; +class QScrollArea; #include "DatasetCategory.hpp" // CategoryBuckets #include "interact/SlicePlaneMath.hpp" // geopro::render::interact::SliceAxis #include "repo/RepoTypes.hpp" @@ -29,6 +30,11 @@ public: void setStructure(const std::vector& nodes); // 转发各段 void refreshArrayFilters(); // 装置枚举异步加载后,重填各段装置筛选下拉 CategorySection* section(const std::string& id) const; // 按 CategorySpec.id 取段 + // ── 三维体生成后:自动勾选触发渲染 + 标题前等待动画(spinner)反馈(转发到含该 ds 的段)── + void setItemChecked(const QString& dsId, bool on); // 自动勾选(触发渲染) + void setItemBusy(const QString& dsId, bool busy); // 复选框↔等待 spinner 切换 + void clearAllBusy(); // 撤回所有 spinner(失败兜底) + void scrollItemToTop(const QString& dsId); // 把某行尽量滚到面板顶部(新建三维体后定位) signals: void checkedDatasetsChanged(const QStringList& dsIds); // 5 段勾选并集 @@ -47,12 +53,17 @@ signals: private: void recomputeCheckedUnion(); + // scrollItemToTop 的多拍重试实现:展开段/新增行后布局与滚动条范围需多次结算,单拍常滚不到位。 + // 每拍重算行位置并设滚动条,剩余拍数耗尽前持续校正 → 末拍几何稳定后行稳定到顶。 + void scrollItemToTopRetry(const QString& dsId, int attemptsLeft); // 据各段折叠态重排 stretch:折叠段=0(仅段头高)、展开段=1(吸收余量);全折叠时尾部弹簧吸收→段头顶到顶部。 // 仅在面板不产生滚动条(内容short于视口)时有可见效果——正是用户反馈的"折叠后停在原位中间"场景。 void relayoutSections(); std::map sections_; std::vector ordered_; // 按 categoryConfigs 顺序(relayout 遍历用) + QScrollArea* scroll_ = nullptr; // 外层滚动区(scrollItemToTop 定位用) + QWidget* content_ = nullptr; // 滚动内容容器(坐标映射用) QVBoxLayout* col_ = nullptr; // 段堆叠布局(末项=尾部弹簧) std::map checkedBySeg_; // 各段当前勾选(合并成并集) }; diff --git a/src/app/panels/columns/CategorySection.cpp b/src/app/panels/columns/CategorySection.cpp index a5f8ace..2d9544f 100644 --- a/src/app/panels/columns/CategorySection.cpp +++ b/src/app/panels/columns/CategorySection.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -151,6 +152,17 @@ CategorySection::CategorySection(const CategorySpec& spec, geopro::data::Dataset bool CategorySection::isExpanded() const { return header_ && header_->isChecked(); } +void CategorySection::ensureExpanded() { + if (header_ && !header_->isChecked()) header_->setChecked(true); // toggled→展开段体 +} + +QTreeWidgetItem* CategorySection::itemFor(const QString& dsId) const { + if (!list_ || dsId.isEmpty()) return nullptr; + for (QTreeWidgetItemIterator it(list_); *it; ++it) + if ((*it)->data(0, kDsIdRole).toString() == dsId) return *it; + return nullptr; +} + void CategorySection::setStructure(const std::vector& nodes) { structure_ = nodes; // 容器分层(项目根/GS/TM→ds)在 Task 12 接入真实结构后据此构建。 } @@ -180,6 +192,51 @@ void CategorySection::setChecked(const QString& dsId, bool on) { } } +void CategorySection::setBusy(const QString& dsId, bool on) { + QTreeWidgetItem* target = nullptr; + for (QTreeWidgetItemIterator it(list_); *it; ++it) + if ((*it)->data(0, kDsIdRole).toString() == dsId) { target = *it; break; } + if (!target) return; + { + // 改 busy/角度角色用 SignalBlocker:不触发 itemChanged→emitChecked→重渲染;viewport 仍重绘。 + const QSignalBlocker block(list_); + target->setData(0, kDsBusyRole, on); + if (on) target->setData(0, Qt::CheckStateRole, Qt::Checked); // 渲染中保持勾选(仍属渲染集) + } + bool anyBusy = false; + for (QTreeWidgetItemIterator it(list_); *it; ++it) + if ((*it)->data(0, kDsBusyRole).toBool()) { anyBusy = true; break; } + if (anyBusy) { + if (!spinTimer_) { + spinTimer_ = new QTimer(this); + spinTimer_->setInterval(80); + connect(spinTimer_, &QTimer::timeout, this, [this]() { + spinAngle_ = (spinAngle_ + 30) % 360; + const QSignalBlocker block(list_); + for (QTreeWidgetItemIterator it(list_); *it; ++it) + if ((*it)->data(0, kDsBusyRole).toBool()) + (*it)->setData(0, kDsSpinAngleRole, spinAngle_); + }); + } + if (!spinTimer_->isActive()) spinTimer_->start(); + } else if (spinTimer_) { + spinTimer_->stop(); + } + if (list_->viewport()) list_->viewport()->update(); +} + +void CategorySection::clearAllBusy() { + const QSignalBlocker block(list_); + bool any = false; + for (QTreeWidgetItemIterator it(list_); *it; ++it) + if ((*it)->data(0, kDsBusyRole).toBool()) { + (*it)->setData(0, kDsBusyRole, false); + any = true; + } + if (spinTimer_) spinTimer_->stop(); + if (any && list_->viewport()) list_->viewport()->update(); +} + void CategorySection::refreshArrayCombo() { if (!spec_.hasArrayTypeFilter || !arrayCombo_) return; const QString prev = arrayCombo_->currentData().toString(); diff --git a/src/app/panels/columns/CategorySection.hpp b/src/app/panels/columns/CategorySection.hpp index f03b4d0..4de7528 100644 --- a/src/app/panels/columns/CategorySection.hpp +++ b/src/app/panels/columns/CategorySection.hpp @@ -7,10 +7,12 @@ #include "repo/RepoTypes.hpp" class QTreeWidget; +class QTreeWidgetItem; class QComboBox; class QDateEdit; class QLabel; class QToolButton; +class QTimer; class QWidget; namespace geopro::data { @@ -33,11 +35,17 @@ public: void setStructure(const std::vector& nodes); void setDatasets(const std::vector& rows); void setChecked(const QString& dsId, bool on); // 按 dsId 勾选/取消(新建切片自动勾选等场景) + // 渲染中:该行复选框替换为等待 spinner(busy=true)/复原(false)。busy 期间保持勾选、动画由定时器驱动。 + void setBusy(const QString& dsId, bool busy); + void clearAllBusy(); // 撤回本段所有 spinner(失败兜底) void selectItem(const QString& dsId); // 程序化选中某行(VTK→list 反向联动);空/未找到=清选中 QStringList checkedIds() const { return checkedDsIds(); } // 当前勾选 ds(异常显隐同步用) void refreshArrayFilter() { refreshArrayCombo(); } // 装置枚举异步加载后重填下拉 const CategorySpec& spec() const { return spec_; } bool isExpanded() const; // 段头展开态(供外层按折叠状态重分配 stretch,实现"折叠向上收") + void ensureExpanded(); // 展开本段(折叠则展开):滚动定位前确保目标行可见 + QTreeWidget* listWidget() const { return list_; } // 段体树(外层滚动定位用) + QTreeWidgetItem* itemFor(const QString& dsId) const; // 按 dsId 取行(无则 nullptr) signals: void checkedDatasetsChanged(const QStringList& dsIds); // 数据行勾选=渲染 @@ -73,6 +81,8 @@ private: QComboBox* arrayCombo_ = nullptr; // 装置类型筛选(仅 hasArrayTypeFilter) DateRangeEdit* dateRange_ = nullptr; // 采集时间范围筛选(起止双日历,可清空) QTreeWidget* list_ = nullptr; + QTimer* spinTimer_ = nullptr; // 驱动 busy 行 spinner 旋转(有 busy 行时运行) + int spinAngle_ = 0; // 当前 spinner 角度(度) }; } // namespace geopro::app diff --git a/src/app/panels/columns/ColumnDrawer.cpp b/src/app/panels/columns/ColumnDrawer.cpp index e7fba88..7d74414 100644 --- a/src/app/panels/columns/ColumnDrawer.cpp +++ b/src/app/panels/columns/ColumnDrawer.cpp @@ -83,6 +83,7 @@ void ColumnDrawer::toggleCollapsed() // 折叠后只保留按钮宽度;展开恢复可调范围 setMinimumWidth(collapsed_ ? 0 : 180); setMaximumWidth(collapsed_ ? 18 : 560); + emit collapsedChanged(collapsed_); // 通知上层调 QSplitter 尺寸,回收/恢复栏宽(防残留空白) } void ColumnDrawer::expand() diff --git a/src/app/panels/columns/ColumnDrawer.hpp b/src/app/panels/columns/ColumnDrawer.hpp index 77b75e6..05175c1 100644 --- a/src/app/panels/columns/ColumnDrawer.hpp +++ b/src/app/panels/columns/ColumnDrawer.hpp @@ -26,6 +26,8 @@ public: signals: // 切换「三维分析 / 二维分析」tab:is2D=true 进入二维分析。上层据此切相机+翻维度可见标志。 void analysisModeChanged(bool is2D); + // 折叠/展开切换:上层据此调整 QSplitter 尺寸,回收/恢复抽屉栏宽(否则收起残留空白区)。 + void collapsedChanged(bool collapsed); public slots: void toggleCollapsed(); diff --git a/src/controller/I3dSceneView.hpp b/src/controller/I3dSceneView.hpp index 53175fb..d0cebde 100644 --- a/src/controller/I3dSceneView.hpp +++ b/src/controller/I3dSceneView.hpp @@ -31,6 +31,8 @@ public: virtual void clear() = 0; virtual void setVerticalExaggeration(double ve) = 0; + // 三维体体绘制最大不透明度(0~1):运行时调节已渲染体 + 后续新体(默认 0.30)。默认空实现,测试 mock 无需覆盖。 + virtual void setVolumeOpacity(double maxOpacity) { (void)maxOpacity; } // 地表高程基准(测线地表高程):2D 足迹「顶部/底部」摆放锚定真实地表。 virtual double zRefElev() const = 0; diff --git a/src/controller/VtkSceneController.cpp b/src/controller/VtkSceneController.cpp index 47c5ce6..16aefce 100644 --- a/src/controller/VtkSceneController.cpp +++ b/src/controller/VtkSceneController.cpp @@ -4,6 +4,7 @@ #include #include +#include #include #include "I3dSceneView.hpp" @@ -150,9 +151,12 @@ void VtkSceneController::addDatasetAsync(const std::string& dsId, unsigned long if (cachedGrid != volumeCache_.end() && cachedScale != volumeScaleCache_.end()) { view_.addVolume(dsId, cachedGrid->second, cachedScale->second); // 缓存命中(色阶随体缓存) onDatasetArrived(); + emit volumeRendered(QString::fromStdString(dsId)); // 缓存命中即时完成 → 撤 spinner + emit datasetRendered(QString::fromStdString(dsId)); return; } loadingDs_.insert(dsId); + emit datasetLoading(QString::fromStdString(dsId)); // 异步建体开始 → 列表项转 spinner sceneRepo_.loadVolume( dsId, [self, gen, dsId](data::VolumeGrid g, core::ColorScale cs) { @@ -161,8 +165,13 @@ void VtkSceneController::addDatasetAsync(const std::string& dsId, unsigned long if (gen != self->rebuildGeneration_ || !self->isChecked(dsId)) return; self->volumeScaleCache_[dsId] = cs; // 色阶随体一起缓存(mock 体在 dsRepo_ 无条目) auto it = self->volumeCache_.emplace(dsId, std::move(g)).first; + qInfo().noquote() << "[volrender] addVolume dsId=" << QString::fromStdString(dsId) + << "nx=" << it->second.vol.nx() << "ny=" << it->second.vol.ny() + << "nz=" << it->second.vol.nz(); self->view_.addVolume(dsId, it->second, self->volumeScaleCache_[dsId]); self->onDatasetArrived(); + emit self->volumeRendered(QString::fromStdString(dsId)); // 落地完成 → 撤 spinner + emit self->datasetRendered(QString::fromStdString(dsId)); }, [self, gen, dsId](const std::string& m) { if (!self) return; @@ -175,6 +184,7 @@ void VtkSceneController::addDatasetAsync(const std::string& dsId, unsigned long // 剖面 → 帘面(着色用 loadSection 返回的 s.scale,与体的源色阶同源)。 loadingDs_.insert(dsId); + emit datasetLoading(QString::fromStdString(dsId)); // 剖面首次加载较慢 → 列表项转 spinner sceneRepo_.loadSection( dsId, [self, gen, dsId](data::SectionData s) { @@ -183,6 +193,7 @@ void VtkSceneController::addDatasetAsync(const std::string& dsId, unsigned long if (gen != self->rebuildGeneration_ || !self->isChecked(dsId)) return; // 作废/已取消 self->view_.addCurtain(dsId, s.grid, s.scale); self->onDatasetArrived(); + emit self->datasetRendered(QString::fromStdString(dsId)); // 帘面落地 → 复原复选框 }, [self, gen, dsId](const std::string& m) { if (!self) return; @@ -240,6 +251,11 @@ void VtkSceneController::setVerticalExaggeration(double ve) { preserveCameraOnRebuild_ = false; } +void VtkSceneController::setVolumeOpacity(double maxOpacity) { + // 运行时更新已渲染体的不透明度传递函数(不重建体,实时跟手)+ 记为后续新体默认(见 VtkSceneView)。 + view_.setVolumeOpacity(maxOpacity); +} + void VtkSceneController::rebuild() { rebuildInternal(); } void VtkSceneController::setVolumeColorScale(const std::string& dsId, diff --git a/src/controller/VtkSceneController.hpp b/src/controller/VtkSceneController.hpp index 78a3568..88d85ce 100644 --- a/src/controller/VtkSceneController.hpp +++ b/src/controller/VtkSceneController.hpp @@ -47,6 +47,8 @@ public slots: void onAnalysisModeChanged(bool is2D); void setLayer(SceneLayer layer, bool on); void setVerticalExaggeration(double ve); + // 三维体透明度调节(工具条滑块):运行时更新已渲染体的不透明度,并作为后续新体默认(0~1)。 + void setVolumeOpacity(double maxOpacity); void rebuild(); // 主题切换等外部触发的重渲染 // 色阶编辑器「确定」:更新某三维体色阶并就地重渲染(体素 + 其切片随新色阶重建)。 @@ -66,6 +68,12 @@ public slots: signals: void loadFailed(const QString& message); + // 三维体异步建体+落地渲染完成(dsId)。供 UI 撤回该体列表项的等待 spinner、复原复选框。 + void volumeRendered(const QString& dsId); + // 任一数据集(剖面/体)异步加载开始 / 渲染完成:上层据此把该列表项复选框↔等待 spinner 切换。 + // 仅异步路径发(缓存命中即时完成只发 rendered);覆盖非三维体(剖面首次渲染也较慢,用户反馈)。 + void datasetLoading(const QString& dsId); + void datasetRendered(const QString& dsId); private: void rebuildInternal(); diff --git a/src/core/algo/VolumeBuilder.cpp b/src/core/algo/VolumeBuilder.cpp index e298d97..a75f4e8 100644 --- a/src/core/algo/VolumeBuilder.cpp +++ b/src/core/algo/VolumeBuilder.cpp @@ -3,10 +3,12 @@ #include #include #include +#include #include +#include #include - -#include "algo/IdwInterpolator.hpp" +#include +#include namespace geopro::core { @@ -24,10 +26,64 @@ void fitAxis(double ext, double cell, double& outCell, int& outN) { outN = kMaxVolumeDim; outCell = ext / static_cast(kMaxVolumeDim - 1); // maxDim 格覆盖全 ext } + +// 平面凸包(Andrew monotone chain,返回 CCW 顶点;末点=首点已去重)。点 <3 / 共线退化 → 空。 +struct Hull2D { std::vector x, y; }; + +// (A-O) × (B-O):>0 表示 B 在有向边 O→A 左侧。 +double cross2(double ox, double oy, double ax, double ay, double bx, double by) { + return (ax - ox) * (by - oy) - (ay - oy) * (bx - ox); +} + +Hull2D convexHull2D(const std::vector& xs, const std::vector& ys) { + Hull2D hull; + const std::size_t n = xs.size(); + if (n < 3) return hull; + std::vector idx(n); + for (std::size_t i = 0; i < n; ++i) idx[i] = i; + std::sort(idx.begin(), idx.end(), [&](std::size_t a, std::size_t b) { + return xs[a] < xs[b] || (xs[a] == xs[b] && ys[a] < ys[b]); + }); + std::vector h(2 * n); + int k = 0; + for (std::size_t ii = 0; ii < n; ++ii) { // 下凸包 + const std::size_t i = idx[ii]; + while (k >= 2 && + cross2(xs[h[k - 2]], ys[h[k - 2]], xs[h[k - 1]], ys[h[k - 1]], xs[i], ys[i]) <= 0) + --k; + h[k++] = i; + } + const int lower = k + 1; + for (std::size_t ii = n; ii-- > 0;) { // 上凸包 + const std::size_t i = idx[ii]; + while (k >= lower && + cross2(xs[h[k - 2]], ys[h[k - 2]], xs[h[k - 1]], ys[h[k - 1]], xs[i], ys[i]) <= 0) + --k; + h[k++] = i; + } + if (k - 1 < 3) return hull; // 去末点后仍 <3 → 退化 + hull.x.reserve(k - 1); hull.y.reserve(k - 1); + for (int t = 0; t < k - 1; ++t) { hull.x.push_back(xs[h[t]]); hull.y.push_back(ys[h[t]]); } + return hull; +} + +// 点是否在 CCW 凸多边形内(含边界),buf=向外缓冲(米,保边界整列不被误裁)。 +bool inHull(const Hull2D& hull, double px, double py, double buf) { + const std::size_t m = hull.x.size(); + for (std::size_t i = 0; i < m; ++i) { + const std::size_t j = (i + 1) % m; + const double ex = hull.x[j] - hull.x[i], ey = hull.y[j] - hull.y[i]; + const double len = std::sqrt(ex * ex + ey * ey); + const double c = cross2(hull.x[i], hull.y[i], hull.x[j], hull.y[j], px, py); + // c/len = 点到该边的有符号垂距(内侧为正);< -buf 即在多边形外超过缓冲 → 排除。 + if (len > 0.0 && c < -buf * len) return false; + } + return true; +} } // namespace BuiltVolume buildVolume(const PointSet& pts, double cellXY, double cellZ, - double power, double maxDist) { + double power, double maxDist, bool clipToFootprint) { if (pts.v.empty()) { throw std::invalid_argument("buildVolume: empty point set"); } @@ -50,11 +106,111 @@ BuiltVolume buildVolume(const PointSet& pts, double cellXY, double cellZ, fitAxis(maxy - miny, cellXY, spec.dy, spec.ny); fitAxis(maxz - minz, cellZ, spec.dz, spec.nz); spec.power = power; - spec.maxDist = maxDist; - // 3) IDW(maxDist 外 NaN 留空)。 - const IdwInterpolator idw; - ScalarVolume vol = idw.interpolate(pts, spec); + // 节点封顶:真实数据(大测区/小 cell)易逼近 kMaxVolumeDim³ → IDW 卡死。超 kMaxNodes 等比放大三轴 cell。 + { + constexpr long long kMaxNodes = 4'000'000; + const long long tot = 1LL * spec.nx * spec.ny * spec.nz; + if (tot > kMaxNodes) { + const double s = std::cbrt(static_cast(tot) / static_cast(kMaxNodes)); + spec.dx *= s; spec.dy *= s; spec.dz *= s; + auto rc = [](double ext, double cell) { int n = static_cast(ext / cell) + 1; return n < 1 ? 1 : n; }; + spec.nx = rc(maxx - minx, spec.dx); + spec.ny = rc(maxy - miny, spec.dy); + spec.nz = rc(maxz - minz, spec.dz); + } + } + + // 退化维补一层:若某横/竖向范围 < 一个 cell(如共面/共线剖面 → 该向仅 1 层),vtkGPUVolumeRayCastMapper + // 无法对「1 层厚的体」(本质 2D)体绘制 → 报 vtkExecutive 错误、什么都渲染不出来。补到 2 层 → 成薄板可渲。 + spec.nx = std::max(spec.nx, 2); + spec.ny = std::max(spec.ny, 2); + spec.nz = std::max(spec.nz, 2); + + // 各向异性搜索半径(实测:对角线全域 IDW 对真实井字数据=每节点求和全部点→卡死;井字线间最大空隙 + // 仅 ~20m,故水平半径 auto=0.2×XY 对角线[限 12~60m] 足以跨格填满而非全域;垂直限带→剖面深向密 + // 采、带内必有点,既不混深度又把候选点经「按 z 排序+二分定带」剪到深度邻域,避免卡死)。 + const double exX = maxx - minx, exY = maxy - miny; + const double xydiag = std::sqrt(exX * exX + exY * exY); + // 水平半径 auto = XY 对角线 → 填满整个凸包足迹(对齐 Surfer Blanking 后的实心体)。实测因抽稀 + // + z-带垂直剪枝,大半径与小半径耗时几乎一致(~2.8s/真实赣州 4 线),故取满填。 + const double maxDistH = (maxDist > 0.0) ? maxDist : (xydiag > 0.0 ? xydiag : 1.0); + const double maxDistV = std::max(6.0 * spec.dz, 2.0); + spec.maxDist = maxDistH; // 记录(属性页/诊断) + + // 点抽稀到网格分辨率:剖面 ~0.4m 采样远密于网格 → 按 (dx,dy,dz) 体素聚合(质心+均值), + // 大幅减候选点、不损可视化分辨率。 + struct ThinAcc { double sx = 0, sy = 0, sz = 0, sv = 0; int c = 0; }; + std::unordered_map tmap; + tmap.reserve(pts.v.size()); + auto keyOf = [&](double x, double y, double z) -> long long { + const long long ix = static_cast(std::floor((x - minx) / spec.dx)); + const long long iy = static_cast(std::floor((y - miny) / spec.dy)); + const long long iz = static_cast(std::floor((z - minz) / spec.dz)); + return (ix * 73856093LL) ^ (iy * 19349663LL) ^ (iz * 83492791LL); + }; + for (std::size_t i = 0; i < pts.v.size(); ++i) { + ThinAcc& a = tmap[keyOf(pts.x[i], pts.y[i], pts.z[i])]; + a.sx += pts.x[i]; a.sy += pts.y[i]; a.sz += pts.z[i]; a.sv += pts.v[i]; ++a.c; + } + std::vector tx, ty, tz, tv; + tx.reserve(tmap.size()); ty.reserve(tmap.size()); tz.reserve(tmap.size()); tv.reserve(tmap.size()); + for (const auto& kv : tmap) { + const ThinAcc& a = kv.second; + tx.push_back(a.sx / a.c); ty.push_back(a.sy / a.c); + tz.push_back(a.sz / a.c); tv.push_back(a.sv / a.c); + } + const std::size_t nt = tv.size(); + + // 抽稀点按 z 升序 → 每深度切片二分定 [gz-V, gz+V] 带,仅遍历带内点。 + std::vector order(nt); + std::iota(order.begin(), order.end(), std::size_t{0}); + std::sort(order.begin(), order.end(), [&](std::size_t a, std::size_t b) { return tz[a] < tz[b]; }); + std::vector zs(nt); + for (std::size_t t = 0; t < nt; ++t) zs[t] = tz[order[t]]; + + // 足迹凸包(用原始点;退化 <3/共线 → 空 → 跳过裁剪)。 + const Hull2D hull = clipToFootprint ? convexHull2D(pts.x, pts.y) : Hull2D{}; + const bool useClip = hull.x.size() >= 3; + const double buf = 0.5 * std::max(spec.dx, spec.dy); + + // 3) 各向异性 z-带 IDW(凸包外/带内无点 → NaN 留空)。 + ScalarVolume vol(spec.nx, spec.ny, spec.nz); + const double nan = std::numeric_limits::quiet_NaN(); + const double maxH2 = maxDistH * maxDistH; + const bool fastPow2 = (power == 2.0); + const double halfPow = power * 0.5; + for (int k = 0; k < spec.nz; ++k) { + const double gz = spec.oz + k * spec.dz; + const std::size_t lo = static_cast( + std::lower_bound(zs.begin(), zs.end(), gz - maxDistV) - zs.begin()); + const std::size_t hi = static_cast( + std::upper_bound(zs.begin(), zs.end(), gz + maxDistV) - zs.begin()); + for (int j = 0; j < spec.ny; ++j) { + const double gy = spec.oy + j * spec.dy; + for (int i = 0; i < spec.nx; ++i) { + const double gx = spec.ox + i * spec.dx; + if (useClip && !inHull(hull, gx, gy, buf)) { vol.at(i, j, k) = nan; continue; } + double wsum = 0.0, vsum = 0.0; + bool any = false, hit = false; double hitVal = 0.0; + for (std::size_t t = lo; t < hi; ++t) { + const std::size_t p = order[t]; + const double ddx = gx - tx[p], ddy = gy - ty[p]; + const double h2 = ddx * ddx + ddy * ddy; + if (h2 > maxH2) continue; // 超水平半径 + const double ddz = gz - tz[p]; + const double d2 = h2 + ddz * ddz; + any = true; + if (d2 < 1e-12) { hit = true; hitVal = tv[p]; break; } + const double w = fastPow2 ? (1.0 / d2) : std::pow(d2, -halfPow); + wsum += w; vsum += w * tv[p]; + } + if (hit) vol.at(i, j, k) = hitVal; + else if (!any || wsum == 0.0) vol.at(i, j, k) = nan; + else vol.at(i, j, k) = vsum / wsum; + } + } + } // 4) 数据实测值域(仅有限值)。无有限值 → 退化 {0,1}。 double vmin = std::numeric_limits::infinity(); diff --git a/src/core/algo/VolumeBuilder.hpp b/src/core/algo/VolumeBuilder.hpp index cee1c23..638e465 100644 --- a/src/core/algo/VolumeBuilder.hpp +++ b/src/core/algo/VolumeBuilder.hpp @@ -20,7 +20,15 @@ struct BuiltVolume { // 前置:pts 须含 ≥1 点(空集抛 std::invalid_argument)。 // 确定性:同点集同参数 → 同 GridSpec 同体(不冻结 spec,见计划 §1 决策)。 // 提取自 LocalSample3dRepository::loadVolume,供本地样本 / 真实 Api 共享,消除调参漂移。 +// +// maxDist 语义(对齐客户 Surfer:XYZC + IDW + 边界 Blanking,见 +// docs/superpowers/specs/2026-06-27-inversion-3d-volume-surfer-method-and-gaps.md): +// - maxDist > 0:局部 IDW 半径(超距 blank),偏快、剖面附近清晰,但跨大空隙可能填不满。 +// - maxDist <= 0:自动「覆盖测区」——半径取包络对角线,域内每点取到全部散点(≈Surfer 用全数据)。 +// clipToFootprint=true(默认):用散点平面**凸包**做足迹裁剪,凸包外网格列整列置空(≈Surfer 用 +// 边界多边形 Blanking)。避免单纯放大半径把体鼓满外接盒("变粗"的根因)。 +// 退化(散点 <3 / 平面近共线,如单条剖面)→ 自动跳过裁剪。 BuiltVolume buildVolume(const PointSet& pts, double cellXY, double cellZ, - double power, double maxDist); + double power, double maxDist, bool clipToFootprint = true); } // namespace geopro::core diff --git a/src/data/api/Api3dRepository.cpp b/src/data/api/Api3dRepository.cpp index 75def22..26a12f5 100644 --- a/src/data/api/Api3dRepository.cpp +++ b/src/data/api/Api3dRepository.cpp @@ -1,5 +1,6 @@ #include "api/Api3dRepository.hpp" +#include #include #include #include @@ -7,10 +8,14 @@ #include #include +#include +#include #include #include #include #include +#include +#include #include #include "algo/VolumeBuilder.hpp" // core::PointSet / BuiltVolume / buildVolume(含 Field.hpp) @@ -148,6 +153,13 @@ const VoxelGenerateRequest* Api3dRepository::lastVoxelRequest(const std::string& return (it != volumes_.end() && it->second.request) ? &*it->second.request : nullptr; } +void Api3dRepository::clearMockData() { + // 切换项目:清空内存态三维体/切片/异常,避免上个项目的产物残留进新项目列表。 + volumes_.clear(); + slices_.clear(); + anomalies_.clear(); +} + std::vector Api3dRepository::volumeRows() const { std::vector rows; rows.reserve(volumes_.size()); @@ -230,6 +242,19 @@ void Api3dRepository::appendGridPoints(const core::Grid& g, core::PointSet& pts) } } +void Api3dRepository::loadSectionWithRetry(const std::string& dsId, int attemptsLeft, + std::function onOk, OnError onErr) { + loadSection(dsId, onOk, [this, dsId, attemptsLeft, onOk, onErr](const std::string& m) { + if (attemptsLeft > 0) { // 瞬时失败(502 等)→ 重试,不立刻判整体失败 + qInfo().noquote() << "[volbuild] source" << QString::fromStdString(dsId) + << "加载失败,重试(剩" << attemptsLeft << "次):" << QString::fromStdString(m); + loadSectionWithRetry(dsId, attemptsLeft - 1, onOk, onErr); + return; + } + onErr(m); + }); +} + void Api3dRepository::finalizeVolume(const std::string& dsId, const core::PointSet& pts, const core::ColorScale& scale, const VolumeBuildParams& params, @@ -239,34 +264,75 @@ void Api3dRepository::finalizeVolume(const std::string& dsId, const core::PointS 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; + + // 重 IDW 建体放后台线程,避免阻塞 UI(用户要求:渲染必须异步)。纯计算(无 Qt/VTK),算完 + // 经事件循环回主线程做缓存 + 交付(缓存写 volumes_ / onOk 触碰 VTK 必须在主线程)。 + // 兜底:无 QCoreApplication(headless/单测)时退化为同步,保证可测/可离屏。 + auto deliver = [this, dsId, scale, onOk, onErr](std::shared_ptr bv, + std::string err, std::size_t nPts) { + if (!bv) { + onErr(std::string("Api3dRepository::loadVolume: ") + err); + return; + } + double vmin = bv->vmin, vmax = bv->vmax; const std::vector stops = scale.stopValues(); if (stops.size() >= 2) { vmin = stops.front(); vmax = stops.back(); } - qInfo().noquote() << "[volbuild] finalize pts=" << pts.v.size() << "grid" - << bv.spec.nx << "x" << bv.spec.ny << "x" << bv.spec.nz - << "origin" << bv.spec.ox << bv.spec.oy << bv.spec.oz << "spacing" - << bv.spec.dx << bv.spec.dy << bv.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}}, + qInfo().noquote() << "[volbuild] finalize pts=" << nPts << "grid" << bv->spec.nx << "x" + << bv->spec.ny << "x" << bv->spec.nz << "origin" << bv->spec.ox + << bv->spec.oy << bv->spec.oz << "spacing" << bv->spec.dx << bv->spec.dy + << bv->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}; auto it = volumes_.find(dsId); if (it != volumes_.end()) { // 缓存明细 + 色阶(下次命中即跳重算) it->second.cachedGrid = out; it->second.cachedScale = scale; - it->second.pointCount = pts.v.size(); // 持久化聚合散点数(详情统计用) + it->second.pointCount = nPts; // 持久化聚合散点数(详情统计用) } onOk(std::move(out), scale); - } catch (const std::exception& e) { - onErr(std::string("Api3dRepository::loadVolume: ") + e.what()); + }; + + // 纯计算闭包:返回 (built|nullptr, err, nPts)。 + auto compute = [pts, params]() { + std::shared_ptr bv; + std::string err; + try { + bv = std::make_shared(geopro::core::buildVolume( + pts, params.cellXY, params.cellZ, params.power, params.maxDist)); + } catch (const std::exception& e) { + err = e.what(); + } + return std::make_tuple(bv, err, pts.v.size()); + }; + + qInfo().noquote() << "[volbuild] start dsId=" << QString::fromStdString(dsId) + << "pts=" << pts.v.size() << "async=" << (QCoreApplication::instance() != nullptr); + if (!QCoreApplication::instance()) { // 无事件循环(headless/单测)→ 同步 + auto res = compute(); + deliver(std::get<0>(res), std::get<1>(res), std::get<2>(res)); + return; } + std::thread([compute, deliver, dsId]() mutable { + const auto t0 = std::chrono::steady_clock::now(); + auto res = compute(); + auto bv = std::get<0>(res); // 具名变量(非结构化绑定)→ C++17 可被 lambda 捕获 + auto err = std::get<1>(res); + auto nPts = std::get<2>(res); + const auto ms = std::chrono::duration_cast( + std::chrono::steady_clock::now() - t0).count(); + qInfo().noquote() << "[volbuild] computed dsId=" << QString::fromStdString(dsId) + << "ms=" << ms << "ok=" << (bv != nullptr); + // 回主线程交付(QueuedConnection;qApp 为主线程对象,存活于整个会话)。 + QMetaObject::invokeMethod( + qApp, + [deliver, bv, err, nPts]() mutable { deliver(std::move(bv), std::move(err), nPts); }, + Qt::QueuedConnection); + }).detach(); } void Api3dRepository::loadVolume(const std::string& dsId, @@ -294,15 +360,14 @@ void Api3dRepository::loadVolume(const std::string& dsId, int pending; bool failed = false; core::PointSet pts; - core::ColorScale scale; // 取首个到达源的色阶定值域 - bool haveScale = false; + std::vector scales; // 收集所有源色阶 → 取 vmax 中位者定值域(不依赖到达顺序) }; auto agg = std::make_shared(); agg->pending = static_cast(params.sourceDatasetIds.size()); for (const std::string& srcId : params.sourceDatasetIds) { - loadSection( - srcId, + loadSectionWithRetry( + srcId, /*attemptsLeft=*/2, [this, dsId, srcId, params, agg, onOk, onErr](SectionData s) { if (agg->failed) return; const std::size_t before = agg->pts.v.size(); @@ -311,12 +376,20 @@ void Api3dRepository::loadVolume(const std::string& dsId, << "grid" << s.grid.nx() << "x" << s.grid.ny() << "-> +" << (agg->pts.v.size() - before) << "pts (total" << agg->pts.v.size() << ")"; - if (!agg->haveScale) { - agg->scale = s.scale; - agg->haveScale = true; - } + agg->scales.push_back(s.scale); if (--agg->pending > 0) return; // 还有源未到齐 - finalizeVolume(dsId, agg->pts, agg->scale, params, onOk, onErr); + // 值域定法(修偶发"淡蓝/几乎不可见"根因):旧逻辑取「首个到达源」的色阶 → 多条线值域 + // 不一(如多条 2168、一条 24550)时随异步到达顺序抖动;取到大值域那条会把数据全压到 + // 色阶低端→全蓝近透明。改为取所有源色阶按 vmax 排序的中位者:确定性(去到达顺序依赖) + // + 抗单条线值域离群 → 多数线的正常值域稳定胜出。 + auto& ss = agg->scales; + std::sort(ss.begin(), ss.end(), + [](const core::ColorScale& a, const core::ColorScale& b) { + const auto av = a.stopValues(), bv = b.stopValues(); + return (av.empty() ? 0.0 : av.back()) < (bv.empty() ? 0.0 : bv.back()); + }); + const core::ColorScale chosen = ss.empty() ? core::ColorScale{} : ss[ss.size() / 2]; + finalizeVolume(dsId, agg->pts, chosen, params, onOk, onErr); }, [agg, onErr](const std::string& m) { if (agg->failed) return; diff --git a/src/data/api/Api3dRepository.hpp b/src/data/api/Api3dRepository.hpp index 98576c7..77e0ee5 100644 --- a/src/data/api/Api3dRepository.hpp +++ b/src/data/api/Api3dRepository.hpp @@ -50,6 +50,8 @@ public: const std::string& name, int coarse = 8); // 取回某三维体组装的请求体(测试/联调);非本 repo 创建或无 request 时返回 nullptr。 const VoxelGenerateRequest* lastVoxelRequest(const std::string& dsId) const; + // 清空内存态三维体/切片/异常(切换项目时调;否则上个项目的体/切片/异常残留在新项目列表)。 + void clearMockData(); // 已创建三维体的列表行(ddCode="dd_voxel"),供三维分析栏合并注入(每次 setDatasets 追加)。 std::vector volumeRows() const; @@ -119,6 +121,10 @@ private: // 把一条剖面 section grid 的有限值节点按 CurtainActor 同口径定位(lat/lon→frame.toLocal, // 世界 Z=grid.y 高程)追加为 IDW 散点 → 保证体与帘面同系对齐。 void appendGridPoints(const core::Grid& g, core::PointSet& pts) const; + // 源剖面带重试加载:瞬时失败(如后端 502 Bad Gateway)重试 attemptsLeft 次,避免一条源抖动 + // 就让整个三维体建不出来(表现为"连坐标轴都没有"的无声不渲染)。重试用尽才 onErr。 + void loadSectionWithRetry(const std::string& dsId, int attemptsLeft, + std::function onOk, OnError onErr); // 多源散点聚合完成 → buildVolume + 值域 + 缓存明细/色阶 → onOk(体, 色阶)。 void finalizeVolume(const std::string& dsId, const core::PointSet& pts, const core::ColorScale& scale, const VolumeBuildParams& params, diff --git a/src/render/actors/VoxelActor.cpp b/src/render/actors/VoxelActor.cpp index 08156c6..8509e56 100644 --- a/src/render/actors/VoxelActor.cpp +++ b/src/render/actors/VoxelActor.cpp @@ -3,9 +3,15 @@ #include #include +#include #include #include +#include +#include #include +#include +#include +#include #include #include #include @@ -18,8 +24,9 @@ namespace { // 颜色/不透明度传递函数采样级数。 constexpr int kTransferSamples = 64; -// 体绘制最大不透明度([vmin,vmax] 线性 0→kMaxOpacity)。 -constexpr double kMaxOpacity = 0.15; +// 体绘制最大不透明度([vmin,vmax] 线性 0→kMaxOpacity)。值越大体越实(越不透明); +// 0.15 偏淡 → 0.30 更实仍可看穿内部。再大(0.4~0.6)会更像实心块、遮挡内部结构。 +constexpr double kMaxOpacity = 0.30; // NaN/留空格的哨兵:落在 [vmin,vmax] 之外,传递函数把它映射为完全透明。 double sentinel(double vmin) { return vmin - 1.0; } @@ -100,7 +107,9 @@ vtkSmartPointer buildVoxel(const geopro::core::ScalarVolume& vol, img->SetOrigin(ox, oy, oz); img->SetSpacing(dx, dy, dz); - vtkNew sc; + // 标量用 float(非 double):OpenGL 无原生 double 体纹理,GPU 体绘制对 double 处理不稳/部分驱动间歇 + // 出空(偶发不渲染根因之一),且省一半显存。float 精度对可视化足够。 + vtkNew sc; sc->SetName("v"); sc->SetNumberOfTuples(static_cast(nx) * ny * nz); // 点序 i 最快、j 次之、k 最慢(匹配 vtkImageData 与 ScalarVolume::idx)。 @@ -109,7 +118,7 @@ vtkSmartPointer buildVoxel(const geopro::core::ScalarVolume& vol, for (int i = 0; i < nx; ++i) { const double v = vol.at(i, j, k); const vtkIdType id = (static_cast(k) * ny + j) * nx + i; - sc->SetValue(id, std::isnan(v) ? blank : v); // NaN → 哨兵 + sc->SetValue(id, static_cast(std::isnan(v) ? blank : v)); // NaN → 哨兵 } img->GetPointData()->SetScalars(sc); outImage = img; @@ -208,4 +217,34 @@ vtkSmartPointer buildVoxelI16FromImage(vtkImageData* shortImg, return assembleVolumeI16(shortImg, q, cs, vminPhys, vmaxPhys); } +vtkSmartPointer buildIsosurface(vtkImageData* img, const geopro::core::ColorScale& cs, + double vmin, double vmax, double isoValue) +{ + if (!img) return nullptr; + if (vmin >= vmax) vmax = vmin + 1.0; + // 阈值钳进 (vmin,vmax):=vmin 会沿留空哨兵边界成面、=vmax 抽不出。 + const double eps = 1e-6 * (vmax - vmin); + isoValue = std::max(vmin + eps, std::min(vmax - eps, isoValue)); + + vtkNew fe; + fe->SetInputData(img); + fe->SetValue(0, isoValue); + fe->ComputeNormalsOn(); + fe->ComputeGradientsOff(); + fe->ComputeScalarsOff(); + fe->Update(); + if (!fe->GetOutput() || fe->GetOutput()->GetNumberOfPoints() == 0) return nullptr; // 无超阈区 + + vtkNew mapper; + mapper->SetInputConnection(fe->GetOutputPort()); + mapper->ScalarVisibilityOff(); // 用 actor 实色,不按标量着色 + + auto actor = vtkSmartPointer::New(); + actor->SetMapper(mapper); + const auto c = cs.colorAt(isoValue); // 阈值处的色(高值多为暖红,复刻参考图红块) + actor->GetProperty()->SetColor(c.r / 255.0, c.g / 255.0, c.b / 255.0); + actor->GetProperty()->SetOpacity(1.0); // 不透明实心 + return actor; +} + } // namespace geopro::render diff --git a/src/render/actors/VoxelActor.hpp b/src/render/actors/VoxelActor.hpp index dbc873f..92b94bd 100644 --- a/src/render/actors/VoxelActor.hpp +++ b/src/render/actors/VoxelActor.hpp @@ -1,4 +1,5 @@ #pragma once +#include #include #include #include @@ -7,6 +8,12 @@ #include "model/ScalarVolumeI16.hpp" namespace geopro::render { +// 体上抽等值面(marching cubes/FlyingEdges)→ 不透明实心 actor,凸显超阈异常体(参考图红块)。 +// img 为 buildVoxel 暴露的 vtkImageData(标量=物理值,留空=哨兵 vmin-1,低于任意 isoValue 不成面)。 +// isoValue 在 [vmin,vmax] 内;颜色取 ColorScale 在 isoValue 处的实色、不透明。无超阈区 → 返回 nullptr。 +vtkSmartPointer buildIsosurface(vtkImageData* img, const geopro::core::ColorScale& cs, + double vmin, double vmax, double isoValue); + // 把 core 规则标量体(IDW 输出,含 NaN 留空)转 vtkImageData,再建 GPU 光线投射体绘制。 // 颜色按 ColorScale 在 [vmin,vmax] 采样;NaN/留空格 → 不透明度 0(透明)。 // 返回 vtkVolume(由调用方加入 renderer)。 diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index efe5575..c5f4da8 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -29,6 +29,8 @@ target_sources(geopro_tests PRIVATE core/test_local_frame.cpp) target_sources(geopro_tests PRIVATE core/test_model.cpp) target_sources(geopro_tests PRIVATE core/test_color_scale.cpp) target_sources(geopro_tests PRIVATE core/test_idw.cpp) +# buildVolume:凸包足迹裁剪 + maxDist=0 自动覆盖测区(对齐客户 Surfer Blanking)。 +target_sources(geopro_tests PRIVATE core/test_volume_builder.cpp) target_sources(geopro_tests PRIVATE core/test_crs_transform.cpp) target_sources(geopro_tests PRIVATE core/test_model_data.cpp) target_sources(geopro_tests PRIVATE core/test_geo_frame.cpp) diff --git a/tests/core/test_volume_builder.cpp b/tests/core/test_volume_builder.cpp new file mode 100644 index 0000000..f878ff7 --- /dev/null +++ b/tests/core/test_volume_builder.cpp @@ -0,0 +1,61 @@ +#include + +#include +#include + +#include "algo/VolumeBuilder.hpp" + +using namespace geopro::core; + +namespace { +// 构造一个直角三角形足迹的散点:顶点 (0,0)/(100,0)/(0,100),沿三条边各撒点(值=10)。 +// 凸包 = 该三角形;外接盒 = [0,100]×[0,100],右上角 (100,100) 落在凸包外。 +PointSet trianglePoints() { + PointSet pts; + auto add = [&](double x, double y) { + pts.x.push_back(x); pts.y.push_back(y); pts.z.push_back(0.0); pts.v.push_back(10.0); + }; + for (int t = 0; t <= 100; t += 10) { + add(static_cast(t), 0.0); // 底边 y=0 + add(0.0, static_cast(t)); // 左边 x=0 + add(static_cast(t), 100.0 - t); // 斜边 x+y=100 + } + return pts; +} +} // namespace + +// maxDist=0(自动有界半径)+ 默认凸包裁剪:凸包内填满;凸包外即便在半径内也被裁成 NaN。 +TEST(VolumeBuilder, FootprintClipBlanksOutsideHull) { + const PointSet pts = trianglePoints(); + const BuiltVolume bv = buildVolume(pts, /*cellXY*/10.0, /*cellZ*/10.0, /*power*/2.0, + /*maxDist*/0.0, /*clipToFootprint*/true); + // 网格:ox=oy=0, dx=dy=10, nx=ny=11;nz 退化(z 跨度 0)补到 2(防 GPU 体绘制拒绝 1 层厚体)。 + ASSERT_EQ(bv.spec.nx, 11); + ASSERT_EQ(bv.spec.ny, 11); + ASSERT_EQ(bv.spec.nz, 2); + + // (40,40) 在三角形内(40+40<100)→ 有限值。 + EXPECT_TRUE(std::isfinite(bv.vol.at(4, 4, 0))); + // (60,60) 在凸包外(60+60>100)但离斜边仅 ~14m(在自动半径 ~28m 内)→ 足迹裁剪置空 → NaN。 + EXPECT_TRUE(std::isnan(bv.vol.at(6, 6, 0))); +} + +// 关闭裁剪:凸包外但在半径内的 (60,60) 被 IDW 填满(证明上例的 NaN 确由足迹裁剪所致,而非取不到点)。 +TEST(VolumeBuilder, NoClipKeepsInRadiusOutsideHull) { + const PointSet pts = trianglePoints(); + const BuiltVolume bv = buildVolume(pts, 10.0, 10.0, 2.0, /*maxDist*/0.0, + /*clipToFootprint*/false); + EXPECT_TRUE(std::isfinite(bv.vol.at(6, 6, 0))); +} + +// 退化(单条近共线剖面:XY 全在一条线上)→ 凸包退化 → 跳过裁剪,不致全盘置空。 +TEST(VolumeBuilder, DegenerateCollinearSkipsClip) { + PointSet pts; + for (int t = 0; t <= 100; t += 10) { + pts.x.push_back(static_cast(t)); pts.y.push_back(0.0); + pts.z.push_back(0.0); pts.v.push_back(5.0); + } + const BuiltVolume bv = buildVolume(pts, 10.0, 10.0, 2.0, /*maxDist*/0.0, /*clipToFootprint*/true); + // y 跨度 0(退化维补到 ny=2);节点应被 IDW 正常填充(裁剪跳过,不应全 NaN)。 + EXPECT_TRUE(std::isfinite(bv.vol.at(5, 0, 0))); +}