feat/vtk-3d-view #7
|
|
@ -31,7 +31,7 @@ namespace {
|
||||||
constexpr double kDefCellXY = 1.0;
|
constexpr double kDefCellXY = 1.0;
|
||||||
constexpr double kDefCellZ = 0.5;
|
constexpr double kDefCellZ = 0.5;
|
||||||
constexpr double kDefPower = 2.0;
|
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 kRoleDsId = Qt::UserRole + 1; // 源树项存 dsId
|
||||||
constexpr int kRoleMountId = Qt::UserRole + 1; // 生成位置树项存 id
|
constexpr int kRoleMountId = Qt::UserRole + 1; // 生成位置树项存 id
|
||||||
constexpr int kRoleMountConfType = Qt::UserRole + 2; // 生成位置树项存 confType
|
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);
|
cellXY_ = makeSpin(kDefCellXY, 0.01, 1000.0, 0.5, 2);
|
||||||
cellZ_ = makeSpin(kDefCellZ, 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);
|
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("水平间距 (米)")), cellXY_);
|
||||||
form2->addRow(formkit::editLabel(QStringLiteral("竖向间距 (米)")), cellZ_);
|
form2->addRow(formkit::editLabel(QStringLiteral("竖向间距 (米)")), cellZ_);
|
||||||
form2->addRow(formkit::editLabel(QStringLiteral("IDW 幂次")), power_);
|
form2->addRow(formkit::editLabel(QStringLiteral("IDW 幂次")), power_);
|
||||||
form2->addRow(formkit::editLabel(QStringLiteral("最大影响距离 (米)")), maxDist_);
|
form2->addRow(formkit::editLabel(QStringLiteral("最大影响距离 (米, 0=自动)")), maxDist_);
|
||||||
cardLay->addLayout(form2);
|
cardLay->addLayout(form2);
|
||||||
|
|
||||||
cols->addWidget(card, 1);
|
cols->addWidget(card, 1);
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,9 @@ VolumePropertiesDialog::VolumePropertiesDialog(const QString& name, const Volume
|
||||||
.row(QStringLiteral("网格间距"), QStringLiteral("XY=%1 m Z=%2 m")
|
.row(QStringLiteral("网格间距"), QStringLiteral("XY=%1 m Z=%2 m")
|
||||||
.arg(info.params.cellXY, 0, 'f', 2)
|
.arg(info.params.cellXY, 0, 'f', 2)
|
||||||
.arg(info.params.cellZ, 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("色阶来源"),
|
.row(QStringLiteral("色阶来源"),
|
||||||
info.params.colorScaleId.empty() ? QStringLiteral("首个源数据集")
|
info.params.colorScaleId.empty() ? QStringLiteral("首个源数据集")
|
||||||
: QString::fromStdString(info.params.colorScaleId));
|
: QString::fromStdString(info.params.colorScaleId));
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,11 @@
|
||||||
#include <vtkCubeAxesActor.h>
|
#include <vtkCubeAxesActor.h>
|
||||||
#include <vtkNew.h>
|
#include <vtkNew.h>
|
||||||
#include <vtkProp.h>
|
#include <vtkProp.h>
|
||||||
|
#include <vtkPiecewiseFunction.h>
|
||||||
#include <vtkRenderWindow.h>
|
#include <vtkRenderWindow.h>
|
||||||
#include <vtkRenderer.h>
|
#include <vtkRenderer.h>
|
||||||
#include <vtkVolume.h>
|
#include <vtkVolume.h>
|
||||||
|
#include <vtkVolumeProperty.h>
|
||||||
|
|
||||||
#include "CameraPreset.hpp"
|
#include "CameraPreset.hpp"
|
||||||
#include "Scene.hpp"
|
#include "Scene.hpp"
|
||||||
|
|
@ -33,6 +35,20 @@
|
||||||
namespace geopro::app {
|
namespace geopro::app {
|
||||||
|
|
||||||
namespace {
|
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)。
|
// 控制器层枚举 → render 层枚举(保持控制器不依赖 render)。
|
||||||
geopro::render::AxesMode toRenderMode(geopro::controller::AxesMode m) {
|
geopro::render::AxesMode toRenderMode(geopro::controller::AxesMode m) {
|
||||||
switch (m) {
|
switch (m) {
|
||||||
|
|
@ -124,6 +140,13 @@ void VtkSceneView::clear() {
|
||||||
|
|
||||||
void VtkSceneView::setVerticalExaggeration(double ve) { verticalExaggeration_ = ve; }
|
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) {
|
void VtkSceneView::addSurveyLine(const geopro::core::Grid& grid) {
|
||||||
auto line = geopro::render::buildSurveyLine(grid, *frame_);
|
auto line = geopro::render::buildSurveyLine(grid, *frame_);
|
||||||
if (line) {
|
if (line) {
|
||||||
|
|
@ -175,6 +198,7 @@ void VtkSceneView::addVolume(const std::string& dsId, const geopro::data::Volume
|
||||||
// picker 命中体、worldPoint 落体内 → nearestSlice 按平面距离选错切片(用户 ④ 串选)。
|
// picker 命中体、worldPoint 落体内 → nearestSlice 按平面距离选错切片(用户 ④ 串选)。
|
||||||
volume->PickableOff();
|
volume->PickableOff();
|
||||||
volume->SetVisibility(analysisMode2D_ ? 0 : 1); // 体=3D内容:二维分析下隐藏
|
volume->SetVisibility(analysisMode2D_ ? 0 : 1); // 体=3D内容:二维分析下隐藏
|
||||||
|
applyVolumeOpacity(volume, volumeOpacity_); // 套用当前透明度(工具条调过则新体跟随)
|
||||||
scene_.addViewProp(volume);
|
scene_.addViewProp(volume);
|
||||||
dsProps_[dsId].push_back(volume);
|
dsProps_[dsId].push_back(volume);
|
||||||
currentVolumeImage_ = image;
|
currentVolumeImage_ = image;
|
||||||
|
|
@ -182,7 +206,17 @@ void VtkSceneView::addVolume(const std::string& dsId, const geopro::data::Volume
|
||||||
currentVmin_ = vol.vmin;
|
currentVmin_ = vol.vmin;
|
||||||
currentVmax_ = vol.vmax;
|
currentVmax_ = vol.vmax;
|
||||||
volumeOwnerDs_ = dsId;
|
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();
|
if (onVolumeChanged) onVolumeChanged();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ class vtkRenderer;
|
||||||
class vtkRenderWindow;
|
class vtkRenderWindow;
|
||||||
class vtkProp;
|
class vtkProp;
|
||||||
class vtkActor;
|
class vtkActor;
|
||||||
|
class vtkVolume;
|
||||||
|
|
||||||
namespace geopro::app {
|
namespace geopro::app {
|
||||||
|
|
||||||
|
|
@ -34,6 +35,7 @@ public:
|
||||||
|
|
||||||
void clear() override;
|
void clear() override;
|
||||||
void setVerticalExaggeration(double ve) override;
|
void setVerticalExaggeration(double ve) override;
|
||||||
|
void setVolumeOpacity(double maxOpacity) override; // 运行时调已渲染体 + 后续新体的最大不透明度
|
||||||
double zRefElev() const override { return zRefElev_; }
|
double zRefElev() const override { return zRefElev_; }
|
||||||
void addSurveyLine(const geopro::core::Grid& grid) override;
|
void addSurveyLine(const geopro::core::Grid& grid) override;
|
||||||
void addCurtain(const std::string& dsId, const geopro::core::Grid& grid,
|
void addCurtain(const std::string& dsId, const geopro::core::Grid& grid,
|
||||||
|
|
@ -127,6 +129,7 @@ private:
|
||||||
std::shared_ptr<geopro::core::GeoLocalFrame> frame_;
|
std::shared_ptr<geopro::core::GeoLocalFrame> frame_;
|
||||||
double zRefElev_;
|
double zRefElev_;
|
||||||
double verticalExaggeration_ = 1.0;
|
double verticalExaggeration_ = 1.0;
|
||||||
|
double volumeOpacity_ = 0.30; // 三维体体绘制最大不透明度(默认 0.30,工具条可调);新体建好即套用
|
||||||
// 是否已按真实剖面 lat/lon 重锚 frame 原点(每次 clear 重置):默认原点取自样本、可能离真实数据
|
// 是否已按真实剖面 lat/lon 重锚 frame 原点(每次 clear 重置):默认原点取自样本、可能离真实数据
|
||||||
// 很远→局部坐标巨大、轴刻度无意义;首个带经纬剖面到达时重锚到其中心,同选择内多剖面共用配准。
|
// 很远→局部坐标巨大、轴刻度无意义;首个带经纬剖面到达时重锚到其中心,同选择内多剖面共用配准。
|
||||||
bool frameAnchoredToData_ = false;
|
bool frameAnchoredToData_ = false;
|
||||||
|
|
@ -151,6 +154,7 @@ private:
|
||||||
vtkSmartPointer<vtkImageData> image;
|
vtkSmartPointer<vtkImageData> image;
|
||||||
geopro::core::ColorScale cs;
|
geopro::core::ColorScale cs;
|
||||||
double vmin = 0.0, vmax = 0.0;
|
double vmin = 0.0, vmax = 0.0;
|
||||||
|
vtkSmartPointer<vtkVolume> volume; // 体 actor(运行时调不透明度:改其 property 的不透明度传递函数)
|
||||||
};
|
};
|
||||||
std::map<std::string, VolumeRec> volumes_;
|
std::map<std::string, VolumeRec> volumes_;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
#include "VtkViewToolbar.hpp"
|
#include "VtkViewToolbar.hpp"
|
||||||
|
|
||||||
#include <QFrame>
|
#include <QFrame>
|
||||||
|
#include <QHBoxLayout>
|
||||||
|
#include <QLabel>
|
||||||
|
#include <QPoint>
|
||||||
#include <QSize>
|
#include <QSize>
|
||||||
|
#include <QSlider>
|
||||||
#include <QToolButton>
|
#include <QToolButton>
|
||||||
#include <QVBoxLayout>
|
#include <QVBoxLayout>
|
||||||
|
|
||||||
|
|
@ -66,7 +70,10 @@ VtkViewToolbar::VtkViewToolbar(QWidget* parent) : QWidget(parent) {
|
||||||
viewDirButtons_.push_back(b); // 二维分析下禁用(会改朝向、破坏近俯视锁定)
|
viewDirButtons_.push_back(b); // 二维分析下禁用(会改朝向、破坏近俯视锁定)
|
||||||
}
|
}
|
||||||
sep();
|
sep();
|
||||||
// ── 段3:缩放 / 复位 ──
|
// ── 段3:透明度(缩放段顶部,放大上面)+ 缩放 / 复位 ──
|
||||||
|
opacityBtn_ = textBtn(QStringLiteral("透"));
|
||||||
|
opacityBtn_->setToolTip(QStringLiteral("三维体透明度"));
|
||||||
|
connect(opacityBtn_, &QToolButton::clicked, this, &VtkViewToolbar::showOpacityPopup);
|
||||||
connect(iconBtn(Glyph::Plus, QStringLiteral("放大")), &QToolButton::clicked, this,
|
connect(iconBtn(Glyph::Plus, QStringLiteral("放大")), &QToolButton::clicked, this,
|
||||||
&VtkViewToolbar::zoomInRequested);
|
&VtkViewToolbar::zoomInRequested);
|
||||||
connect(iconBtn(Glyph::Minus, QStringLiteral("缩小")), &QToolButton::clicked, this,
|
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}};}"));
|
"QToolButton:hover{background:{{bg/hover}};color:{{accent/primary}};}"));
|
||||||
setFixedWidth(44);
|
setFixedWidth(44);
|
||||||
adjustSize();
|
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) {
|
void VtkViewToolbar::setAnalysisMode2D(bool is2D) {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@
|
||||||
#include "I3dSceneView.hpp" // geopro::controller::ViewDir
|
#include "I3dSceneView.hpp" // geopro::controller::ViewDir
|
||||||
|
|
||||||
class QToolButton;
|
class QToolButton;
|
||||||
|
class QSlider;
|
||||||
|
class QLabel;
|
||||||
|
|
||||||
namespace geopro::app {
|
namespace geopro::app {
|
||||||
|
|
||||||
|
|
@ -25,9 +27,16 @@ signals:
|
||||||
void zoomInRequested();
|
void zoomInRequested();
|
||||||
void zoomOutRequested();
|
void zoomOutRequested();
|
||||||
void fitRequested(); // 复位=适配
|
void fitRequested(); // 复位=适配
|
||||||
|
void opacityChanged(double maxOpacity); // 三维体透明度滑块(0~1,实时)
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
void showOpacityPopup(); // 在透明度按钮旁弹出滑块面板
|
||||||
|
|
||||||
std::vector<QToolButton*> viewDirButtons_; // 6 向快捷视图按钮:二维分析下禁用
|
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
|
} // namespace geopro::app
|
||||||
|
|
|
||||||
118
src/app/main.cpp
118
src/app/main.cpp
|
|
@ -107,6 +107,7 @@
|
||||||
#include "SlicePropertiesDialog.hpp"
|
#include "SlicePropertiesDialog.hpp"
|
||||||
#include "SliceExport.hpp"
|
#include "SliceExport.hpp"
|
||||||
#include "ToastOverlay.hpp"
|
#include "ToastOverlay.hpp"
|
||||||
|
#include "panels/LoadingOverlay.hpp"
|
||||||
#include "TopBar.hpp"
|
#include "TopBar.hpp"
|
||||||
#include "VolumeParamsDialog.hpp"
|
#include "VolumeParamsDialog.hpp"
|
||||||
#include "VolumePropertiesDialog.hpp"
|
#include "VolumePropertiesDialog.hpp"
|
||||||
|
|
@ -179,6 +180,8 @@
|
||||||
#include <vtkCameraInterpolator.h>
|
#include <vtkCameraInterpolator.h>
|
||||||
#include <vtkGenericOpenGLRenderWindow.h>
|
#include <vtkGenericOpenGLRenderWindow.h>
|
||||||
#include <vtkLookupTable.h>
|
#include <vtkLookupTable.h>
|
||||||
|
#include <vtkObjectFactory.h>
|
||||||
|
#include <vtkOutputWindow.h>
|
||||||
#include <vtkProperty.h>
|
#include <vtkProperty.h>
|
||||||
#include <vtkImageData.h>
|
#include <vtkImageData.h>
|
||||||
#include <vtkDataArray.h>
|
#include <vtkDataArray.h>
|
||||||
|
|
@ -199,6 +202,8 @@ public:
|
||||||
{
|
{
|
||||||
host_->installEventFilter(this);
|
host_->installEventFilter(this);
|
||||||
}
|
}
|
||||||
|
// overlay 定位/置顶后,再把这些控件 raise 到 overlay 之上(如工具条/提示常驻最上层)。
|
||||||
|
void setRaiseAfter(std::vector<QWidget*> w) { raiseAfter_ = std::move(w); }
|
||||||
void reposition()
|
void reposition()
|
||||||
{
|
{
|
||||||
overlay_->adjustSize();
|
overlay_->adjustSize();
|
||||||
|
|
@ -211,8 +216,18 @@ public:
|
||||||
// 偏移取非负:保证浮层矩形始终 ⊆ host 矩形(不跑到 host 外)。
|
// 偏移取非负:保证浮层矩形始终 ⊆ host 矩形(不跑到 host 外)。
|
||||||
const int dx = std::max(0, (h.width() - o.width()) / 2);
|
const int dx = std::max(0, (h.width() - o.width()) / 2);
|
||||||
const int dy = std::max(0, (h.height() - o.height()) / 2);
|
const int dy = std::max(0, (h.height() - o.height()) / 2);
|
||||||
overlay_->move(host_->x() + dx, host_->y() + dy);
|
if (overlay_->parentWidget() == host_) {
|
||||||
overlay_->raise();
|
// 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:
|
protected:
|
||||||
|
|
@ -226,6 +241,7 @@ protected:
|
||||||
private:
|
private:
|
||||||
QWidget* overlay_;
|
QWidget* overlay_;
|
||||||
QWidget* host_;
|
QWidget* host_;
|
||||||
|
std::vector<QWidget*> raiseAfter_; // 定位后再 raise 到 overlay 之上的常驻控件(工具条/提示)
|
||||||
};
|
};
|
||||||
|
|
||||||
// 取 vector 中位数(用于由测线 lat/lon 推世界系原点)。空则返回 0。
|
// 取 vector 中位数(用于由测线 lat/lon 推世界系原点)。空则返回 0。
|
||||||
|
|
@ -399,6 +415,13 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
|
||||||
split->addWidget(vtkWidget);
|
split->addWidget(vtkWidget);
|
||||||
split->setStretchFactor(0, 0);
|
split->setStretchFactor(0, 0);
|
||||||
split->setStretchFactor(1, 1);
|
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(viewHeader);
|
||||||
centerLayout->addWidget(split, 1);
|
centerLayout->addWidget(split, 1);
|
||||||
// 工具条悬浮于画布左上角(overlay;左上固定,画布 resize 无需重定位)。
|
// 工具条悬浮于画布左上角(overlay;左上固定,画布 resize 无需重定位)。
|
||||||
|
|
@ -418,6 +441,10 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
|
||||||
"border:1px solid {{accent/primary}};padding:8px 12px;}"));
|
"border:1px solid {{accent/primary}};padding:8px 12px;}"));
|
||||||
anomalyHint->hide();
|
anomalyHint->hide();
|
||||||
|
|
||||||
|
// 保存三维体等待蒙版(公共组件 LoadingOverlay):挂 centerWidget → showOver 铺满整个「VTK视图」
|
||||||
|
// 子视图(表头+左树+画布)并 raise 到顶层;保存阶段显示,完成即撤。复用于其他需要整窗等待的场景。
|
||||||
|
auto* vtkLoading = new geopro::app::LoadingOverlay(centerWidget);
|
||||||
|
|
||||||
// ── 二维分析 B 期:高程 Z 拖动读数浮层(顶部居中)+ 足迹拾取/拖动回调注入交互样式 ──────────
|
// ── 二维分析 B 期:高程 Z 拖动读数浮层(顶部居中)+ 足迹拾取/拖动回调注入交互样式 ──────────
|
||||||
// 拖动选中足迹时显示其当前世界 Z,松开隐藏;不挡画布鼠标。深底方角(同异常提示坑规避)。
|
// 拖动选中足迹时显示其当前世界 Z,松开隐藏;不挡画布鼠标。深底方角(同异常提示坑规避)。
|
||||||
auto* elevHint = new QLabel(vtkWidget);
|
auto* elevHint = new QLabel(vtkWidget);
|
||||||
|
|
@ -811,6 +838,13 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
|
||||||
&geopro::controller::VtkSceneController::zoomOut);
|
&geopro::controller::VtkSceneController::zoomOut);
|
||||||
QObject::connect(viewToolbar, &geopro::app::VtkViewToolbar::fitRequested, sceneCtrl,
|
QObject::connect(viewToolbar, &geopro::app::VtkViewToolbar::fitRequested, sceneCtrl,
|
||||||
&geopro::controller::VtkSceneController::fit);
|
&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 抽屉面板(非模态弹窗)。
|
// 设置(⚙)→ 工具条右侧 toggle 抽屉面板(非模态弹窗)。
|
||||||
QObject::connect(viewToolbar, &geopro::app::VtkViewToolbar::axesSettingsRequested, &window,
|
QObject::connect(viewToolbar, &geopro::app::VtkViewToolbar::axesSettingsRequested, &window,
|
||||||
[axesPanel, viewToolbar]() {
|
[axesPanel, viewToolbar]() {
|
||||||
|
|
@ -852,8 +886,8 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
|
||||||
});
|
});
|
||||||
// 段头「+新增三维体」:弹参数对话框 → 组装真实 VoxelGenerateRequest → createVolume(mock) → 刷新。
|
// 段头「+新增三维体」:弹参数对话框 → 组装真实 VoxelGenerateRequest → createVolume(mock) → 刷新。
|
||||||
QObject::connect(analysisTab, &geopro::app::CategoryAnalysisTab::generateVolumeRequested, &window,
|
QObject::connect(analysisTab, &geopro::app::CategoryAnalysisTab::generateVolumeRequested, &window,
|
||||||
[&window, &nav, scene3dRepo, refreshAnalysis, lastSourceRows,
|
[&window, &nav, scene3dRepo, refreshAnalysis, lastSourceRows, lastStructNodes,
|
||||||
lastStructNodes](const QString& /*dsTypeCode*/, const QStringList& sourceIds) {
|
analysisTab, vtkLoading](const QString& /*dsTypeCode*/, const QStringList& sourceIds) {
|
||||||
if (sourceIds.isEmpty()) return;
|
if (sourceIds.isEmpty()) return;
|
||||||
// 源 ds(id,名称,结构归属):名称/structParentId 从最近拉取的行查(缺则用 id)。
|
// 源 ds(id,名称,结构归属):名称/structParentId 从最近拉取的行查(缺则用 id)。
|
||||||
QVector<geopro::app::VolumeSourceItem> sources;
|
QVector<geopro::app::VolumeSourceItem> sources;
|
||||||
|
|
@ -889,8 +923,47 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
|
||||||
req.power = p.power;
|
req.power = p.power;
|
||||||
req.maxDist = p.maxDist;
|
req.maxDist = p.maxDist;
|
||||||
req.colorScaleId = p.colorScaleId;
|
req.colorScaleId = p.colorScaleId;
|
||||||
scene3dRepo->createVolume(req);
|
// 保存(目前 mock,瞬时同步):直接建体+入树,不弹等待蒙版——mock 没有耗时,蒙版
|
||||||
refreshAnalysis();
|
// 会 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 详情口径)。
|
// 双击数据详情:dd_slice→切片属性;dd_voxel→三维体属性(同 colAnalysis 详情口径)。
|
||||||
QObject::connect(analysisTab, &geopro::app::CategoryAnalysisTab::detailRequested, &window,
|
QObject::connect(analysisTab, &geopro::app::CategoryAnalysisTab::detailRequested, &window,
|
||||||
|
|
@ -1197,7 +1270,10 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
|
||||||
// ── 中央“空状态”引导浮层:未接入真实 sections 时,引导首次使用者从左侧入手。──
|
// ── 中央“空状态”引导浮层:未接入真实 sections 时,引导首次使用者从左侧入手。──
|
||||||
// 透明背景 + 鼠标穿透(不挡 QVTK 交互);CenterOverlay 随视口尺寸保持居中;
|
// 透明背景 + 鼠标穿透(不挡 QVTK 交互);CenterOverlay 随视口尺寸保持居中;
|
||||||
// 接入真实中央数据后改成依 sections 是否为空调 setVisible 即可。
|
// 接入真实中央数据后改成依 sections 是否为空调 setVisible 即可。
|
||||||
auto* emptyState = new QFrame(centerWidget);
|
// 挂在 vtkWidget 下(而非 centerWidget):使其与工具条/提示同属 vtkWidget 子级,CenterOverlay 会把它
|
||||||
|
// 压到子级最底(在 GL 之上、工具条/提示之下)→ 工具条永远在最上层、引导层在最下层(修视图缩小时
|
||||||
|
// 引导层挡住工具条)。
|
||||||
|
auto* emptyState = new QFrame(vtkWidget);
|
||||||
emptyState->setObjectName(QStringLiteral("centralEmpty"));
|
emptyState->setObjectName(QStringLiteral("centralEmpty"));
|
||||||
emptyState->setAttribute(Qt::WA_TransparentForMouseEvents);
|
emptyState->setAttribute(Qt::WA_TransparentForMouseEvents);
|
||||||
// 背景取 canvas/bg(#0B1320)——与画布同色:在原生 GL 上覆盖透明无法生效(会回退成不透明浅底),
|
// 背景取 canvas/bg(#0B1320)——与画布同色:在原生 GL 上覆盖透明无法生效(会回退成不透明浅底),
|
||||||
|
|
@ -1240,6 +1316,8 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
|
||||||
esLay->addWidget(esHint);
|
esLay->addWidget(esHint);
|
||||||
|
|
||||||
auto* emptyCentering = new CenterOverlay(emptyState, vtkWidget);
|
auto* emptyCentering = new CenterOverlay(emptyState, vtkWidget);
|
||||||
|
// 引导层定位后,把工具条与提示浮层 raise 到其上 → 工具条永在最上层(修:缩小渲染区时引导层挡工具条)。
|
||||||
|
emptyCentering->setRaiseAfter({viewToolbar, anomalyHint, elevHint});
|
||||||
emptyCentering->reposition();
|
emptyCentering->reposition();
|
||||||
|
|
||||||
auto* vtkDock = new ads::CDockWidget(QStringLiteral("VTK视图"));
|
auto* vtkDock = new ads::CDockWidget(QStringLiteral("VTK视图"));
|
||||||
|
|
@ -1522,8 +1600,10 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
|
||||||
// 仅真正换项目用(delete-refresh 等 switchProject(currentProjectId) 不走此处,避免误清)。
|
// 仅真正换项目用(delete-refresh 等 switchProject(currentProjectId) 不走此处,避免误清)。
|
||||||
auto clearCentral = [sceneCtrl, emptyState, checkedProfiles, checkedAnalysis,
|
auto clearCentral = [sceneCtrl, emptyState, checkedProfiles, checkedAnalysis,
|
||||||
pushChecked, lastSourceRows, refreshAnalysis, checkedSliceIds,
|
pushChecked, lastSourceRows, refreshAnalysis, checkedSliceIds,
|
||||||
syncSlices, basemap, sceneView]() {
|
syncSlices, basemap, sceneView, scene3dRepo]() {
|
||||||
// 数据源清空 → 5 段 + col2D 清空(refreshAnalysis 内 setBuckets/dim2D;客户端三维体仍驻留)。
|
// 切项目:先清内存态三维体/切片/异常(否则上个项目的产物残留进新项目列表),再 refreshAnalysis。
|
||||||
|
scene3dRepo->clearMockData();
|
||||||
|
// 数据源清空 → 5 段 + col2D 清空(refreshAnalysis 内 setBuckets/dim2D)。
|
||||||
*lastSourceRows = {};
|
*lastSourceRows = {};
|
||||||
refreshAnalysis();
|
refreshAnalysis();
|
||||||
// 勾选集清空并下发空到 VTK(帘面/体素/切片/2D 足迹全部撤场)。
|
// 勾选集清空并下发空到 VTK(帘面/体素/切片/2D 足迹全部撤场)。
|
||||||
|
|
@ -2095,6 +2175,17 @@ public:
|
||||||
return false;
|
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
|
} // namespace
|
||||||
|
|
||||||
int main(int argc, char* argv[])
|
int main(int argc, char* argv[])
|
||||||
|
|
@ -2114,6 +2205,15 @@ int main(int argc, char* argv[])
|
||||||
QSurfaceFormat::setDefaultFormat(QVTKOpenGLStereoWidget::defaultFormat());
|
QSurfaceFormat::setDefaultFormat(QVTKOpenGLStereoWidget::defaultFormat());
|
||||||
GuardedApplication app(argc, argv); // 顶层异常护栏:slot/事件里的异常不致客户端崩溃
|
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 传递
|
// 异步 ApiCall::finished 等信号携带 ApiResponse;注册元类型以支持跨 QueuedConnection 传递
|
||||||
// (当前详情链路为同线程 DirectConnection,非严格必需,但作防御性注册,见 spec §5.1)。
|
// (当前详情链路为同线程 DirectConnection,非严格必需,但作防御性注册,见 spec §5.1)。
|
||||||
qRegisterMetaType<geopro::net::ApiResponse>();
|
qRegisterMetaType<geopro::net::ApiResponse>();
|
||||||
|
|
|
||||||
|
|
@ -90,14 +90,26 @@ public:
|
||||||
const bool isContainer = idx.data(kDsDdCodeRole).toString() == QStringLiteral("container");
|
const bool isContainer = idx.data(kDsDdCodeRole).toString() == QStringLiteral("container");
|
||||||
if (checkable) {
|
if (checkable) {
|
||||||
QRect checkRect(r.left() + 12, r.top() + (r.height() - box) / 2, box, box);
|
QRect checkRect(r.left() + 12, r.top() + (r.height() - box) / 2, box, box);
|
||||||
const auto cs = static_cast<Qt::CheckState>(idx.data(Qt::CheckStateRole).toInt());
|
if (idx.data(kDsBusyRole).toBool()) {
|
||||||
QStyleOptionViewItem o(opt);
|
// 渲染中:复选框位置画旋转 spinner(等待动画,角度由 kDsSpinAngleRole 驱动)。
|
||||||
o.rect = checkRect;
|
const int angle = idx.data(kDsSpinAngleRole).toInt();
|
||||||
o.state &= ~QStyle::State_HasFocus;
|
p->save();
|
||||||
o.state |= (cs == Qt::Checked ? QStyle::State_On : QStyle::State_Off);
|
p->setRenderHint(QPainter::Antialiasing, true);
|
||||||
const QWidget* w = opt.widget;
|
QPen pen(geopro::app::tokenColor("accent/primary"), 2.0);
|
||||||
QStyle* st = w ? w->style() : QApplication::style();
|
pen.setCapStyle(Qt::RoundCap);
|
||||||
st->drawPrimitive(QStyle::PE_IndicatorItemViewItemCheck, &o, p, w);
|
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;
|
||||||
|
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; // 复选框右侧留白后再放文本
|
textLeftPad = 12 + box + 8; // 复选框右侧留白后再放文本
|
||||||
} else if (isContainer) {
|
} else if (isContainer) {
|
||||||
// 容器文本左缘对齐子级复选框的左缘(r.left()+12)——使「容器→带框子级」的视觉缩进 = 一个树级
|
// 容器文本左缘对齐子级复选框的左缘(r.left()+12)——使「容器→带框子级」的视觉缩进 = 一个树级
|
||||||
|
|
@ -143,6 +155,7 @@ public:
|
||||||
const QModelIndex& idx) override {
|
const QModelIndex& idx) override {
|
||||||
if (!(idx.flags() & Qt::ItemIsUserCheckable))
|
if (!(idx.flags() & Qt::ItemIsUserCheckable))
|
||||||
return QStyledItemDelegate::editorEvent(ev, model, opt, idx);
|
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 QRect r = opt.rect.adjusted(4, 2, -4, -2);
|
||||||
const int box = 16;
|
const int box = 16;
|
||||||
// 命中区放宽到复选框左侧整段(含一点文本起始),便于点击。
|
// 命中区放宽到复选框左侧整段(含一点文本起始),便于点击。
|
||||||
|
|
@ -154,12 +167,14 @@ public:
|
||||||
if (ev->type() == QEvent::MouseButtonRelease) {
|
if (ev->type() == QEvent::MouseButtonRelease) {
|
||||||
auto* me = static_cast<QMouseEvent*>(ev);
|
auto* me = static_cast<QMouseEvent*>(ev);
|
||||||
if (me->button() == Qt::LeftButton && hit.contains(me->pos())) {
|
if (me->button() == Qt::LeftButton && hit.contains(me->pos())) {
|
||||||
|
if (busy) return true; // 渲染中:不切换,仅消费
|
||||||
toggle();
|
toggle();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
} else if (ev->type() == QEvent::KeyPress) {
|
} else if (ev->type() == QEvent::KeyPress) {
|
||||||
auto* ke = static_cast<QKeyEvent*>(ev);
|
auto* ke = static_cast<QKeyEvent*>(ev);
|
||||||
if (ke->key() == Qt::Key_Space || ke->key() == Qt::Key_Select) {
|
if (ke->key() == Qt::Key_Space || ke->key() == Qt::Key_Select) {
|
||||||
|
if (busy) return true;
|
||||||
toggle();
|
toggle();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@ constexpr int kDsNameRole = 0x0105; // Qt::UserRole + 5(dsName,详情页
|
||||||
constexpr int kDsTypeNameRole = 0x0106; // Qt::UserRole + 6(类型名,快速筛选用)
|
constexpr int kDsTypeNameRole = 0x0106; // Qt::UserRole + 6(类型名,快速筛选用)
|
||||||
constexpr int kDsCreateTimeRole = 0x0107; // Qt::UserRole + 7(创建时间,按日期筛选用)
|
constexpr int kDsCreateTimeRole = 0x0107; // Qt::UserRole + 7(创建时间,按日期筛选用)
|
||||||
constexpr int kDsTmObjectIdRole = 0x0108; // Qt::UserRole + 8(所属 TM 对象 id=白化 structParentId)
|
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 树)。
|
// 数据页签:树形(按 DsRow.parentId 嵌套,源数据为根、派生数据挂其下,对齐原版 el-table 树)。
|
||||||
// 每项列0:文本 = dsName +「创建时间 · 类型名」;data(0,角色) 存 dsId/ddCode/dsName。
|
// 每项列0:文本 = dsName +「创建时间 · 类型名」;data(0,角色) 存 dsId/ddCode/dsName。
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,10 @@ LoadingOverlay::LoadingOverlay(QWidget* parent) : QWidget(parent), label_(new QL
|
||||||
hide();
|
hide();
|
||||||
}
|
}
|
||||||
|
|
||||||
void LoadingOverlay::showOver() {
|
void LoadingOverlay::showOver() { showOver(QStringLiteral("加载中…")); }
|
||||||
|
|
||||||
|
void LoadingOverlay::showOver(const QString& message) {
|
||||||
|
label_->setText(message);
|
||||||
if (parentWidget()) setGeometry(parentWidget()->rect());
|
if (parentWidget()) setGeometry(parentWidget()->rect());
|
||||||
raise();
|
raise();
|
||||||
show();
|
show();
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,14 @@
|
||||||
class QLabel;
|
class QLabel;
|
||||||
namespace geopro::app {
|
namespace geopro::app {
|
||||||
|
|
||||||
// 半透明「加载中…」遮罩。贴在目标视图上层,showOver()/hide() 切换,几何随父 resize 跟随。
|
// 半透明「等待」遮罩(公共组件)。贴在任意目标视图上层(含 VTK QVTKOpenGLStereoWidget),
|
||||||
|
// showOver()/hide() 切换,几何随父 resize 跟随。可传自定义文案在不同场景复用。
|
||||||
class LoadingOverlay : public QWidget {
|
class LoadingOverlay : public QWidget {
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
public:
|
public:
|
||||||
explicit LoadingOverlay(QWidget* parent);
|
explicit LoadingOverlay(QWidget* parent);
|
||||||
void showOver(); // 铺满父尺寸、置顶、显示
|
void showOver(); // 铺满父尺寸、置顶、显示(默认「加载中…」)
|
||||||
|
void showOver(const QString& message); // 同上,自定义提示文案(如「正在保存三维体…」)
|
||||||
protected:
|
protected:
|
||||||
bool eventFilter(QObject* obj, QEvent* ev) override; // 跟随父 resize
|
bool eventFilter(QObject* obj, QEvent* ev) override; // 跟随父 resize
|
||||||
private:
|
private:
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
#include "panels/columns/CategoryAnalysisTab.hpp"
|
#include "panels/columns/CategoryAnalysisTab.hpp"
|
||||||
|
|
||||||
|
#include <QAbstractItemView>
|
||||||
|
#include <QPointer>
|
||||||
#include <QScrollArea>
|
#include <QScrollArea>
|
||||||
|
#include <QScrollBar>
|
||||||
|
#include <QTimer>
|
||||||
|
#include <QTreeWidget>
|
||||||
#include <QVBoxLayout>
|
#include <QVBoxLayout>
|
||||||
|
|
||||||
#include "Theme.hpp"
|
#include "Theme.hpp"
|
||||||
|
|
@ -15,12 +20,14 @@ CategoryAnalysisTab::CategoryAnalysisTab(geopro::data::DatasetFieldDictionary* d
|
||||||
outer->setSpacing(0);
|
outer->setSpacing(0);
|
||||||
|
|
||||||
auto* scroll = new QScrollArea(this);
|
auto* scroll = new QScrollArea(this);
|
||||||
|
scroll_ = scroll;
|
||||||
scroll->setWidgetResizable(true);
|
scroll->setWidgetResizable(true);
|
||||||
scroll->setFrameShape(QFrame::NoFrame);
|
scroll->setFrameShape(QFrame::NoFrame);
|
||||||
scroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); // 内容随面板宽自适应,不出横向滚动条
|
scroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); // 内容随面板宽自适应,不出横向滚动条
|
||||||
outer->addWidget(scroll, 1);
|
outer->addWidget(scroll, 1);
|
||||||
|
|
||||||
auto* content = new QWidget(scroll);
|
auto* content = new QWidget(scroll);
|
||||||
|
content_ = content;
|
||||||
auto* col = new QVBoxLayout(content);
|
auto* col = new QVBoxLayout(content);
|
||||||
col_ = col;
|
col_ = col;
|
||||||
col->setContentsMargins(0, space::kSm, 0, space::kSm); // 顶部留白:首段段头不贴顶
|
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;
|
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() {
|
void CategoryAnalysisTab::recomputeCheckedUnion() {
|
||||||
QStringList all; // ds 归属唯一段,跨段不重复,直接拼接
|
QStringList all; // ds 归属唯一段,跨段不重复,直接拼接
|
||||||
for (const auto& [id, ids] : checkedBySeg_) all += ids;
|
for (const auto& [id, ids] : checkedBySeg_) all += ids;
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
class QVBoxLayout;
|
class QVBoxLayout;
|
||||||
|
class QScrollArea;
|
||||||
#include "DatasetCategory.hpp" // CategoryBuckets
|
#include "DatasetCategory.hpp" // CategoryBuckets
|
||||||
#include "interact/SlicePlaneMath.hpp" // geopro::render::interact::SliceAxis
|
#include "interact/SlicePlaneMath.hpp" // geopro::render::interact::SliceAxis
|
||||||
#include "repo/RepoTypes.hpp"
|
#include "repo/RepoTypes.hpp"
|
||||||
|
|
@ -29,6 +30,11 @@ public:
|
||||||
void setStructure(const std::vector<geopro::data::StructNode>& nodes); // 转发各段
|
void setStructure(const std::vector<geopro::data::StructNode>& nodes); // 转发各段
|
||||||
void refreshArrayFilters(); // 装置枚举异步加载后,重填各段装置筛选下拉
|
void refreshArrayFilters(); // 装置枚举异步加载后,重填各段装置筛选下拉
|
||||||
CategorySection* section(const std::string& id) const; // 按 CategorySpec.id 取段
|
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:
|
signals:
|
||||||
void checkedDatasetsChanged(const QStringList& dsIds); // 5 段勾选并集
|
void checkedDatasetsChanged(const QStringList& dsIds); // 5 段勾选并集
|
||||||
|
|
@ -47,12 +53,17 @@ signals:
|
||||||
|
|
||||||
private:
|
private:
|
||||||
void recomputeCheckedUnion();
|
void recomputeCheckedUnion();
|
||||||
|
// scrollItemToTop 的多拍重试实现:展开段/新增行后布局与滚动条范围需多次结算,单拍常滚不到位。
|
||||||
|
// 每拍重算行位置并设滚动条,剩余拍数耗尽前持续校正 → 末拍几何稳定后行稳定到顶。
|
||||||
|
void scrollItemToTopRetry(const QString& dsId, int attemptsLeft);
|
||||||
// 据各段折叠态重排 stretch:折叠段=0(仅段头高)、展开段=1(吸收余量);全折叠时尾部弹簧吸收→段头顶到顶部。
|
// 据各段折叠态重排 stretch:折叠段=0(仅段头高)、展开段=1(吸收余量);全折叠时尾部弹簧吸收→段头顶到顶部。
|
||||||
// 仅在面板不产生滚动条(内容short于视口)时有可见效果——正是用户反馈的"折叠后停在原位中间"场景。
|
// 仅在面板不产生滚动条(内容short于视口)时有可见效果——正是用户反馈的"折叠后停在原位中间"场景。
|
||||||
void relayoutSections();
|
void relayoutSections();
|
||||||
|
|
||||||
std::map<std::string, CategorySection*> sections_;
|
std::map<std::string, CategorySection*> sections_;
|
||||||
std::vector<CategorySection*> ordered_; // 按 categoryConfigs 顺序(relayout 遍历用)
|
std::vector<CategorySection*> ordered_; // 按 categoryConfigs 顺序(relayout 遍历用)
|
||||||
|
QScrollArea* scroll_ = nullptr; // 外层滚动区(scrollItemToTop 定位用)
|
||||||
|
QWidget* content_ = nullptr; // 滚动内容容器(坐标映射用)
|
||||||
QVBoxLayout* col_ = nullptr; // 段堆叠布局(末项=尾部弹簧)
|
QVBoxLayout* col_ = nullptr; // 段堆叠布局(末项=尾部弹簧)
|
||||||
std::map<std::string, QStringList> checkedBySeg_; // 各段当前勾选(合并成并集)
|
std::map<std::string, QStringList> checkedBySeg_; // 各段当前勾选(合并成并集)
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
#include <QPushButton>
|
#include <QPushButton>
|
||||||
#include <QSet>
|
#include <QSet>
|
||||||
#include <QSignalBlocker>
|
#include <QSignalBlocker>
|
||||||
|
#include <QTimer>
|
||||||
#include <QToolButton>
|
#include <QToolButton>
|
||||||
#include <QTreeWidget>
|
#include <QTreeWidget>
|
||||||
#include <QTreeWidgetItemIterator>
|
#include <QTreeWidgetItemIterator>
|
||||||
|
|
@ -151,6 +152,17 @@ CategorySection::CategorySection(const CategorySpec& spec, geopro::data::Dataset
|
||||||
|
|
||||||
bool CategorySection::isExpanded() const { return header_ && header_->isChecked(); }
|
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) {
|
void CategorySection::setStructure(const std::vector<geopro::data::StructNode>& nodes) {
|
||||||
structure_ = nodes; // 容器分层(项目根/GS/TM→ds)在 Task 12 接入真实结构后据此构建。
|
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() {
|
void CategorySection::refreshArrayCombo() {
|
||||||
if (!spec_.hasArrayTypeFilter || !arrayCombo_) return;
|
if (!spec_.hasArrayTypeFilter || !arrayCombo_) return;
|
||||||
const QString prev = arrayCombo_->currentData().toString();
|
const QString prev = arrayCombo_->currentData().toString();
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,12 @@
|
||||||
#include "repo/RepoTypes.hpp"
|
#include "repo/RepoTypes.hpp"
|
||||||
|
|
||||||
class QTreeWidget;
|
class QTreeWidget;
|
||||||
|
class QTreeWidgetItem;
|
||||||
class QComboBox;
|
class QComboBox;
|
||||||
class QDateEdit;
|
class QDateEdit;
|
||||||
class QLabel;
|
class QLabel;
|
||||||
class QToolButton;
|
class QToolButton;
|
||||||
|
class QTimer;
|
||||||
class QWidget;
|
class QWidget;
|
||||||
|
|
||||||
namespace geopro::data {
|
namespace geopro::data {
|
||||||
|
|
@ -33,11 +35,17 @@ public:
|
||||||
void setStructure(const std::vector<geopro::data::StructNode>& nodes);
|
void setStructure(const std::vector<geopro::data::StructNode>& nodes);
|
||||||
void setDatasets(const std::vector<geopro::data::DsRow>& rows);
|
void setDatasets(const std::vector<geopro::data::DsRow>& rows);
|
||||||
void setChecked(const QString& dsId, bool on); // 按 dsId 勾选/取消(新建切片自动勾选等场景)
|
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 反向联动);空/未找到=清选中
|
void selectItem(const QString& dsId); // 程序化选中某行(VTK→list 反向联动);空/未找到=清选中
|
||||||
QStringList checkedIds() const { return checkedDsIds(); } // 当前勾选 ds(异常显隐同步用)
|
QStringList checkedIds() const { return checkedDsIds(); } // 当前勾选 ds(异常显隐同步用)
|
||||||
void refreshArrayFilter() { refreshArrayCombo(); } // 装置枚举异步加载后重填下拉
|
void refreshArrayFilter() { refreshArrayCombo(); } // 装置枚举异步加载后重填下拉
|
||||||
const CategorySpec& spec() const { return spec_; }
|
const CategorySpec& spec() const { return spec_; }
|
||||||
bool isExpanded() const; // 段头展开态(供外层按折叠状态重分配 stretch,实现"折叠向上收")
|
bool isExpanded() const; // 段头展开态(供外层按折叠状态重分配 stretch,实现"折叠向上收")
|
||||||
|
void ensureExpanded(); // 展开本段(折叠则展开):滚动定位前确保目标行可见
|
||||||
|
QTreeWidget* listWidget() const { return list_; } // 段体树(外层滚动定位用)
|
||||||
|
QTreeWidgetItem* itemFor(const QString& dsId) const; // 按 dsId 取行(无则 nullptr)
|
||||||
|
|
||||||
signals:
|
signals:
|
||||||
void checkedDatasetsChanged(const QStringList& dsIds); // 数据行勾选=渲染
|
void checkedDatasetsChanged(const QStringList& dsIds); // 数据行勾选=渲染
|
||||||
|
|
@ -73,6 +81,8 @@ private:
|
||||||
QComboBox* arrayCombo_ = nullptr; // 装置类型筛选(仅 hasArrayTypeFilter)
|
QComboBox* arrayCombo_ = nullptr; // 装置类型筛选(仅 hasArrayTypeFilter)
|
||||||
DateRangeEdit* dateRange_ = nullptr; // 采集时间范围筛选(起止双日历,可清空)
|
DateRangeEdit* dateRange_ = nullptr; // 采集时间范围筛选(起止双日历,可清空)
|
||||||
QTreeWidget* list_ = nullptr;
|
QTreeWidget* list_ = nullptr;
|
||||||
|
QTimer* spinTimer_ = nullptr; // 驱动 busy 行 spinner 旋转(有 busy 行时运行)
|
||||||
|
int spinAngle_ = 0; // 当前 spinner 角度(度)
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace geopro::app
|
} // namespace geopro::app
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,7 @@ void ColumnDrawer::toggleCollapsed()
|
||||||
// 折叠后只保留按钮宽度;展开恢复可调范围
|
// 折叠后只保留按钮宽度;展开恢复可调范围
|
||||||
setMinimumWidth(collapsed_ ? 0 : 180);
|
setMinimumWidth(collapsed_ ? 0 : 180);
|
||||||
setMaximumWidth(collapsed_ ? 18 : 560);
|
setMaximumWidth(collapsed_ ? 18 : 560);
|
||||||
|
emit collapsedChanged(collapsed_); // 通知上层调 QSplitter 尺寸,回收/恢复栏宽(防残留空白)
|
||||||
}
|
}
|
||||||
|
|
||||||
void ColumnDrawer::expand()
|
void ColumnDrawer::expand()
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,8 @@ public:
|
||||||
signals:
|
signals:
|
||||||
// 切换「三维分析 / 二维分析」tab:is2D=true 进入二维分析。上层据此切相机+翻维度可见标志。
|
// 切换「三维分析 / 二维分析」tab:is2D=true 进入二维分析。上层据此切相机+翻维度可见标志。
|
||||||
void analysisModeChanged(bool is2D);
|
void analysisModeChanged(bool is2D);
|
||||||
|
// 折叠/展开切换:上层据此调整 QSplitter 尺寸,回收/恢复抽屉栏宽(否则收起残留空白区)。
|
||||||
|
void collapsedChanged(bool collapsed);
|
||||||
|
|
||||||
public slots:
|
public slots:
|
||||||
void toggleCollapsed();
|
void toggleCollapsed();
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,8 @@ public:
|
||||||
|
|
||||||
virtual void clear() = 0;
|
virtual void clear() = 0;
|
||||||
virtual void setVerticalExaggeration(double ve) = 0;
|
virtual void setVerticalExaggeration(double ve) = 0;
|
||||||
|
// 三维体体绘制最大不透明度(0~1):运行时调节已渲染体 + 后续新体(默认 0.30)。默认空实现,测试 mock 无需覆盖。
|
||||||
|
virtual void setVolumeOpacity(double maxOpacity) { (void)maxOpacity; }
|
||||||
// 地表高程基准(测线地表高程):2D 足迹「顶部/底部」摆放锚定真实地表。
|
// 地表高程基准(测线地表高程):2D 足迹「顶部/底部」摆放锚定真实地表。
|
||||||
virtual double zRefElev() const = 0;
|
virtual double zRefElev() const = 0;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
#include <set>
|
#include <set>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
|
|
||||||
|
#include <QDebug>
|
||||||
#include <QPointer>
|
#include <QPointer>
|
||||||
|
|
||||||
#include "I3dSceneView.hpp"
|
#include "I3dSceneView.hpp"
|
||||||
|
|
@ -150,9 +151,12 @@ void VtkSceneController::addDatasetAsync(const std::string& dsId, unsigned long
|
||||||
if (cachedGrid != volumeCache_.end() && cachedScale != volumeScaleCache_.end()) {
|
if (cachedGrid != volumeCache_.end() && cachedScale != volumeScaleCache_.end()) {
|
||||||
view_.addVolume(dsId, cachedGrid->second, cachedScale->second); // 缓存命中(色阶随体缓存)
|
view_.addVolume(dsId, cachedGrid->second, cachedScale->second); // 缓存命中(色阶随体缓存)
|
||||||
onDatasetArrived();
|
onDatasetArrived();
|
||||||
|
emit volumeRendered(QString::fromStdString(dsId)); // 缓存命中即时完成 → 撤 spinner
|
||||||
|
emit datasetRendered(QString::fromStdString(dsId));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
loadingDs_.insert(dsId);
|
loadingDs_.insert(dsId);
|
||||||
|
emit datasetLoading(QString::fromStdString(dsId)); // 异步建体开始 → 列表项转 spinner
|
||||||
sceneRepo_.loadVolume(
|
sceneRepo_.loadVolume(
|
||||||
dsId,
|
dsId,
|
||||||
[self, gen, dsId](data::VolumeGrid g, core::ColorScale cs) {
|
[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;
|
if (gen != self->rebuildGeneration_ || !self->isChecked(dsId)) return;
|
||||||
self->volumeScaleCache_[dsId] = cs; // 色阶随体一起缓存(mock 体在 dsRepo_ 无条目)
|
self->volumeScaleCache_[dsId] = cs; // 色阶随体一起缓存(mock 体在 dsRepo_ 无条目)
|
||||||
auto it = self->volumeCache_.emplace(dsId, std::move(g)).first;
|
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->view_.addVolume(dsId, it->second, self->volumeScaleCache_[dsId]);
|
||||||
self->onDatasetArrived();
|
self->onDatasetArrived();
|
||||||
|
emit self->volumeRendered(QString::fromStdString(dsId)); // 落地完成 → 撤 spinner
|
||||||
|
emit self->datasetRendered(QString::fromStdString(dsId));
|
||||||
},
|
},
|
||||||
[self, gen, dsId](const std::string& m) {
|
[self, gen, dsId](const std::string& m) {
|
||||||
if (!self) return;
|
if (!self) return;
|
||||||
|
|
@ -175,6 +184,7 @@ void VtkSceneController::addDatasetAsync(const std::string& dsId, unsigned long
|
||||||
|
|
||||||
// 剖面 → 帘面(着色用 loadSection 返回的 s.scale,与体的源色阶同源)。
|
// 剖面 → 帘面(着色用 loadSection 返回的 s.scale,与体的源色阶同源)。
|
||||||
loadingDs_.insert(dsId);
|
loadingDs_.insert(dsId);
|
||||||
|
emit datasetLoading(QString::fromStdString(dsId)); // 剖面首次加载较慢 → 列表项转 spinner
|
||||||
sceneRepo_.loadSection(
|
sceneRepo_.loadSection(
|
||||||
dsId,
|
dsId,
|
||||||
[self, gen, dsId](data::SectionData s) {
|
[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; // 作废/已取消
|
if (gen != self->rebuildGeneration_ || !self->isChecked(dsId)) return; // 作废/已取消
|
||||||
self->view_.addCurtain(dsId, s.grid, s.scale);
|
self->view_.addCurtain(dsId, s.grid, s.scale);
|
||||||
self->onDatasetArrived();
|
self->onDatasetArrived();
|
||||||
|
emit self->datasetRendered(QString::fromStdString(dsId)); // 帘面落地 → 复原复选框
|
||||||
},
|
},
|
||||||
[self, gen, dsId](const std::string& m) {
|
[self, gen, dsId](const std::string& m) {
|
||||||
if (!self) return;
|
if (!self) return;
|
||||||
|
|
@ -240,6 +251,11 @@ void VtkSceneController::setVerticalExaggeration(double ve) {
|
||||||
preserveCameraOnRebuild_ = false;
|
preserveCameraOnRebuild_ = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void VtkSceneController::setVolumeOpacity(double maxOpacity) {
|
||||||
|
// 运行时更新已渲染体的不透明度传递函数(不重建体,实时跟手)+ 记为后续新体默认(见 VtkSceneView)。
|
||||||
|
view_.setVolumeOpacity(maxOpacity);
|
||||||
|
}
|
||||||
|
|
||||||
void VtkSceneController::rebuild() { rebuildInternal(); }
|
void VtkSceneController::rebuild() { rebuildInternal(); }
|
||||||
|
|
||||||
void VtkSceneController::setVolumeColorScale(const std::string& dsId,
|
void VtkSceneController::setVolumeColorScale(const std::string& dsId,
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,8 @@ public slots:
|
||||||
void onAnalysisModeChanged(bool is2D);
|
void onAnalysisModeChanged(bool is2D);
|
||||||
void setLayer(SceneLayer layer, bool on);
|
void setLayer(SceneLayer layer, bool on);
|
||||||
void setVerticalExaggeration(double ve);
|
void setVerticalExaggeration(double ve);
|
||||||
|
// 三维体透明度调节(工具条滑块):运行时更新已渲染体的不透明度,并作为后续新体默认(0~1)。
|
||||||
|
void setVolumeOpacity(double maxOpacity);
|
||||||
void rebuild(); // 主题切换等外部触发的重渲染
|
void rebuild(); // 主题切换等外部触发的重渲染
|
||||||
|
|
||||||
// 色阶编辑器「确定」:更新某三维体色阶并就地重渲染(体素 + 其切片随新色阶重建)。
|
// 色阶编辑器「确定」:更新某三维体色阶并就地重渲染(体素 + 其切片随新色阶重建)。
|
||||||
|
|
@ -66,6 +68,12 @@ public slots:
|
||||||
|
|
||||||
signals:
|
signals:
|
||||||
void loadFailed(const QString& message);
|
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:
|
private:
|
||||||
void rebuildInternal();
|
void rebuildInternal();
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,12 @@
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
#include <limits>
|
#include <limits>
|
||||||
|
#include <numeric>
|
||||||
#include <stdexcept>
|
#include <stdexcept>
|
||||||
|
#include <unordered_map>
|
||||||
#include "algo/IdwInterpolator.hpp"
|
#include <vector>
|
||||||
|
|
||||||
namespace geopro::core {
|
namespace geopro::core {
|
||||||
|
|
||||||
|
|
@ -24,10 +26,64 @@ void fitAxis(double ext, double cell, double& outCell, int& outN) {
|
||||||
outN = kMaxVolumeDim;
|
outN = kMaxVolumeDim;
|
||||||
outCell = ext / static_cast<double>(kMaxVolumeDim - 1); // maxDim 格覆盖全 ext
|
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
|
} // namespace
|
||||||
|
|
||||||
BuiltVolume buildVolume(const PointSet& pts, double cellXY, double cellZ,
|
BuiltVolume buildVolume(const PointSet& pts, double cellXY, double cellZ,
|
||||||
double power, double maxDist) {
|
double power, double maxDist, bool clipToFootprint) {
|
||||||
if (pts.v.empty()) {
|
if (pts.v.empty()) {
|
||||||
throw std::invalid_argument("buildVolume: empty point set");
|
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(maxy - miny, cellXY, spec.dy, spec.ny);
|
||||||
fitAxis(maxz - minz, cellZ, spec.dz, spec.nz);
|
fitAxis(maxz - minz, cellZ, spec.dz, spec.nz);
|
||||||
spec.power = power;
|
spec.power = power;
|
||||||
spec.maxDist = maxDist;
|
|
||||||
|
|
||||||
// 3) IDW(maxDist 外 NaN 留空)。
|
// 节点封顶:真实数据(大测区/小 cell)易逼近 kMaxVolumeDim³ → IDW 卡死。超 kMaxNodes 等比放大三轴 cell。
|
||||||
const IdwInterpolator idw;
|
{
|
||||||
ScalarVolume vol = idw.interpolate(pts, spec);
|
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}。
|
// 4) 数据实测值域(仅有限值)。无有限值 → 退化 {0,1}。
|
||||||
double vmin = std::numeric_limits<double>::infinity();
|
double vmin = std::numeric_limits<double>::infinity();
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,15 @@ struct BuiltVolume {
|
||||||
// 前置:pts 须含 ≥1 点(空集抛 std::invalid_argument)。
|
// 前置:pts 须含 ≥1 点(空集抛 std::invalid_argument)。
|
||||||
// 确定性:同点集同参数 → 同 GridSpec 同体(不冻结 spec,见计划 §1 决策)。
|
// 确定性:同点集同参数 → 同 GridSpec 同体(不冻结 spec,见计划 §1 决策)。
|
||||||
// 提取自 LocalSample3dRepository::loadVolume,供本地样本 / 真实 Api 共享,消除调参漂移。
|
// 提取自 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,
|
BuiltVolume buildVolume(const PointSet& pts, double cellXY, double cellZ,
|
||||||
double power, double maxDist);
|
double power, double maxDist, bool clipToFootprint = true);
|
||||||
|
|
||||||
} // namespace geopro::core
|
} // namespace geopro::core
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
#include "api/Api3dRepository.hpp"
|
#include "api/Api3dRepository.hpp"
|
||||||
|
|
||||||
|
#include <QCoreApplication>
|
||||||
#include <QDateTime>
|
#include <QDateTime>
|
||||||
#include <QDebug>
|
#include <QDebug>
|
||||||
#include <QJsonDocument>
|
#include <QJsonDocument>
|
||||||
|
|
@ -7,10 +8,14 @@
|
||||||
#include <QString>
|
#include <QString>
|
||||||
#include <QVariant>
|
#include <QVariant>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <chrono>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
#include <exception>
|
#include <exception>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
|
#include <thread>
|
||||||
|
#include <tuple>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
|
|
||||||
#include "algo/VolumeBuilder.hpp" // core::PointSet / BuiltVolume / buildVolume(含 Field.hpp)
|
#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;
|
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> Api3dRepository::volumeRows() const {
|
||||||
std::vector<DsRow> rows;
|
std::vector<DsRow> rows;
|
||||||
rows.reserve(volumes_.size());
|
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,
|
void Api3dRepository::finalizeVolume(const std::string& dsId, const core::PointSet& pts,
|
||||||
const core::ColorScale& scale,
|
const core::ColorScale& scale,
|
||||||
const VolumeBuildParams& params,
|
const VolumeBuildParams& params,
|
||||||
|
|
@ -239,34 +264,75 @@ void Api3dRepository::finalizeVolume(const std::string& dsId, const core::PointS
|
||||||
onErr("Api3dRepository::loadVolume: 配准后无有效散点(源数据为空或全无数据)");
|
onErr("Api3dRepository::loadVolume: 配准后无有效散点(源数据为空或全无数据)");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
geopro::core::BuiltVolume bv =
|
// 重 IDW 建体放后台线程,避免阻塞 UI(用户要求:渲染必须异步)。纯计算(无 Qt/VTK),算完
|
||||||
geopro::core::buildVolume(pts, params.cellXY, params.cellZ, params.power, params.maxDist);
|
// 经事件循环回主线程做缓存 + 交付(缓存写 volumes_ / onOk 触碰 VTK 必须在主线程)。
|
||||||
// 值域:优先色阶分段值,否则 buildVolume 的数据实测范围。
|
// 兜底:无 QCoreApplication(headless/单测)时退化为同步,保证可测/可离屏。
|
||||||
double vmin = bv.vmin, vmax = bv.vmax;
|
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();
|
const std::vector<double> stops = scale.stopValues();
|
||||||
if (stops.size() >= 2) {
|
if (stops.size() >= 2) {
|
||||||
vmin = stops.front();
|
vmin = stops.front();
|
||||||
vmax = stops.back();
|
vmax = stops.back();
|
||||||
}
|
}
|
||||||
qInfo().noquote() << "[volbuild] finalize pts=" << pts.v.size() << "grid"
|
qInfo().noquote() << "[volbuild] finalize pts=" << nPts << "grid" << bv->spec.nx << "x"
|
||||||
<< bv.spec.nx << "x" << bv.spec.ny << "x" << bv.spec.nz
|
<< bv->spec.ny << "x" << bv->spec.nz << "origin" << bv->spec.ox
|
||||||
<< "origin" << bv.spec.ox << bv.spec.oy << bv.spec.oz << "spacing"
|
<< bv->spec.oy << bv->spec.oz << "spacing" << bv->spec.dx << bv->spec.dy
|
||||||
<< bv.spec.dx << bv.spec.dy << bv.spec.dz;
|
<< bv->spec.dz;
|
||||||
VolumeGrid out{std::move(bv.vol),
|
VolumeGrid out{std::move(bv->vol),
|
||||||
{{bv.spec.ox, bv.spec.oy, bv.spec.oz}},
|
{{bv->spec.ox, bv->spec.oy, bv->spec.oz}},
|
||||||
{{bv.spec.dx, bv.spec.dy, bv.spec.dz}},
|
{{bv->spec.dx, bv->spec.dy, bv->spec.dz}},
|
||||||
vmin, vmax};
|
vmin, vmax};
|
||||||
auto it = volumes_.find(dsId);
|
auto it = volumes_.find(dsId);
|
||||||
if (it != volumes_.end()) { // 缓存明细 + 色阶(下次命中即跳重算)
|
if (it != volumes_.end()) { // 缓存明细 + 色阶(下次命中即跳重算)
|
||||||
it->second.cachedGrid = out;
|
it->second.cachedGrid = out;
|
||||||
it->second.cachedScale = scale;
|
it->second.cachedScale = scale;
|
||||||
it->second.pointCount = pts.v.size(); // 持久化聚合散点数(详情统计用)
|
it->second.pointCount = nPts; // 持久化聚合散点数(详情统计用)
|
||||||
}
|
}
|
||||||
onOk(std::move(out), scale);
|
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<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) {
|
||||||
|
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);
|
||||||
|
// 回主线程交付(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,
|
void Api3dRepository::loadVolume(const std::string& dsId,
|
||||||
|
|
@ -294,15 +360,14 @@ void Api3dRepository::loadVolume(const std::string& dsId,
|
||||||
int pending;
|
int pending;
|
||||||
bool failed = false;
|
bool failed = false;
|
||||||
core::PointSet pts;
|
core::PointSet pts;
|
||||||
core::ColorScale scale; // 取首个到达源的色阶定值域
|
std::vector<core::ColorScale> scales; // 收集所有源色阶 → 取 vmax 中位者定值域(不依赖到达顺序)
|
||||||
bool haveScale = false;
|
|
||||||
};
|
};
|
||||||
auto agg = std::make_shared<Agg>();
|
auto agg = std::make_shared<Agg>();
|
||||||
agg->pending = static_cast<int>(params.sourceDatasetIds.size());
|
agg->pending = static_cast<int>(params.sourceDatasetIds.size());
|
||||||
|
|
||||||
for (const std::string& srcId : params.sourceDatasetIds) {
|
for (const std::string& srcId : params.sourceDatasetIds) {
|
||||||
loadSection(
|
loadSectionWithRetry(
|
||||||
srcId,
|
srcId, /*attemptsLeft=*/2,
|
||||||
[this, dsId, srcId, params, agg, onOk, onErr](SectionData s) {
|
[this, dsId, srcId, params, agg, onOk, onErr](SectionData s) {
|
||||||
if (agg->failed) return;
|
if (agg->failed) return;
|
||||||
const std::size_t before = agg->pts.v.size();
|
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() << "-> +"
|
<< "grid" << s.grid.nx() << "x" << s.grid.ny() << "-> +"
|
||||||
<< (agg->pts.v.size() - before) << "pts (total"
|
<< (agg->pts.v.size() - before) << "pts (total"
|
||||||
<< agg->pts.v.size() << ")";
|
<< agg->pts.v.size() << ")";
|
||||||
if (!agg->haveScale) {
|
agg->scales.push_back(s.scale);
|
||||||
agg->scale = s.scale;
|
|
||||||
agg->haveScale = true;
|
|
||||||
}
|
|
||||||
if (--agg->pending > 0) return; // 还有源未到齐
|
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) {
|
[agg, onErr](const std::string& m) {
|
||||||
if (agg->failed) return;
|
if (agg->failed) return;
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,8 @@ public:
|
||||||
const std::string& name, int coarse = 8);
|
const std::string& name, int coarse = 8);
|
||||||
// 取回某三维体组装的请求体(测试/联调);非本 repo 创建或无 request 时返回 nullptr。
|
// 取回某三维体组装的请求体(测试/联调);非本 repo 创建或无 request 时返回 nullptr。
|
||||||
const VoxelGenerateRequest* lastVoxelRequest(const std::string& dsId) const;
|
const VoxelGenerateRequest* lastVoxelRequest(const std::string& dsId) const;
|
||||||
|
// 清空内存态三维体/切片/异常(切换项目时调;否则上个项目的体/切片/异常残留在新项目列表)。
|
||||||
|
void clearMockData();
|
||||||
// 已创建三维体的列表行(ddCode="dd_voxel"),供三维分析栏合并注入(每次 setDatasets 追加)。
|
// 已创建三维体的列表行(ddCode="dd_voxel"),供三维分析栏合并注入(每次 setDatasets 追加)。
|
||||||
std::vector<DsRow> volumeRows() const;
|
std::vector<DsRow> volumeRows() const;
|
||||||
|
|
||||||
|
|
@ -119,6 +121,10 @@ private:
|
||||||
// 把一条剖面 section grid 的有限值节点按 CurtainActor 同口径定位(lat/lon→frame.toLocal,
|
// 把一条剖面 section grid 的有限值节点按 CurtainActor 同口径定位(lat/lon→frame.toLocal,
|
||||||
// 世界 Z=grid.y 高程)追加为 IDW 散点 → 保证体与帘面同系对齐。
|
// 世界 Z=grid.y 高程)追加为 IDW 散点 → 保证体与帘面同系对齐。
|
||||||
void appendGridPoints(const core::Grid& g, core::PointSet& pts) const;
|
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(体, 色阶)。
|
// 多源散点聚合完成 → buildVolume + 值域 + 缓存明细/色阶 → onOk(体, 色阶)。
|
||||||
void finalizeVolume(const std::string& dsId, const core::PointSet& pts,
|
void finalizeVolume(const std::string& dsId, const core::PointSet& pts,
|
||||||
const core::ColorScale& scale, const VolumeBuildParams& params,
|
const core::ColorScale& scale, const VolumeBuildParams& params,
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,15 @@
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <limits>
|
#include <limits>
|
||||||
|
|
||||||
|
#include <vtkActor.h>
|
||||||
#include <vtkColorTransferFunction.h>
|
#include <vtkColorTransferFunction.h>
|
||||||
#include <vtkDoubleArray.h>
|
#include <vtkDoubleArray.h>
|
||||||
|
#include <vtkFloatArray.h>
|
||||||
|
#include <vtkFlyingEdges3D.h>
|
||||||
#include <vtkNew.h>
|
#include <vtkNew.h>
|
||||||
|
#include <vtkPolyData.h>
|
||||||
|
#include <vtkPolyDataMapper.h>
|
||||||
|
#include <vtkProperty.h>
|
||||||
#include <vtkShortArray.h>
|
#include <vtkShortArray.h>
|
||||||
#include <vtkSmartVolumeMapper.h>
|
#include <vtkSmartVolumeMapper.h>
|
||||||
#include <vtkPiecewiseFunction.h>
|
#include <vtkPiecewiseFunction.h>
|
||||||
|
|
@ -18,8 +24,9 @@ namespace {
|
||||||
|
|
||||||
// 颜色/不透明度传递函数采样级数。
|
// 颜色/不透明度传递函数采样级数。
|
||||||
constexpr int kTransferSamples = 64;
|
constexpr int kTransferSamples = 64;
|
||||||
// 体绘制最大不透明度([vmin,vmax] 线性 0→kMaxOpacity)。
|
// 体绘制最大不透明度([vmin,vmax] 线性 0→kMaxOpacity)。值越大体越实(越不透明);
|
||||||
constexpr double kMaxOpacity = 0.15;
|
// 0.15 偏淡 → 0.30 更实仍可看穿内部。再大(0.4~0.6)会更像实心块、遮挡内部结构。
|
||||||
|
constexpr double kMaxOpacity = 0.30;
|
||||||
|
|
||||||
// NaN/留空格的哨兵:落在 [vmin,vmax] 之外,传递函数把它映射为完全透明。
|
// NaN/留空格的哨兵:落在 [vmin,vmax] 之外,传递函数把它映射为完全透明。
|
||||||
double sentinel(double vmin) { return vmin - 1.0; }
|
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->SetOrigin(ox, oy, oz);
|
||||||
img->SetSpacing(dx, dy, dz);
|
img->SetSpacing(dx, dy, dz);
|
||||||
|
|
||||||
vtkNew<vtkDoubleArray> sc;
|
// 标量用 float(非 double):OpenGL 无原生 double 体纹理,GPU 体绘制对 double 处理不稳/部分驱动间歇
|
||||||
|
// 出空(偶发不渲染根因之一),且省一半显存。float 精度对可视化足够。
|
||||||
|
vtkNew<vtkFloatArray> sc;
|
||||||
sc->SetName("v");
|
sc->SetName("v");
|
||||||
sc->SetNumberOfTuples(static_cast<vtkIdType>(nx) * ny * nz);
|
sc->SetNumberOfTuples(static_cast<vtkIdType>(nx) * ny * nz);
|
||||||
// 点序 i 最快、j 次之、k 最慢(匹配 vtkImageData 与 ScalarVolume::idx)。
|
// 点序 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) {
|
for (int i = 0; i < nx; ++i) {
|
||||||
const double v = vol.at(i, j, k);
|
const double v = vol.at(i, j, k);
|
||||||
const vtkIdType id = (static_cast<vtkIdType>(k) * ny + j) * nx + i;
|
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);
|
img->GetPointData()->SetScalars(sc);
|
||||||
outImage = img;
|
outImage = img;
|
||||||
|
|
@ -208,4 +217,34 @@ vtkSmartPointer<vtkVolume> buildVoxelI16FromImage(vtkImageData* shortImg,
|
||||||
return assembleVolumeI16(shortImg, q, cs, vminPhys, vmaxPhys);
|
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
|
} // namespace geopro::render
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
#pragma once
|
#pragma once
|
||||||
|
#include <vtkActor.h>
|
||||||
#include <vtkSmartPointer.h>
|
#include <vtkSmartPointer.h>
|
||||||
#include <vtkVolume.h>
|
#include <vtkVolume.h>
|
||||||
#include <vtkImageData.h>
|
#include <vtkImageData.h>
|
||||||
|
|
@ -7,6 +8,12 @@
|
||||||
#include "model/ScalarVolumeI16.hpp"
|
#include "model/ScalarVolumeI16.hpp"
|
||||||
namespace geopro::render {
|
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 光线投射体绘制。
|
// 把 core 规则标量体(IDW 输出,含 NaN 留空)转 vtkImageData,再建 GPU 光线投射体绘制。
|
||||||
// 颜色按 ColorScale 在 [vmin,vmax] 采样;NaN/留空格 → 不透明度 0(透明)。
|
// 颜色按 ColorScale 在 [vmin,vmax] 采样;NaN/留空格 → 不透明度 0(透明)。
|
||||||
// 返回 vtkVolume(由调用方加入 renderer)。
|
// 返回 vtkVolume(由调用方加入 renderer)。
|
||||||
|
|
|
||||||
|
|
@ -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_model.cpp)
|
||||||
target_sources(geopro_tests PRIVATE core/test_color_scale.cpp)
|
target_sources(geopro_tests PRIVATE core/test_color_scale.cpp)
|
||||||
target_sources(geopro_tests PRIVATE core/test_idw.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_crs_transform.cpp)
|
||||||
target_sources(geopro_tests PRIVATE core/test_model_data.cpp)
|
target_sources(geopro_tests PRIVATE core/test_model_data.cpp)
|
||||||
target_sources(geopro_tests PRIVATE core/test_geo_frame.cpp)
|
target_sources(geopro_tests PRIVATE core/test_geo_frame.cpp)
|
||||||
|
|
|
||||||
|
|
@ -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=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<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)));
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue