feat/vtk-3d-view #7

Merged
gaozheng merged 301 commits from feat/vtk-3d-view into main 2026-06-27 18:43:52 +08:00
11 changed files with 82 additions and 56 deletions
Showing only changes of commit 7ff6f184e9 - Show all commits

View File

@ -42,6 +42,13 @@ When your changes create orphans:
The test: Every changed line should trace directly to the user's request. The test: Every changed line should trace directly to the user's request.
**Exception — Tech debt must be fixed, not deferred:** When you discover a real bug,
broken behavior, or technical debt while working (even if it predates this change and
you didn't introduce it), fix it — do not use "not introduced by this round / pre-existing"
as a reason to leave it. Surface it, then handle it. (User directive, 2026-06-25, binding.)
This overrides the "don't fix what isn't broken" bias above *for genuine defects* — it does
not license cosmetic refactors or unrequested rewrites.
## 4. Goal-Driven Execution ## 4. Goal-Driven Execution
**Define success criteria. Loop until verified.** **Define success criteria. Loop until verified.**

View File

@ -51,13 +51,15 @@ AxesSettingsPanel::AxesSettingsPanel(QWidget* parent) : QFrame(parent) {
unit_->addItems({QStringLiteral(""), QStringLiteral("英尺")}); unit_->addItems({QStringLiteral(""), QStringLiteral("英尺")});
v->addWidget(unit_); v->addWidget(unit_);
// X / Y / Z 三轴(各:显示开关 + 最小值/最大值)。 // X / Y / Z 三轴(各:显示开关 + 最小值/最大值)。与上方单位组留间距。
v->addSpacing(space::kSm);
x_ = addAxisRow(v, QStringLiteral("X轴"), {true, -500, 500}); x_ = addAxisRow(v, QStringLiteral("X轴"), {true, -500, 500});
y_ = addAxisRow(v, QStringLiteral("Y轴"), {true, -500, 500}); y_ = addAxisRow(v, QStringLiteral("Y轴"), {true, -500, 500});
z_ = addAxisRow(v, QStringLiteral("Z轴"), {true, 0, 200}); z_ = addAxisRow(v, QStringLiteral("Z轴"), {true, 0, 200});
// 放大系数(=垂直夸张):滑块 1~10×恢复重构前的拖动交互。拖动实时改标签 // 放大系数(=垂直夸张):滑块 1~10×。仅改面板内数值/标签,点「应用」才统一生效(不再实时)。
// 松手sliderReleased才发信号触发一次重建——VE 改的是几何,必须重建,但不在拖动中连续重建。 // 与上方坐标轴组留出间距(用户反馈:滑块离上面项目太近)。
v->addSpacing(space::kMd);
auto* scaleRow = new QHBoxLayout(); auto* scaleRow = new QHBoxLayout();
auto* scaleLbl = new QLabel(QStringLiteral("放大系数"), this); auto* scaleLbl = new QLabel(QStringLiteral("放大系数"), this);
scaleLbl->setStyleSheet(QStringLiteral("border:none;")); scaleLbl->setStyleSheet(QStringLiteral("border:none;"));
@ -66,12 +68,12 @@ AxesSettingsPanel::AxesSettingsPanel(QWidget* parent) : QFrame(parent) {
scaleSlider_->setMinimum(1); scaleSlider_->setMinimum(1);
scaleSlider_->setMaximum(10); scaleSlider_->setMaximum(10);
scaleSlider_->setValue(1); scaleSlider_->setValue(1);
scaleSlider_->setSingleStep(1);
scaleSlider_->setPageStep(1); // 点击轨道按 1 步移动(默认 pageStep=10 → 点一下直接跳到 10 的 bug
scaleLabel_ = new QLabel(QStringLiteral("1.0×"), this); scaleLabel_ = new QLabel(QStringLiteral("1.0×"), this);
scaleLabel_->setStyleSheet(QStringLiteral("border:none;color:#888;min-width:36px;")); scaleLabel_->setStyleSheet(QStringLiteral("border:none;color:#888;min-width:36px;"));
connect(scaleSlider_, &QSlider::valueChanged, this, connect(scaleSlider_, &QSlider::valueChanged, this,
[this](int v) { scaleLabel_->setText(QStringLiteral("%1.0×").arg(v)); }); [this](int v) { scaleLabel_->setText(QStringLiteral("%1.0×").arg(v)); });
connect(scaleSlider_, &QSlider::sliderReleased, this,
[this] { emit verticalExaggerationChanged(static_cast<double>(scaleSlider_->value())); });
scaleRow->addWidget(scaleSlider_, 1); scaleRow->addWidget(scaleSlider_, 1);
scaleRow->addWidget(scaleLabel_); scaleRow->addWidget(scaleLabel_);
v->addLayout(scaleRow); v->addLayout(scaleRow);
@ -88,7 +90,8 @@ AxesSettingsPanel::AxesSettingsPanel(QWidget* parent) : QFrame(parent) {
auto rd = [](const Row& r) { auto rd = [](const Row& r) {
return AxisRange{r.show->isChecked(), r.lo->value(), r.hi->value()}; return AxisRange{r.show->isChecked(), r.lo->value(), r.hi->value()};
}; };
emit applied(rd(x_), rd(y_), rd(z_), unit_->currentIndex()); emit applied(rd(x_), rd(y_), rd(z_), unit_->currentIndex(),
static_cast<double>(scaleSlider_->value()));
}); });
btns->addStretch(1); btns->addStretch(1);
btns->addWidget(cancel); btns->addWidget(cancel);

View File

@ -23,10 +23,9 @@ public:
void setValues(AxisRange x, AxisRange y, AxisRange z, int unitIdx, double scale); void setValues(AxisRange x, AxisRange y, AxisRange z, int unitIdx, double scale);
signals: signals:
// 「应用」:仅坐标轴(显示方式/单位/per-axis 可见性·范围);走增量重建,不重绘数据。 // 「应用」一次性下发:坐标轴(显示方式/单位/per-axis 可见性·范围) + 放大系数(scale=垂直夸张)。
void applied(AxisRange x, AxisRange y, AxisRange z, int unitIdx); // 放大系数滑块仅改面板内数值,点「应用」才统一生效(与面板其余项一致,不再实时)。
// 放大系数(=垂直夸张)滑块:拖动实时改数值标签,松手才发此信号触发一次重建。 void applied(AxisRange x, AxisRange y, AxisRange z, int unitIdx, double scale);
void verticalExaggerationChanged(double ve);
void closed(); // × 或 取消 void closed(); // × 或 取消
private: private:

View File

@ -317,13 +317,15 @@ void VtkSceneView::rebuildAxes() {
} }
} }
void VtkSceneView::render(bool is2D) { void VtkSceneView::render(bool is2D, bool resetCamera) {
// 视图区背景永远深色(规范 §0.5:不随明暗切换),让色阶数据更突出。 // 视图区背景永远深色(规范 §0.5:不随明暗切换),让色阶数据更突出。
double bgR, bgG, bgB; double bgR, bgG, bgB;
geopro::app::vtkBackground(bgR, bgG, bgB); geopro::app::vtkBackground(bgR, bgG, bgB);
scene_.renderer()->SetBackground(bgR, bgG, bgB); scene_.renderer()->SetBackground(bgR, bgG, bgB);
// 坐标轴仅三维视图显示2D 俯视测线不需要立体坐标轴)。 // 坐标轴仅三维视图显示2D 俯视测线不需要立体坐标轴)。
if (!is2D) rebuildAxes(); if (!is2D) rebuildAxes();
// 相机预设(朝向)只在取景时应用——保留相机的重建(改放大系数)不重设朝向,否则也会跳视角。
if (resetCamera) {
if (is2D) if (is2D)
geopro::render::applyTop2D(scene_.renderer()); geopro::render::applyTop2D(scene_.renderer());
else else
@ -333,9 +335,10 @@ void VtkSceneView::render(bool is2D) {
scene_.renderer()->ResetCamera(bounds); // 取景到数据(不含底图,否则数据缩成小点) scene_.renderer()->ResetCamera(bounds); // 取景到数据(不含底图,否则数据缩成小点)
else else
scene_.renderer()->ResetCamera(); scene_.renderer()->ResetCamera();
}
scene_.renderer()->ResetCameraClippingRange(); // 裁剪面含底图 → 不被"蒙版"切掉 scene_.renderer()->ResetCameraClippingRange(); // 裁剪面含底图 → 不被"蒙版"切掉
if (renderWindow_) renderWindow_->Render(); if (renderWindow_) renderWindow_->Render();
if (onCameraChanged) onCameraChanged(); // 取景后 → 底图按新视锥重算覆盖 if (onCameraChanged) onCameraChanged(); // 相机/数据变了 → 底图按新视锥重算覆盖
} }
void VtkSceneView::renderIncremental() { void VtkSceneView::renderIncremental() {

View File

@ -56,7 +56,7 @@ public:
void applyCameraView(geopro::controller::ViewDir dir) override; void applyCameraView(geopro::controller::ViewDir dir) override;
void zoom(double factor) override; void zoom(double factor) override;
void fitView() override; void fitView() override;
void render(bool is2D) override; void render(bool is2D, bool resetCamera = true) override;
void renderIncremental() override; void renderIncremental() override;
// ── P3 切片交互:暴露当前体素 image含 VE 烤入的 origin/spacing供切片附着 ── // ── P3 切片交互:暴露当前体素 image含 VE 烤入的 origin/spacing供切片附着 ──

View File

@ -761,15 +761,24 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
} }
}); });
// 三维体段右键删除切片→deleteSlice / 异常→deleteAnomaly删后刷新树。 // 三维体段右键删除切片→deleteSlice / 异常→deleteAnomaly删后刷新树。
// 异常删除须同时 refreshAnomalies重载异常 actor——否则列表行没了但场景里异常仍渲染技术债已修
QObject::connect(analysisTab, &geopro::app::CategoryAnalysisTab::deleteDatasetRequested, &window, QObject::connect(analysisTab, &geopro::app::CategoryAnalysisTab::deleteDatasetRequested, &window,
[scene3dRepo, refreshAnalysis](const QString& dsId, const QString& ddCode) { [scene3dRepo, refreshAnalysis, refreshAnomalies](const QString& dsId,
const QString& ddCode) {
const std::string id = dsId.toStdString(); const std::string id = dsId.toStdString();
auto ok = [refreshAnalysis]() { refreshAnalysis(); }; if (ddCode == QStringLiteral("dd_slice")) {
auto err = [](const std::string&) {}; scene3dRepo->deleteSlice(
if (ddCode == QStringLiteral("dd_slice")) id, [refreshAnalysis]() { refreshAnalysis(); },
scene3dRepo->deleteSlice(id, ok, err); [](const std::string&) {});
else if (ddCode == QStringLiteral("dd_anomaly")) } else if (ddCode == QStringLiteral("dd_anomaly")) {
scene3dRepo->deleteAnomaly(id, ok, err); scene3dRepo->deleteAnomaly(
id,
[refreshAnalysis, refreshAnomalies]() {
refreshAnalysis(); // 刷三维体段列表(异常行消失)
refreshAnomalies(); // 重载异常 actor场景同步移除
},
[](const std::string&) {});
}
}); });
// O点位置/字体、旧栏「生成三维体」「勾选→渲染」接线均已退役——分别由 analysisTab 的 // O点位置/字体、旧栏「生成三维体」「勾选→渲染」接线均已退役——分别由 analysisTab 的
// generateVolumeRequested / checkedDatasetsChanged 接管。) // generateVolumeRequested / checkedDatasetsChanged 接管。)
@ -943,11 +952,15 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
// 垂直夸张:地形须与剖面用同一 VE 才对齐(都按真实高程×VE)。单一来源 kVerticalExaggeration 下发 // 垂直夸张:地形须与剖面用同一 VE 才对齐(都按真实高程×VE)。单一来源 kVerticalExaggeration 下发
// 控制器(上方)/底图;运行时 VE 改由坐标轴设置抽屉「应用」下发(旧三维数据集栏滑块已退役)。 // 控制器(上方)/底图;运行时 VE 改由坐标轴设置抽屉「应用」下发(旧三维数据集栏滑块已退役)。
basemap->setVerticalExaggeration(kVerticalExaggeration); basemap->setVerticalExaggeration(kVerticalExaggeration);
// 坐标轴设置抽屉「应用」:仅轴显示开关 + 单位 + per-axis 范围 → 走 setAxesConfig已改增量重建 // 坐标轴设置抽屉「应用」:一次性下发 轴显示/单位/范围(增量重建,不重绘数据)+ 放大系数(VE)。
// 不再清场景重绘数据)。放大系数=垂直夸张改由滑块独立信号下发(见下)。 // VE 改几何须重建但走保留相机的重建setVerticalExaggeration 内 preserveCamera→ 当前视角直接
// 重绘,不再先跳远视角;底图同步 VE。仅在 VE 实际变化时下发,避免无谓重建。
auto lastVE = std::make_shared<double>(kVerticalExaggeration);
QObject::connect(axesPanel, &geopro::app::AxesSettingsPanel::applied, &window, QObject::connect(axesPanel, &geopro::app::AxesSettingsPanel::applied, &window,
[axesPanel, sceneCtrl](geopro::app::AxisRange x, geopro::app::AxisRange y, [axesPanel, sceneCtrl, basemap, lastVE](geopro::app::AxisRange x,
geopro::app::AxisRange z, int unitIdx) { geopro::app::AxisRange y,
geopro::app::AxisRange z, int unitIdx,
double scale) {
const bool anyShow = x.show || y.show || z.show; const bool anyShow = x.show || y.show || z.show;
// 每轴:可见性=显示开关;自定义范围=面板 min/max按当前单位真正改刻度。 // 每轴:可见性=显示开关;自定义范围=面板 min/max按当前单位真正改刻度。
auto cfg = [](const geopro::app::AxisRange& a) { auto cfg = [](const geopro::app::AxisRange& a) {
@ -958,15 +971,13 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
unitIdx == 1 ? geopro::controller::AxesUnit::Feet unitIdx == 1 ? geopro::controller::AxesUnit::Feet
: geopro::controller::AxesUnit::Meter, : geopro::controller::AxesUnit::Meter,
cfg(x), cfg(y), cfg(z)); cfg(x), cfg(y), cfg(z));
if (scale > 0 && scale != *lastVE) { // 放大系数变化 → 保留相机重建 + 底图同步
*lastVE = scale;
sceneCtrl->setVerticalExaggeration(scale);
basemap->setVerticalExaggeration(scale);
}
axesPanel->hide(); axesPanel->hide();
}); });
// 放大系数滑块(=垂直夸张):松手即下发控制器 + 底图VE 改几何须一次重建,但不在拖动中连续重建)。
QObject::connect(axesPanel, &geopro::app::AxesSettingsPanel::verticalExaggerationChanged, &window,
[sceneCtrl, basemap](double ve) {
if (ve <= 0) return;
sceneCtrl->setVerticalExaggeration(ve);
basemap->setVerticalExaggeration(ve);
});
// ── 中央“空状态”引导浮层:未接入真实 sections 时,引导首次使用者从左侧入手。── // ── 中央“空状态”引导浮层:未接入真实 sections 时,引导首次使用者从左侧入手。──
// 透明背景 + 鼠标穿透(不挡 QVTK 交互CenterOverlay 随视口尺寸保持居中; // 透明背景 + 鼠标穿透(不挡 QVTK 交互CenterOverlay 随视口尺寸保持居中;

View File

@ -81,16 +81,13 @@ public:
p->fillRect(QRect(opt.rect.left(), opt.rect.top(), 2, opt.rect.height()), p->fillRect(QRect(opt.rect.left(), opt.rect.top(), 2, opt.rect.height()),
geopro::app::tokenColor("accent/primary")); geopro::app::tokenColor("accent/primary"));
// 可勾选项:左侧画复选框(用当前 style 的指示器),文本整体右移。 // 可勾选项:左侧画复选框(用当前 style 的指示器),文本整体右移。容器节点(项目/GS/TM)不画
// 容器节点(项目/GS/TM)虽不画复选框,也保留同宽复选框列——否则其子级(带复选框)相对容器的 // 复选框、名称紧跟展开图标(左留白小,与对象树容器一致;勿为对齐子级而预留空复选框列,
// 视觉缩进 = 树级缩进 + 复选框列宽,比「子带框→孙带框」的缩进大一截(用户实测 #6三维体段 // 否则容器名与展开图标间出现大段空白,见用户 #2 反馈)。
// 体相对 TM 缩进过大)。保留同宽列后各级缩进只差一个树级,与对象树一致。
const int box = 16;
const int checkColPad = 12 + box + 8; // 复选框列总宽(复选框 + 两侧留白)
int textLeftPad = 6; int textLeftPad = 6;
const bool checkable = (idx.flags() & Qt::ItemIsUserCheckable); const bool checkable = (idx.flags() & Qt::ItemIsUserCheckable);
const bool isContainer = idx.data(kDsDdCodeRole).toString() == QStringLiteral("container");
if (checkable) { if (checkable) {
const int box = 16;
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()); const auto cs = static_cast<Qt::CheckState>(idx.data(Qt::CheckStateRole).toInt());
QStyleOptionViewItem o(opt); QStyleOptionViewItem o(opt);
@ -100,9 +97,7 @@ public:
const QWidget* w = opt.widget; const QWidget* w = opt.widget;
QStyle* st = w ? w->style() : QApplication::style(); QStyle* st = w ? w->style() : QApplication::style();
st->drawPrimitive(QStyle::PE_IndicatorItemViewItemCheck, &o, p, w); st->drawPrimitive(QStyle::PE_IndicatorItemViewItemCheck, &o, p, w);
textLeftPad = checkColPad; // 复选框右侧留白后再放文本 textLeftPad = 12 + box + 8; // 复选框右侧留白后再放文本
} else if (isContainer) {
textLeftPad = checkColPad; // 容器保留同宽列(不画框),使子级缩进与对象树一致
} }
QString title = disp, meta; QString title = disp, meta;

View File

@ -27,9 +27,9 @@ DateRangeEdit::DateRangeEdit(QWidget* parent) : QWidget(parent) {
btn_, QStringLiteral( btn_, QStringLiteral(
"QToolButton{background-color:{{bg/panel}};color:{{text/primary}};" "QToolButton{background-color:{{bg/panel}};color:{{text/primary}};"
"border:1px solid {{border/default}};border-radius:4px;" "border:1px solid {{border/default}};border-radius:4px;"
"padding:6px 24px 6px 8px;min-height:16px;text-align:left;" "padding:6px 22px 6px 8px;min-height:16px;text-align:left;"
"background-image:url(:/icons/chevron-down.svg);background-repeat:no-repeat;" "background-image:url(:/icons/chevron-down.svg);background-repeat:no-repeat;"
"background-position:right 8px center;}" "background-origin:padding;background-position:right center;}"
"QToolButton:hover{border-color:{{border/strong}};}")); "QToolButton:hover{border-color:{{border/strong}};}"));
connect(btn_, &QToolButton::clicked, this, &DateRangeEdit::openPopup); connect(btn_, &QToolButton::clicked, this, &DateRangeEdit::openPopup);
lay->addWidget(btn_); lay->addWidget(btn_);

View File

@ -73,8 +73,9 @@ public:
// 适配全览P2ResetCamera 并提交渲染。 // 适配全览P2ResetCamera 并提交渲染。
virtual void fitView() = 0; virtual void fitView() = 0;
// 应用相机预设2D 俯视 / 3D 自由)并提交渲染(全量重建用,会 ResetCamera // 应用相机预设2D 俯视 / 3D 自由并提交渲染全量重建用。resetCamera=true 时 ResetCamera 取景到
virtual void render(bool is2D) = 0; // 数据false 时保留当前相机(如改放大系数全量重建但要原地重绘,不跳远视角)。
virtual void render(bool is2D, bool resetCamera = true) = 0;
// 增量提交渲染:重建坐标轴并刷新,但不动相机(勾选/取消单个 ds 时视角不跳)。 // 增量提交渲染:重建坐标轴并刷新,但不动相机(勾选/取消单个 ds 时视角不跳)。
virtual void renderIncremental() = 0; virtual void renderIncremental() = 0;
}; };

View File

@ -221,7 +221,12 @@ void VtkSceneController::setLayer(SceneLayer layer, bool on) {
void VtkSceneController::setVerticalExaggeration(double ve) { void VtkSceneController::setVerticalExaggeration(double ve) {
verticalExaggeration_ = ve; verticalExaggeration_ = ve;
// VE 烤进帘面 SetScale / 体素 image / 地形几何,须全量重建;但保留当前相机 → 原地按新夸张重绘,
// 不先跳远视角再回(用户反馈)。重建中 fitOnArrival_ 也置 false见 rebuildInternal
// 异步到场数据经 renderIncremental 在当前相机下显示。
preserveCameraOnRebuild_ = true;
rebuildInternal(); rebuildInternal();
preserveCameraOnRebuild_ = false;
} }
void VtkSceneController::rebuild() { rebuildInternal(); } void VtkSceneController::rebuild() { rebuildInternal(); }
@ -292,7 +297,7 @@ void VtkSceneController::rebuildInternal() {
// 坐标轴设置在 clear 后下发render 末尾据当前场景包围盒重建坐标轴 prop。 // 坐标轴设置在 clear 后下发render 末尾据当前场景包围盒重建坐标轴 prop。
view_.setAxes(axesMode_, axesUnit_, kAxesFontSize); view_.setAxes(axesMode_, axesUnit_, kAxesFontSize);
view_.setAxesRanges(axisX_, axisY_, axisZ_); view_.setAxesRanges(axisX_, axisY_, axisZ_);
fitOnArrival_ = true; // 全量重建:到场数据自动取景 fitOnArrival_ = !preserveCameraOnRebuild_; // 保留相机重建(改VE):到场数据不自动取景,留当前视角
// 坏 dsIdloadGrid/loadColorScale 抛异常)= best-effort 跳过emit loadFailed 但不中断。 // 坏 dsIdloadGrid/loadColorScale 抛异常)= best-effort 跳过emit loadFailed 但不中断。
try { try {
@ -322,7 +327,8 @@ void VtkSceneController::rebuildInternal() {
emit loadFailed(QString::fromStdString(e.what())); emit loadFailed(QString::fromStdString(e.what()));
} }
view_.render(is2D); // 设背景/相机预设/坐标轴 + ResetCamera数据到场再由 onDatasetArrived 取景) // 保留相机重建(改VE):不 ResetCamera原地按新夸张重绘。
view_.render(is2D, /*resetCamera=*/!preserveCameraOnRebuild_);
} }
} // namespace geopro::controller } // namespace geopro::controller

View File

@ -94,6 +94,7 @@ private:
bool showVoxel_ = false; bool showVoxel_ = false;
bool showTerrain_ = false; bool showTerrain_ = false;
double verticalExaggeration_ = 1.0; double verticalExaggeration_ = 1.0;
bool preserveCameraOnRebuild_ = false; // 改放大系数等:全量重建但保留当前相机(不跳远视角)
// 坐标轴设置P2默认标准 + 米;字号固定 12字体设置待 1.0 确认)。 // 坐标轴设置P2默认标准 + 米;字号固定 12字体设置待 1.0 确认)。
AxesMode axesMode_ = AxesMode::Standard; AxesMode axesMode_ = AxesMode::Standard;