feat(3d-view): 三维体渲染稳定性修复 + 透明度可调/交互优化

- 修偶发"不渲染/淡蓝/很实"根因:合并体值域取"首个到达源色阶"随网络到达
  顺序抖动→改取所有源色阶 vmax 中位者(确定性+抗单线离群)
- 体素标量 double→float:GPU 体绘制对 double 处理不稳/间歇出空,float 更稳且省显存
- 源剖面加载瞬时失败(如后端 502)自动重试,避免一条抖动致整体建不出;失败弹 toast 不再静默
- 退化薄体(共面剖面 ny/nz=1)网格每维补到≥2,避免 vtkGPUVolumeRayCastMapper 拒绝渲染
- 三维体透明度可调:工具条「透」按钮+弹出滑块(默认 0.30,实时改已渲染体)
- 工具条 z 序修复:引导层挂 vtkWidget 并 raise,工具条/提示再 raise 其上(缩小渲染区不再被挡)
- 收起左栏同步 QSplitter 尺寸,消除残留空白
- 切换项目清空三维体/切片/异常列表
- VTK 警告/错误转 Qt 日志,不再弹独立 vtkOutputWindow 窗口
- 勾选非三维体 ds 首次加载也显示等待动画(复选框↔spinner)
- 新建三维体后该行多拍重试滚动到分析栏顶部
This commit is contained in:
gaozheng 2026-06-27 18:32:07 +08:00
parent 9b4f172809
commit 4f6abf0c83
28 changed files with 784 additions and 62 deletions

View File

@ -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<VolumeSourceItem>& 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);

View File

@ -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));

View File

@ -15,9 +15,11 @@
#include <vtkCubeAxesActor.h>
#include <vtkNew.h>
#include <vtkProp.h>
#include <vtkPiecewiseFunction.h>
#include <vtkRenderWindow.h>
#include <vtkRenderer.h>
#include <vtkVolume.h>
#include <vtkVolumeProperty.h>
#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();
}
}

View File

@ -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<geopro::core::GeoLocalFrame> 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<vtkImageData> image;
geopro::core::ColorScale cs;
double vmin = 0.0, vmax = 0.0;
vtkSmartPointer<vtkVolume> volume; // 体 actor运行时调不透明度改其 property 的不透明度传递函数)
};
std::map<std::string, VolumeRec> volumes_;

View File

@ -1,7 +1,11 @@
#include "VtkViewToolbar.hpp"
#include <QFrame>
#include <QHBoxLayout>
#include <QLabel>
#include <QPoint>
#include <QSize>
#include <QSlider>
#include <QToolButton>
#include <QVBoxLayout>
@ -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) {

View File

@ -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<QToolButton*> viewDirButtons_; // 6 向快捷视图按钮:二维分析下禁用
QToolButton* opacityBtn_ = nullptr; // 「透」透明度按钮(缩放段顶部)
QWidget* opacityPopup_ = nullptr; // 弹出滑块面板Qt::Popup点外即关
QSlider* opacitySlider_ = nullptr; // 0~100% → 0~1
QLabel* opacityLabel_ = nullptr; // 「透明度 N%」读数
};
} // namespace geopro::app

View File

@ -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 <vtkCameraInterpolator.h>
#include <vtkGenericOpenGLRenderWindow.h>
#include <vtkLookupTable.h>
#include <vtkObjectFactory.h>
#include <vtkOutputWindow.h>
#include <vtkProperty.h>
#include <vtkImageData.h>
#include <vtkDataArray.h>
@ -199,6 +202,8 @@ public:
{
host_->installEventFilter(this);
}
// overlay 定位/置顶后,再把这些控件 raise 到 overlay 之上(如工具条/提示常驻最上层)。
void setRaiseAfter(std::vector<QWidget*> w) { raiseAfter_ = std::move(w); }
void reposition()
{
overlay_->adjustSize();
@ -211,9 +216,19 @@ 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);
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:
bool eventFilter(QObject* obj, QEvent* e) override
@ -226,6 +241,7 @@ protected:
private:
QWidget* overlay_;
QWidget* host_;
std::vector<QWidget*> 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<int>{18, 100000}
: QList<int>{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;
// 源 dsid,名称,结构归属):名称/structParentId 从最近拉取的行查(缺则用 id
QVector<geopro::app::VolumeSourceItem> 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<geopro::net::ApiResponse>();

View File

@ -90,6 +90,17 @@ 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);
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<Qt::CheckState>(idx.data(Qt::CheckStateRole).toInt());
QStyleOptionViewItem o(opt);
o.rect = checkRect;
@ -98,6 +109,7 @@ public:
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<QMouseEvent*>(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<QKeyEvent*>(ev);
if (ke->key() == Qt::Key_Space || ke->key() == Qt::Key_Select) {
if (busy) return true;
toggle();
return true;
}

View File

@ -23,6 +23,8 @@ constexpr int kDsNameRole = 0x0105; // Qt::UserRole + 5dsName详情页
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 + 9true=该行渲染中,复选框位置画等待 spinner
constexpr int kDsSpinAngleRole = 0x010A; // Qt::UserRole + 10spinner 角度定时器驱动delegate 据此旋转)
// 数据页签:树形(按 DsRow.parentId 嵌套,源数据为根、派生数据挂其下,对齐原版 el-table 树)。
// 每项列0文本 = dsName +「创建时间 · 类型名」data(0,角色) 存 dsId/ddCode/dsName。

View File

@ -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();

View File

@ -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:

View File

@ -1,6 +1,11 @@
#include "panels/columns/CategoryAnalysisTab.hpp"
#include <QAbstractItemView>
#include <QPointer>
#include <QScrollArea>
#include <QScrollBar>
#include <QTimer>
#include <QTreeWidget>
#include <QVBoxLayout>
#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<CategoryAnalysisTab> 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;

View File

@ -6,6 +6,7 @@
#include <vector>
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<geopro::data::StructNode>& 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<std::string, CategorySection*> sections_;
std::vector<CategorySection*> ordered_; // 按 categoryConfigs 顺序relayout 遍历用)
QScrollArea* scroll_ = nullptr; // 外层滚动区scrollItemToTop 定位用)
QWidget* content_ = nullptr; // 滚动内容容器(坐标映射用)
QVBoxLayout* col_ = nullptr; // 段堆叠布局(末项=尾部弹簧)
std::map<std::string, QStringList> checkedBySeg_; // 各段当前勾选(合并成并集)
};

View File

@ -12,6 +12,7 @@
#include <QPushButton>
#include <QSet>
#include <QSignalBlocker>
#include <QTimer>
#include <QToolButton>
#include <QTreeWidget>
#include <QTreeWidgetItemIterator>
@ -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<geopro::data::StructNode>& 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();

View File

@ -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<geopro::data::StructNode>& nodes);
void setDatasets(const std::vector<geopro::data::DsRow>& rows);
void setChecked(const QString& dsId, bool on); // 按 dsId 勾选/取消(新建切片自动勾选等场景)
// 渲染中:该行复选框替换为等待 spinnerbusy=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

View File

@ -83,6 +83,7 @@ void ColumnDrawer::toggleCollapsed()
// 折叠后只保留按钮宽度;展开恢复可调范围
setMinimumWidth(collapsed_ ? 0 : 180);
setMaximumWidth(collapsed_ ? 18 : 560);
emit collapsedChanged(collapsed_); // 通知上层调 QSplitter 尺寸,回收/恢复栏宽(防残留空白)
}
void ColumnDrawer::expand()

View File

@ -26,6 +26,8 @@ public:
signals:
// 切换「三维分析 / 二维分析」tabis2D=true 进入二维分析。上层据此切相机+翻维度可见标志。
void analysisModeChanged(bool is2D);
// 折叠/展开切换:上层据此调整 QSplitter 尺寸,回收/恢复抽屉栏宽(否则收起残留空白区)。
void collapsedChanged(bool collapsed);
public slots:
void toggleCollapsed();

View File

@ -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;

View File

@ -4,6 +4,7 @@
#include <set>
#include <utility>
#include <QDebug>
#include <QPointer>
#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,

View File

@ -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();

View File

@ -3,10 +3,12 @@
#include <algorithm>
#include <cmath>
#include <cstddef>
#include <cstdint>
#include <limits>
#include <numeric>
#include <stdexcept>
#include "algo/IdwInterpolator.hpp"
#include <unordered_map>
#include <vector>
namespace geopro::core {
@ -24,10 +26,64 @@ void fitAxis(double ext, double cell, double& outCell, int& outN) {
outN = kMaxVolumeDim;
outCell = ext / static_cast<double>(kMaxVolumeDim - 1); // maxDim 格覆盖全 ext
}
// 平面凸包Andrew monotone chain返回 CCW 顶点;末点=首点已去重)。点 <3 / 共线退化 → 空。
struct Hull2D { std::vector<double> 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<double>& xs, const std::vector<double>& ys) {
Hull2D hull;
const std::size_t n = xs.size();
if (n < 3) return hull;
std::vector<std::size_t> 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<std::size_t> 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) IDWmaxDist 外 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<double>(tot) / static_cast<double>(kMaxNodes));
spec.dx *= s; spec.dy *= s; spec.dz *= s;
auto rc = [](double ext, double cell) { int n = static_cast<int>(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<long long, ThinAcc> tmap;
tmap.reserve(pts.v.size());
auto keyOf = [&](double x, double y, double z) -> long long {
const long long ix = static_cast<long long>(std::floor((x - minx) / spec.dx));
const long long iy = static_cast<long long>(std::floor((y - miny) / spec.dy));
const long long iz = static_cast<long long>(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<double> 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<std::size_t> 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<double> 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<double>::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::size_t>(
std::lower_bound(zs.begin(), zs.end(), gz - maxDistV) - zs.begin());
const std::size_t hi = static_cast<std::size_t>(
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<double>::infinity();

View File

@ -20,7 +20,15 @@ struct BuiltVolume {
// 前置pts 须含 ≥1 点(空集抛 std::invalid_argument
// 确定性:同点集同参数 → 同 GridSpec 同体(不冻结 spec见计划 §1 决策)。
// 提取自 LocalSample3dRepository::loadVolume供本地样本 / 真实 Api 共享,消除调参漂移。
//
// maxDist 语义(对齐客户 SurferXYZC + 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

View File

@ -1,5 +1,6 @@
#include "api/Api3dRepository.hpp"
#include <QCoreApplication>
#include <QDateTime>
#include <QDebug>
#include <QJsonDocument>
@ -7,10 +8,14 @@
#include <QString>
#include <QVariant>
#include <algorithm>
#include <chrono>
#include <cmath>
#include <cstddef>
#include <exception>
#include <memory>
#include <thread>
#include <tuple>
#include <utility>
#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<DsRow> Api3dRepository::volumeRows() const {
std::vector<DsRow> 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<void(SectionData)> 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 必须在主线程)。
// 兜底:无 QCoreApplicationheadless/单测)时退化为同步,保证可测/可离屏。
auto deliver = [this, dsId, scale, onOk, onErr](std::shared_ptr<geopro::core::BuiltVolume> 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<double> 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);
};
// 纯计算闭包:返回 (built|nullptr, err, nPts)。
auto compute = [pts, params]() {
std::shared_ptr<geopro::core::BuiltVolume> bv;
std::string err;
try {
bv = std::make_shared<geopro::core::BuiltVolume>(geopro::core::buildVolume(
pts, params.cellXY, params.cellZ, params.power, params.maxDist));
} catch (const std::exception& e) {
onErr(std::string("Api3dRepository::loadVolume: ") + e.what());
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::milliseconds>(
std::chrono::steady_clock::now() - t0).count();
qInfo().noquote() << "[volbuild] computed dsId=" << QString::fromStdString(dsId)
<< "ms=" << ms << "ok=" << (bv != nullptr);
// 回主线程交付QueuedConnectionqApp 为主线程对象,存活于整个会话)。
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<core::ColorScale> scales; // 收集所有源色阶 → 取 vmax 中位者定值域(不依赖到达顺序)
};
auto agg = std::make_shared<Agg>();
agg->pending = static_cast<int>(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;

View File

@ -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<DsRow> 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<void(SectionData)> onOk, OnError onErr);
// 多源散点聚合完成 → buildVolume + 值域 + 缓存明细/色阶 → onOk(体, 色阶)。
void finalizeVolume(const std::string& dsId, const core::PointSet& pts,
const core::ColorScale& scale, const VolumeBuildParams& params,

View File

@ -3,9 +3,15 @@
#include <cmath>
#include <limits>
#include <vtkActor.h>
#include <vtkColorTransferFunction.h>
#include <vtkDoubleArray.h>
#include <vtkFloatArray.h>
#include <vtkFlyingEdges3D.h>
#include <vtkNew.h>
#include <vtkPolyData.h>
#include <vtkPolyDataMapper.h>
#include <vtkProperty.h>
#include <vtkShortArray.h>
#include <vtkSmartVolumeMapper.h>
#include <vtkPiecewiseFunction.h>
@ -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<vtkVolume> buildVoxel(const geopro::core::ScalarVolume& vol,
img->SetOrigin(ox, oy, oz);
img->SetSpacing(dx, dy, dz);
vtkNew<vtkDoubleArray> sc;
// 标量用 float非 doubleOpenGL 无原生 double 体纹理GPU 体绘制对 double 处理不稳/部分驱动间歇
// 出空偶发不渲染根因之一且省一半显存。float 精度对可视化足够。
vtkNew<vtkFloatArray> sc;
sc->SetName("v");
sc->SetNumberOfTuples(static_cast<vtkIdType>(nx) * ny * nz);
// 点序 i 最快、j 次之、k 最慢(匹配 vtkImageData 与 ScalarVolume::idx
@ -109,7 +118,7 @@ vtkSmartPointer<vtkVolume> 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<vtkIdType>(k) * ny + j) * nx + i;
sc->SetValue(id, std::isnan(v) ? blank : v); // NaN → 哨兵
sc->SetValue(id, static_cast<float>(std::isnan(v) ? blank : v)); // NaN → 哨兵
}
img->GetPointData()->SetScalars(sc);
outImage = img;
@ -208,4 +217,34 @@ vtkSmartPointer<vtkVolume> buildVoxelI16FromImage(vtkImageData* shortImg,
return assembleVolumeI16(shortImg, q, cs, vminPhys, vmaxPhys);
}
vtkSmartPointer<vtkActor> 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<vtkFlyingEdges3D> 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<vtkPolyDataMapper> mapper;
mapper->SetInputConnection(fe->GetOutputPort());
mapper->ScalarVisibilityOff(); // 用 actor 实色,不按标量着色
auto actor = vtkSmartPointer<vtkActor>::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

View File

@ -1,4 +1,5 @@
#pragma once
#include <vtkActor.h>
#include <vtkSmartPointer.h>
#include <vtkVolume.h>
#include <vtkImageData.h>
@ -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<vtkActor> 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

View File

@ -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)

View File

@ -0,0 +1,61 @@
#include <gtest/gtest.h>
#include <cmath>
#include <vector>
#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<double>(t), 0.0); // 底边 y=0
add(0.0, static_cast<double>(t)); // 左边 x=0
add(static_cast<double>(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=11nz 退化(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<double>(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)));
}