geopro/src/app/main.cpp

2008 lines
123 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// M1 工作台(视图重构 Task B正确产品模型。
// - 左上 对象显示栏GS→TM(测线,复选框)。勾选测线 → 在中央显示其 dd_section可多条共存。
// - 左下 数据真实显示栏:单击测线 → 列其采集批次(数据集,tab 数据/文件)。单击采集批次 → 数据详情+异常+属性。
// - 中央「二维地图 / 三维视图」:两个互斥视图(内容不同,不是同一物体换相机)。
// 二维地图 = 对每个勾选数据集 buildSurveyLinelat/lon 红线俯视z=0+ applyTop2D浅底背景
// 三维视图 = 勾选测线的 buildCurtain竖直断面墙actor SetScale(1,1,3) 纵向夸张 + applyFree3D白底
// 三维左上「视图详情」浮层(对齐原型):图层勾选 帘面 / 体素(dd_voxel,散点经 EPSG:4547 配准 IDW)
// / 切片(dd_slice,vtkImagePlaneWidget 在体素 image 上交互拖切面) / 地形(DEM 高程面 + 影像纹理)。
// 切视图 / 勾选变化 / 图层变化 → 重建对应内容。
// - 下方「数据详情」:独立 QVTK 小视图 + 工具条「原数据 / 网格数据」切换 +「显示异常」开关(对齐原型)。
// 单击某 DS → 显示该数据集:
// 网格数据 = #18 banded 等值面+等值线(两 actor SetScale(1,1.5,1) 纵向夸张)。
// 原数据 = #17 彩色散点buildScatterx=距离/y=深度,按散点自带色阶上色)。
// 显示异常 = 在上图叠加异常圈定buildAnomaliesdashed 折线,同纵向夸张对齐)。
// 两者皆平躺俯视正交 + 属性。
// - 右 属性:选中数据集属性文本。
// 世界系:启动 loadGrid("grid1") 取一次,用其 lat/lon 中位/均值作 GeoLocalFrame全项目共享保证多视图配准
#include <initializer_list>
#include <memory>
#include <string>
#include <typeinfo>
#include <vector>
#include <QActionGroup>
#include <QApplication>
#include <QCheckBox>
#include <QColor>
#include <QDialog>
#include <QEasingCurve>
#include <QEvent>
#include <QFile>
#include <QButtonGroup>
#include <QCheckBox>
#include <QComboBox>
#include <QFrame>
#include <QHBoxLayout>
#include <QPushButton>
#include <QSlider>
#include <QGraphicsOpacityEffect>
#include <QDate>
#include <QAction>
#include <QCursor>
#include <QDir>
#include <QFileDialog>
#include <QInputDialog>
#include <QLabel>
#include <QLineEdit>
#include <QListWidget>
#include <QListWidgetItem>
#include <QJsonObject>
#include <QMenu>
#include <QMessageBox>
#include <QPoint>
#include <QSet>
#include <QToolButton>
#include <QKeySequence>
#include <QProcess>
#include <QSettings>
#include <QShortcut>
#include <QPropertyAnimation>
#include <QVariantAnimation>
#include <QStringList>
#include <QTabWidget>
#include <QMainWindow>
#include <QStatusBar>
#include <QStyle>
#include <QSurfaceFormat>
#include <QSignalBlocker>
#include <QTimer>
#include <QToolBar>
#include <QTreeWidget>
#include <QTreeWidgetItem>
#include <QTreeWidgetItemIterator>
#include <QVBoxLayout>
#include <QWidget>
#include <QtWebEngineQuick/QtWebEngineQuick>
#include <DockAreaTitleBar.h>
#include <DockAreaWidget.h>
#include <DockManager.h>
#include <DockWidget.h>
#include "model/ColorScale.hpp"
#include "model/Field.hpp"
#include "repo/LocalSampleRepository.hpp"
#include "ApiClient.hpp"
#include "AuthService.hpp"
#include "DatasetDimension.hpp"
#include "DatasetCategory.hpp"
#include "Credential.hpp"
#include "Glyphs.hpp"
#include "Logging.hpp"
#include "PanelHeader.hpp"
#include "Theme.hpp"
#include "AnomalySaveDialog.hpp"
#include "AnomalyPropertiesDialog.hpp"
#include "ColorScaleConfigDialog.hpp"
#include "SettingsDialog.hpp"
#include "SlicePropertiesDialog.hpp"
#include "SliceExport.hpp"
#include "ToastOverlay.hpp"
#include "TopBar.hpp"
#include "VolumeParamsDialog.hpp"
#include "VolumePropertiesDialog.hpp"
#include "interact/AnomalyDrawTool.hpp"
#include "ProjectListDialog.hpp"
#include "ObjectFormDialog.hpp"
#include "ImportDatasetDialog.hpp"
#include "WorkbenchNavController.hpp"
#include "VtkSceneController.hpp"
#include "VtkSceneView.hpp"
#include "api/NavRequest.hpp"
#include "api/NavLoads.hpp"
#include "DatasetDetailController.hpp"
#include "panels/chart/ErtInversionStrategy.hpp"
#include "panels/chart/MeasurementStrategy.hpp"
#include "panels/chart/GrMeasurementStrategy.hpp"
#include "panels/chart/TrajectoryStrategy.hpp"
#include "panels/chart/GridStrategy.hpp"
#include "api/ApiProjectRepository.hpp"
#include "api/ApiDatasetRepository.hpp"
#include "api/ApiColorTemplateRepository.hpp"
#include "api/ApiDatasetCommandRepository.hpp"
#include "api/Api3dRepository.hpp"
#include "panels/ObjectTreePanel.hpp"
#include "login/LoginWindow.hpp"
#include "panels/DatasetListPanel.hpp"
#include "panels/DatasetDetailPanel.hpp"
#include "panels/DynamicFormView.hpp"
#include "panels/ObjectAttrPanel.hpp"
#include "panels/DatasetAttrPanel.hpp"
#include "panels/ObjectExceptionPanel.hpp"
#include "TileBasemap.hpp"
#include "panels/columns/ColumnDrawer.hpp"
#include "panels/columns/CategoryAnalysisTab.hpp"
#include "panels/columns/Column3DDataset.hpp"
#include "panels/columns/Column2DDataset.hpp"
#include "panels/columns/Column3DAnalysis.hpp"
#include "CameraPreset.hpp"
#include "ColorLutBuilder.hpp"
#include "Scene.hpp"
#include "VoxelFromScatters.hpp"
#include "interact/InteractionManager.hpp"
#include "interact/SlicePlaneMath.hpp"
#include "actors/AnomalyActor.hpp"
#include "actors/CurtainActor.hpp"
#include "actors/ElectrodeActor.hpp"
#include "actors/GridContourActor.hpp"
#include "actors/MapLineActor.hpp"
#include "actors/ScatterActor.hpp"
#include "actors/TerrainActor.hpp"
#include "geo/CrsTransform.hpp"
#include "geo/GeoLocalFrame.hpp"
#include <algorithm>
#include <exception>
#include <memory>
#include <set>
#include <vector>
#include <QVTKOpenGLStereoWidget.h>
#include <vtkActor.h>
#include <vtkCamera.h>
#include <vtkCameraInterpolator.h>
#include <vtkGenericOpenGLRenderWindow.h>
#include <vtkLookupTable.h>
#include <vtkProperty.h>
#include <vtkImageData.h>
#include <vtkDataArray.h>
#include <vtkPointData.h>
#include <vtkRenderWindowInteractor.h>
#include <vtkRenderer.h>
#include <vtkSmartPointer.h>
namespace {
// 居中浮层定位器:监视 host中央 QVTK尺寸/显示变化,把 overlay 浮层
// (与 host 同父的兄弟控件)始终摆到 host 区域正中。用于中央“空状态”引导层。
// 仅外观,无业务逻辑;无信号槽故不需 Q_OBJECT/moc。
class CenterOverlay : public QObject {
public:
CenterOverlay(QWidget* overlay, QWidget* host)
: QObject(host), overlay_(overlay), host_(host)
{
host_->installEventFilter(this);
}
void reposition()
{
overlay_->adjustSize();
const QSize h = host_->size();
// 浮层尺寸钳到不超过 hosthost 比内容小(窗口/抽屉收窄)时不再溢出视图。
QSize o = overlay_->size();
o.setWidth(std::min(o.width(), h.width()));
o.setHeight(std::min(o.height(), h.height()));
overlay_->resize(o);
// 偏移取非负:保证浮层矩形始终 ⊆ host 矩形(不跑到 host 外)。
const int dx = std::max(0, (h.width() - o.width()) / 2);
const int dy = std::max(0, (h.height() - o.height()) / 2);
overlay_->move(host_->x() + dx, host_->y() + dy);
overlay_->raise();
}
protected:
bool eventFilter(QObject* obj, QEvent* e) override
{
if (obj == host_ && (e->type() == QEvent::Resize || e->type() == QEvent::Show))
reposition();
return QObject::eventFilter(obj, e);
}
private:
QWidget* overlay_;
QWidget* host_;
};
// 取 vector 中位数(用于由测线 lat/lon 推世界系原点)。空则返回 0。
double median(std::vector<double> v)
{
if (v.empty()) return 0.0;
std::sort(v.begin(), v.end());
const size_t n = v.size();
return n % 2 ? v[n / 2] : 0.5 * (v[n / 2 - 1] + v[n / 2]);
}
// 纵向夸张倍数Z 基准统一M-3全项目共用同一倍数使 帘面(z) / 体素 / 切片 /
// 数据详情剖面(y) / 地形(relief) 的纵向比例一致——避免「剖面×1.5、帘面×3」不一致。
// 单一可调常量:要整体调纵向观感改这一处即可。
constexpr double kVerticalExaggeration = 1.0;
// 项目 CRS(实证确定,STATUS §4):散点 projX/Y→经纬→GeoLocalFrame 配准体素到世界系。
constexpr const char* kProjectCrs = "EPSG:4547"; // CGCS2000 / 3-degree GK CM 114E
constexpr const char* kWgs84 = "EPSG:4326";
// 在给定 QMainWindow 上构建 M1 工作台。
// repo 生命周期须覆盖到事件循环结束(由调用方保证)。
void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& repo,
geopro::data::IAsyncProjectRepository& projectRepo,
geopro::data::IAsyncDatasetRepository& datasetRepo,
geopro::data::IColorTemplateRepository& colorTplRepo,
geopro::data::IDatasetCommandRepository& cmdRepo,
geopro::controller::WorkbenchNavController& nav,
geopro::controller::DatasetDetailController& detailCtrl)
{
// ── 世界系:启动取一次 grid1 的 lat/lon用中位数作 GeoLocalFrame 原点 ──
// 全项目共享shared_ptr 持有):所有帘面用同一 frame 投影,保证多条测线空间配准。
const auto baseGrid = repo.loadGrid("grid1");
const double lat0 = median(baseGrid.lat);
const double lon0 = median(baseGrid.lon);
auto frame = std::make_shared<geopro::core::GeoLocalFrame>(lat0, lon0);
// 测线地表高程基准(地形 z rebase 用,使地形落在测线附近而非按绝对高程浮空)。
const double refElev = baseGrid.elevation.empty() ? 0.0 : median(baseGrid.elevation);
// ── 中央 QVTK + Scene竖直帘面场景─────────────────────────────────
// Scene 非 QObject堆分配用 widget 销毁信号清理widget 随 window 销毁)。
auto* scene = new geopro::render::Scene();
auto* vtkWidget = new QVTKOpenGLStereoWidget();
vtkNew<vtkGenericOpenGLRenderWindow> renderWindow;
vtkWidget->setRenderWindow(renderWindow);
renderWindow->AddRenderer(scene->renderer());
vtkGenericOpenGLRenderWindow* renderWindowPtr = renderWindow.Get();
// 中央渲染编排VtkSceneController + VtkSceneView取代旧 rebuildCentral lambda 与裸 show* 标志)。
// 3D 场景仓储用 Api3dRepository真实后端loadSection 走真实 ERT 反演端点,委托 datasetRepo
// 视图VtkSceneView非 QObject、控制器/3D 仓储亦然:随 scene 一并在 vtkWidget 销毁时清理。
auto* scene3dRepo = new geopro::data::Api3dRepository(datasetRepo, frame);
auto* sceneView = new geopro::app::VtkSceneView(*scene, renderWindowPtr,
frame, refElev);
auto* sceneCtrl = new geopro::controller::VtkSceneController(repo, *scene3dRepo, *sceneView,
vtkWidget);
sceneCtrl->setVerticalExaggeration(kVerticalExaggeration);
// ── P3 切片交互编排InteractionManager─────────────────────────────────
// interactor 由 QVTK 在 setRenderWindow 后提供renderWindow->GetInteractor())。
// 安装自定义拾取样式 + 持活动切片。仅三维 + 有体素可用;切到二维 closeAll。
auto* interactionMgr = new geopro::render::interact::InteractionManager(
renderWindowPtr->GetInteractor(), renderWindowPtr, scene->renderer());
// 异常圈定工具(#4b在切片平面上画多边形高优先级观察者绘制期独占输入
auto* anomalyDrawTool = new geopro::render::interact::AnomalyDrawTool(
renderWindowPtr->GetInteractor(), scene->renderer());
// sceneView->onVolumeChanged 在三栏接线处设置(把体素 image 推给 InteractionManager见下
// 非 QObject 堆对象统一在此清理,按构造逆序(持 interactor 观察者者先析构,防悬挂崩溃):
QObject::connect(vtkWidget, &QObject::destroyed,
[scene, scene3dRepo, sceneView, interactionMgr, anomalyDrawTool]() {
delete anomalyDrawTool;
delete interactionMgr;
delete sceneView;
delete scene3dRepo;
delete scene;
});
// PROJ 可用性探测(体素/地形/切片层都需配准):三栏重构后浮层勾选已移除,
// 仅保留探测以便将来在三栏里据此禁用相关项;本期结果暂未消费。
bool crsAvailable = false;
try {
geopro::core::CrsTransform probe(kProjectCrs, kWgs84);
crsAvailable = true;
} catch (const std::exception&) {
crsAvailable = false;
}
(void)crsAvailable;
// 停靠系统配置(必须在 CDockManager 构造前设置):对齐原型——面板固定、
// 标题栏不显示「关闭 / 浮动 / 标签菜单」等子窗口操作按钮,并关闭自动隐藏(钉住)。
ads::CDockManager::setConfigFlags(ads::CDockManager::DefaultOpaqueConfig);
ads::CDockManager::setConfigFlag(ads::CDockManager::DockAreaHasCloseButton, false);
ads::CDockManager::setConfigFlag(ads::CDockManager::DockAreaHasUndockButton, false);
ads::CDockManager::setConfigFlag(ads::CDockManager::DockAreaHasTabsMenuButton, false);
ads::CDockManager::setConfigFlag(ads::CDockManager::ActiveTabHasCloseButton, false);
ads::CDockManager::setConfigFlag(ads::CDockManager::AlwaysShowTabs, true); // 单面板也显示标题头
ads::CDockManager::setConfigFlag(ads::CDockManager::FocusHighlighting, false);
ads::CDockManager::setAutoHideConfigFlags(ads::CDockManager::AutoHideFlags()); // 禁用自动隐藏
auto* dockManager = new ads::CDockManager(&window);
window.setCentralWidget(dockManager);
// 覆盖 ADS 自带分隔条样式:其 default.css 用 palette(dark) 把面板间分隔画成深灰粗线,
// 且设在管理器自身、选择器更具体(优先级高于全局主题)。在其后追加同选择器、按主题着色的极淡分隔覆盖。
// 捕获 ADS 基样式一次(避免每次切换重复追加而无限增长),切主题时用 base + 重新着色的覆盖。
const QString dockBaseQss = dockManager->styleSheet();
auto applyDockSplitter = [dockManager, dockBaseQss]() {
dockManager->setStyleSheet(
dockBaseQss +
geopro::app::fillTokens(QStringLiteral(
"ads--CDockContainerWidget ads--CDockSplitter::handle { background: {{divider}}; }"
"ads--CDockContainerWidget ads--CDockSplitter::handle:hover { background: {{accent/primary}}; }")));
};
applyDockSplitter();
QObject::connect(&geopro::app::ThemeManager::instance(), &geopro::app::ThemeManager::changed,
dockManager, [applyDockSplitter]() { applyDockSplitter(); });
// 面板包装:内容顶部加自绘表头(图标+标题+操作按钮ADS 自带标题栏随后隐藏,
// 从而让面板表头与原型完全一致。返回 [表头 + 内容] 容器供 setWidget。
auto wrapWithHeader = [](geopro::app::Glyph icon, const QString& title, QWidget* content,
const QVector<geopro::app::HeaderAction>& actions = {}) {
auto* box = new QWidget();
auto* v = new QVBoxLayout(box);
v->setContentsMargins(0, 0, 0, 0);
v->setSpacing(0);
v->addWidget(geopro::app::buildPanelHeader(icon, title, actions));
v->addWidget(content, 1);
return box;
};
// 中央容器顶部「VTK视图」表头 + 下方 [左三栏抽屉 | 右 QVTK 画布]。
auto* centerWidget = new QWidget();
auto* centerLayout = new QVBoxLayout(centerWidget);
centerLayout->setContentsMargins(0, 0, 0, 0);
centerLayout->setSpacing(0);
// VTK视图面板表头Task 7图标 + 标题「VTK视图」+ 全屏操作按钮(全屏 connect 见 Task 8
auto* viewHeader = geopro::app::buildPanelHeader(
geopro::app::Glyph::Map, QStringLiteral("VTK视图"),
{{geopro::app::Glyph::Fullscreen, QStringLiteral("全屏")}});
// 左侧内嵌三栏抽屉(自带折叠按钮)+ 右侧 GL 画布,水平并列(非 GL 覆盖层,避免 z-order/圆角伪影)。
auto* drawer = new geopro::app::ColumnDrawer(centerWidget);
auto* viewRow = new QHBoxLayout();
viewRow->setContentsMargins(0, 0, 0, 0);
viewRow->setSpacing(0);
viewRow->addWidget(drawer); // 左侧抽屉(自带折叠按钮)
viewRow->addWidget(vtkWidget, 1); // 右侧 GL 画布
centerLayout->addWidget(viewHeader);
centerLayout->addLayout(viewRow, 1);
// 3b三维分析栏勾选的已保存切片(dd_slice) id 集合 + 调和函数。
// syncSlices按"当前活动体 dsId"调和 InteractionManager 上显示的已保存切片——
// 勾选且父体=当前体 → 显示(按 spec 还原);否则移除。须在 onVolumeChanged(体到场/移除)末尾
// 及分析栏勾选变化时调用。注setVolumeImage 会 closeAll故体变更后由本函数重建。
auto checkedSliceIds = std::make_shared<std::set<std::string>>();
auto syncSlices = [interactionMgr, sceneView, scene3dRepo, checkedSliceIds]() {
const std::string curVol = sceneView->currentVolumeDsId();
// 移除:已显示但不再需要(未勾选 / 父体非当前体 / 无活动体)。
for (const std::string& shownId : interactionMgr->shownSavedSliceIds()) {
geopro::data::I3dSceneRepository::SliceSpec sp;
const bool wanted = !curVol.empty() && checkedSliceIds->count(shownId) > 0 &&
scene3dRepo->sliceSpec(shownId, sp) && sp.volumeDsId == curVol;
if (!wanted) interactionMgr->hideSavedSlice(shownId);
}
// 添加:勾选 + 父体=当前体 + 未显示showSavedSlice 内部去重)。按精确三点几何还原。
if (!curVol.empty()) {
for (const std::string& id : *checkedSliceIds) {
geopro::data::I3dSceneRepository::SliceSpec sp;
if (scene3dRepo->sliceSpec(id, sp) && sp.volumeDsId == curVol)
interactionMgr->showSavedSlice(id, sp.axis, sp.origin, sp.point1, sp.point2);
}
}
};
// 异常刷新渲染 + 填充三维分析栏异常列表(#4b/4c按显示过滤档位决定异常集合。
// 0 全部显示=所有异常1 随GS/2 随数据集=当前活动体的异常3 全部隐藏=不渲染、列表空。
// 随GS 暂同随数据集,无 GS 分组数据。loadAnomalyTree 空 key→全部非空→该体。mock 同步回调。)
auto refreshAnomalies = [sceneView, scene3dRepo, drawer, renderWindowPtr]() {
sceneView->clearAnomalies();
auto* ca = drawer->colAnalysis();
const int mode = ca->anomalyFilterMode();
if (mode == 3) { // 全部隐藏
ca->setAnomalies({});
renderWindowPtr->Render();
return;
}
std::string key; // 空 = 全部
if (mode != 0) { // 随GS/随数据集 → 当前活动体
key = sceneView->currentVolumeDsId();
if (key.empty()) { // 无活动体 → 空
ca->setAnomalies({});
renderWindowPtr->Render();
return;
}
}
std::vector<geopro::core::Anomaly> set;
scene3dRepo->loadAnomalyTree(
key,
[&set](geopro::data::I3dSceneRepository::AnomalyTree tree) {
for (auto& b : tree.bodies)
for (auto& a : b.members) set.push_back(a);
for (auto& a : tree.loose) set.push_back(a);
},
[](const std::string&) {});
for (const auto& a : set) sceneView->addAnomaly(a);
ca->setAnomalies(set); // 填充列表(每条显隐勾选默认显示)
renderWindowPtr->Render(); // 必须重绘clear+addAnomaly 改了 prop否则 VTK 不刷新(与列表脱节)
};
// 体素变化(重建/清场)后把体素 image 推给 InteractionManager切片基底并调和已保存切片 + 异常。
sceneView->onVolumeChanged = [interactionMgr, sceneView, syncSlices, refreshAnomalies]() {
if (sceneView->hasVolume())
interactionMgr->setVolumeImage(sceneView->currentVolumeImage(),
sceneView->currentColorScale(), sceneView->currentVmin(),
sceneView->currentVmax());
else
interactionMgr->setVolumeImage(nullptr, sceneView->currentColorScale(), 0.0, 0.0);
syncSlices(); // 体到场/移除后重建当前体下已勾选的切片
refreshAnomalies(); // 同步重载异常 actor + 刷新异常列表
};
// ── 三栏抽屉信号 → 控制器/交互Task 7 接线)──────────────────────────────
auto* c3 = drawer->col3D();
// 三维分析栏 = 后端 Analysis 行(dd_slice) + 客户端创建的三维体mock。生成的三维体是"分析产物"
// (设计 §2.1:三维分析栏按 对象/三维体模型/切片 三级树),归三维分析栏(非数据集栏)。
// 后端列表每次勾选测线全量覆盖,故客户端体单独维护、刷新时合并注入(不被冲掉)。
// 三维分析数据源 = 最近对象树勾选拉取的 ds + 客户端三维体(mock) + 已保存切片;
// splitByCategory 后注入 5 段(电阻率/视电阻率/瞬变/三维体/切片);二维(足迹)经 dim2D 仍走 col2D。
auto lastSourceRows = std::make_shared<std::vector<geopro::data::DsRow>>();
auto refreshAnalysis = [drawer, scene3dRepo, lastSourceRows]() {
std::vector<geopro::data::DsRow> rows = *lastSourceRows;
for (auto& vr : scene3dRepo->volumeRows()) rows.push_back(std::move(vr)); // 客户端三维体
for (auto& sr : scene3dRepo->sliceRows()) rows.push_back(std::move(sr)); // 已保存切片(挂父体下)
drawer->analysisTab()->setBuckets(geopro::app::splitByCategory(rows));
drawer->col2D()->setDatasets(geopro::app::splitByDimension(rows).dim2D);
};
// 渲染勾选聚合:三维数据集栏(剖面→帘面)+ 三维分析栏(三维体/切片→体素/切片)两套勾选并集
// 后下发控制器setCheckedDatasets 全量 diff须并集否则一栏勾选会清掉另一栏的图元
auto checkedProfiles = std::make_shared<QStringList>();
auto checkedAnalysis = std::make_shared<QStringList>();
auto pushChecked = [sceneCtrl, checkedProfiles, checkedAnalysis]() {
QStringList all = *checkedProfiles;
all += *checkedAnalysis;
sceneCtrl->setCheckedDatasets(all);
};
// ── VTK 视图切片右键菜单(设计 §2.3)──────────────────────────────────────
// 右键命中切片 → InteractionManager 选中并回调本 lambda → 弹菜单QCursor 处定位)。
// 保存=按"未保存/已保存"分派(新建+链接+自动勾选 / 覆盖位姿)导出统一为「导出▸图片·dat」
// 正视/翻转/关闭=接现有交互(关闭已保存切片→onSliceClosed 取消列表勾选);创建异常=占位(#4)。
interactionMgr->onSliceContextMenuRequested =
[&window, interactionMgr, sceneView, scene3dRepo, refreshAnalysis, refreshAnomalies, drawer,
anomalyDrawTool, renderWindowPtr]() {
QMenu menu(&window);
QAction* aAnomaly = menu.addAction(QStringLiteral("创建异常"));
QAction* aSave = menu.addAction(QStringLiteral("保存"));
QMenu* expMenu = menu.addMenu(QStringLiteral("导出"));
QAction* aImg = expMenu->addAction(QStringLiteral("图片"));
QAction* aDat = expMenu->addAction(QStringLiteral("dat"));
menu.addSeparator();
QAction* aFace = menu.addAction(QStringLiteral("正视图"));
QAction* aFlip = menu.addAction(QStringLiteral("视图翻转"));
QAction* aClose = menu.addAction(QStringLiteral("关闭"));
QAction* chosen = menu.exec(QCursor::pos());
if (chosen == nullptr) return;
if (chosen == aFace) { interactionMgr->faceSelected(); return; }
if (chosen == aFlip) { interactionMgr->flipView(); return; }
if (chosen == aClose) { interactionMgr->closeSelected(); return; } // →onSliceClosed→取消列表勾选
if (chosen == aAnomaly) {
// 在选中切片平面上启动圈定(左键逐点、右键/回车闭合、Esc 取消)。
namespace ri = geopro::render::interact;
int axis = 3;
ri::Vec3 o{}, p1{}, p2{};
if (!interactionMgr->selectedSlicePlane(axis, o, p1, p2)) return;
const ri::Vec3 e1{{p1[0] - o[0], p1[1] - o[1], p1[2] - o[2]}};
const ri::Vec3 e2{{p2[0] - o[0], p2[1] - o[1], p2[2] - o[2]}};
const ri::Vec3 normal = ri::normalize(ri::cross(e1, e2));
const std::string volId = sceneView->currentVolumeDsId();
// 异常归属spec §8当前选中切片已保存selectedSliceDsId 非空)→挂该切片;临时切片→挂体。
const std::string savedSliceId = interactionMgr->selectedSliceDsId();
anomalyDrawTool->start(
o, normal,
[&window, sceneView, scene3dRepo, renderWindowPtr, refreshAnomalies, volId,
savedSliceId, normal, o](const std::vector<ri::Vec3>& worldPts) {
// 草稿异常:先临时渲染(让用户在对话框前看到所画,且截图含异常)。
geopro::core::Anomaly a;
a.markType = geopro::core::AnomalyMarkType::Polygon;
a.remarkSourceId =
geopro::core::resolveAnomalyMount(!savedSliceId.empty(), savedSliceId, volId);
a.lineColor = "#ff3030";
a.lineWidth = 2.0;
a.dashed = false;
a.planeNormal = {normal[0], normal[1], normal[2]};
a.planeOrigin = {o[0], o[1], o[2]};
for (const auto& p : worldPts) a.worldPts.push_back({p[0], p[1], p[2]});
const std::string draftId = "draft-anomaly";
a.id = draftId;
sceneView->addAnomaly(a);
renderWindowPtr->Render();
// 截图(含异常)→ 临时文件。
const QString shot =
QDir(QDir::tempPath()).filePath(QStringLiteral("geopro_anomaly_shot.png"));
int sw = 0, sh = 0;
geopro::app::captureRenderWindowPng(renderWindowPtr, shot.toStdString(), sw, sh);
geopro::app::AnomalySaveDialog dlg(shot, sw, sh, &window);
if (dlg.exec() != QDialog::Accepted) {
sceneView->removeAnomaly(draftId);
renderWindowPtr->Render();
return;
}
a.id.clear(); // 让仓储生成真实 id
a.name = dlg.anomalyName().toStdString();
a.typeName = dlg.typeName().toStdString();
a.exceptionTypeId = dlg.typeId().toStdString();
a.remark = dlg.remark().toStdString();
scene3dRepo->saveAnomaly(
a, shot.toStdString(),
[sceneView, renderWindowPtr, refreshAnomalies, draftId](std::string) {
sceneView->removeAnomaly(draftId); // 撤草稿
refreshAnomalies(); // 重渲染 + 刷新异常列表(含新异常)
renderWindowPtr->Render();
},
[&window](const std::string& m) {
QMessageBox::warning(&window, QStringLiteral("保存异常"),
QString::fromStdString(m));
});
},
[]() { /* onCancel放弃无需处理 */ });
return;
}
if (chosen == aSave) {
int axis = 3;
geopro::render::interact::Vec3 o{}, p1{}, p2{};
if (!interactionMgr->selectedSlicePlane(axis, o, p1, p2)) return;
geopro::data::I3dSceneRepository::SliceSpec spec;
spec.volumeDsId = sceneView->currentVolumeDsId();
spec.axis = axis;
spec.origin = o;
spec.point1 = p1;
spec.point2 = p2;
const std::string existingId = interactionMgr->selectedSliceDsId();
if (!existingId.empty()) {
// 已保存切片 → 覆盖更新当前位姿(同一「保存」按钮按状态分派)。
scene3dRepo->saveSlice(existingId, spec, []() {},
[&window](const std::string& m) {
QMessageBox::warning(&window, QStringLiteral("保存切片"),
QString::fromStdString(m));
});
return;
}
// 未保存切片 → 新建 dd_slice + 链接当前切片(不重绘) + 列表自动展开勾选(去重不重复)。
if (spec.volumeDsId.empty()) {
QMessageBox::warning(&window, QStringLiteral("保存切片"),
QStringLiteral("当前切片无所属三维体,无法保存。"));
return;
}
bool ok = false;
const QString name = QInputDialog::getText(&window, QStringLiteral("保存切片"),
QStringLiteral("切片名称"),
QLineEdit::Normal,
QStringLiteral("切片"), &ok);
if (!ok) return;
scene3dRepo->createSlice(
spec, name.isEmpty() ? std::string("切片") : name.toStdString(),
[interactionMgr, refreshAnalysis, drawer](std::string newId) {
interactionMgr->tagSelectedSlice(newId); // 链接当前切片 → 新数据集(不重绘)
refreshAnalysis(); // 新行进列表(勾选集不变→不发多余信号)
drawer->colAnalysis()->setItemChecked(QString::fromStdString(newId),
true); // 自动展开+勾选(syncSlices 去重)
},
[&window](const std::string& m) {
QMessageBox::warning(&window, QStringLiteral("保存切片"),
QString::fromStdString(m));
});
return;
}
if (chosen == aImg) {
vtkSmartPointer<vtkImageData> colorImg = interactionMgr->selectedSliceColorImage();
if (colorImg == nullptr) {
QMessageBox::warning(&window, QStringLiteral("导出"),
QStringLiteral("无选中切片或切片无数据。"));
return;
}
const QString path = QFileDialog::getSaveFileName(
&window, QStringLiteral("导出为图片"), QStringLiteral("slice.png"),
QStringLiteral("PNG 图片 (*.png)"));
if (!path.isEmpty() &&
!geopro::app::exportSliceImagePng(colorImg, path.toStdString()))
QMessageBox::warning(&window, QStringLiteral("导出"), QStringLiteral("导出失败。"));
return;
}
if (chosen == aDat) {
vtkImageData* img = interactionMgr->selectedSliceImage();
if (img == nullptr) return;
const QString path = QFileDialog::getSaveFileName(
&window, QStringLiteral("导出到 dat"), QStringLiteral("slice.dat"),
QStringLiteral("数据文件 (*.dat)"));
if (!path.isEmpty() &&
!geopro::app::exportSliceDat(img, path.toStdString()))
QMessageBox::warning(&window, QStringLiteral("导出"), QStringLiteral("导出失败。"));
return;
}
};
// 关闭已保存切片(VTK 视图「关闭」) → 取消三维分析栏对应勾选(场景↔列表双向同步)。
interactionMgr->onSliceClosed = [drawer](const std::string& dsId) {
drawer->colAnalysis()->setItemChecked(QString::fromStdString(dsId), false);
};
QObject::connect(c3, &geopro::app::Column3DDataset::axesModeChanged, sceneCtrl,
&geopro::controller::VtkSceneController::setAxesMode);
QObject::connect(c3, &geopro::app::Column3DDataset::axesUnitChanged, sceneCtrl,
&geopro::controller::VtkSceneController::setAxesUnit);
QObject::connect(c3, &geopro::app::Column3DDataset::verticalExaggerationChanged, sceneCtrl,
&geopro::controller::VtkSceneController::setVerticalExaggeration);
QObject::connect(c3, &geopro::app::Column3DDataset::viewRequested, sceneCtrl,
&geopro::controller::VtkSceneController::applyView);
QObject::connect(c3, &geopro::app::Column3DDataset::zoomInRequested, sceneCtrl,
&geopro::controller::VtkSceneController::zoomIn);
QObject::connect(c3, &geopro::app::Column3DDataset::zoomOutRequested, sceneCtrl,
&geopro::controller::VtkSceneController::zoomOut);
QObject::connect(c3, &geopro::app::Column3DDataset::fitRequested, sceneCtrl,
&geopro::controller::VtkSceneController::fit);
// 三维数据集栏勾选(反演剖面)→ 并入渲染勾选集(剖面走帘面路径)。
QObject::connect(c3, &geopro::app::Column3DDataset::checkedDatasetsChanged, sceneCtrl,
[checkedProfiles, pushChecked](const QStringList& ids) {
*checkedProfiles = ids;
pushChecked();
});
// ── 三维分析 tab5 段信号接线Task 12──────────────────────────────────
auto* analysisTab = drawer->analysisTab();
// 5 段勾选并集 → 按类型分流渲染:反演剖面→帘面(checkedProfiles);三维体→体素(checkedAnalysis)
// 切片(dd_slice)→不进控制器,经 syncSlices 在父体上还原。
QObject::connect(analysisTab, &geopro::app::CategoryAnalysisTab::checkedDatasetsChanged, sceneCtrl,
[checkedProfiles, checkedAnalysis, checkedSliceIds, syncSlices, pushChecked,
scene3dRepo](const QStringList& ids) {
QStringList profiles, analysis;
checkedSliceIds->clear();
for (const QString& id : ids) {
const std::string s = id.toStdString();
if (scene3dRepo->isSliceDataset(s))
checkedSliceIds->insert(s);
else if (scene3dRepo->isVolumeDataset(s))
analysis << id;
else
profiles << id; // 反演剖面 → 帘面
}
*checkedProfiles = profiles;
*checkedAnalysis = analysis;
pushChecked();
syncSlices();
});
// 段头「+新增三维体」:弹参数对话框 → 组装真实 VoxelGenerateRequest → createVolume(mock) → 刷新。
QObject::connect(analysisTab, &geopro::app::CategoryAnalysisTab::generateVolumeRequested, &window,
[&window, &nav, scene3dRepo, refreshAnalysis](const QString& /*dsTypeCode*/,
const QStringList& sourceIds) {
if (sourceIds.isEmpty()) return;
geopro::app::VolumeParamsDialog dlg(static_cast<int>(sourceIds.size()), &window);
if (dlg.exec() != QDialog::Accepted) return;
const geopro::data::VolumeBuildParams p = dlg.params();
geopro::data::VoxelGenerateRequest req;
req.projectId = nav.currentProjectId().toStdString();
req.name = dlg.volumeName().toStdString();
for (const QString& id : sourceIds) req.sourceDatasetIds.push_back(id.toStdString());
req.interpModel =
(p.interpModel == geopro::data::VolumeBuildParams::Model::Kriging) ? "Kriging" : "Idw";
req.cellXY = p.cellXY;
req.cellZ = p.cellZ;
req.power = p.power;
req.maxDist = p.maxDist;
req.colorScaleId = p.colorScaleId;
scene3dRepo->createVolume(req);
refreshAnalysis();
});
// 双击数据详情dd_slice→切片属性dd_voxel→三维体属性同 colAnalysis 详情口径)。
QObject::connect(analysisTab, &geopro::app::CategoryAnalysisTab::detailRequested, &window,
[&window, scene3dRepo](const QString& dsId, const QString& ddCode, const QString& name) {
if (ddCode == QStringLiteral("dd_slice")) {
geopro::data::I3dSceneRepository::SliceSpec sp;
if (scene3dRepo->sliceSpec(dsId.toStdString(), sp)) {
geopro::app::SlicePropertiesDialog dlg(name, sp, &window);
dlg.exec();
}
} else if (ddCode == QStringLiteral("dd_voxel")) {
geopro::data::Api3dRepository::VolumeInfo info;
if (scene3dRepo->volumeInfo(dsId.toStdString(), info)) {
geopro::app::VolumePropertiesDialog dlg(name, info, &window);
dlg.exec();
}
}
});
// O点位置/字体本期 stubTODO P4弹框
QObject::connect(c3, &geopro::app::Column3DDataset::oPointClicked, vtkWidget,
[]() { /* TODO P4: O点位置弹框 */ });
QObject::connect(c3, &geopro::app::Column3DDataset::fontClicked, vtkWidget,
[]() { /* TODO P4: 字体弹框 */ });
// 三维数据集栏右键「生成三维体」:弹参数对话框 → 客户端 createVolumemock→ 刷新三维分析栏
// (新三维体作为"分析产物"出现在三维分析栏,勾选即渲染体)。
QObject::connect(c3, &geopro::app::Column3DDataset::generateVolumeRequested, &window,
[&window, scene3dRepo, refreshAnalysis](const QStringList& sourceIds) {
geopro::app::VolumeParamsDialog dlg(static_cast<int>(sourceIds.size()),
&window);
if (dlg.exec() != QDialog::Accepted) return;
geopro::data::VolumeBuildParams params = dlg.params();
for (const QString& id : sourceIds)
params.sourceDatasetIds.push_back(id.toStdString());
scene3dRepo->createVolume(std::move(params),
dlg.volumeName().toStdString());
refreshAnalysis(); // 新体行进入三维分析栏,勾选即渲染体
});
auto* ca = drawer->colAnalysis();
// 三维分析栏勾选(三维体/切片):体走控制器体素路径;切片(dd_slice)不进控制器(否则 loadSection
// 会对 slice id 失败),单独经 syncSlices 在父体上还原渲染。
QObject::connect(ca, &geopro::app::Column3DAnalysis::checkedItemsChanged, sceneCtrl,
[checkedAnalysis, pushChecked, checkedSliceIds, syncSlices,
scene3dRepo](const QStringList& ids) {
QStringList nonSlice;
checkedSliceIds->clear();
for (const QString& id : ids) {
const std::string s = id.toStdString();
if (scene3dRepo->isSliceDataset(s))
checkedSliceIds->insert(s);
else
nonSlice << id;
}
*checkedAnalysis = nonSlice;
pushChecked(); // 体/其它 → 控制器(增删图元,可能触发 onVolumeChanged→syncSlices
syncSlices(); // 切片勾选变化即时调和(父体已在场时立即显隐)
});
QObject::connect(ca, &geopro::app::Column3DAnalysis::sliceRequested, vtkWidget,
[interactionMgr](geopro::render::interact::SliceAxis axis) {
interactionMgr->addSlice(axis);
});
// 三维分析栏「数据详情」项非体即切片dd_slice / dd_voxel按 ddCode 分派到只读属性
// 对话框(仿异常详情)。数据直接从具体 scene3dRepo 取(体/切片在 3D 仓储,非 detailCtrl 的 2D 管线)。
QObject::connect(ca, &geopro::app::Column3DAnalysis::detailRequested, &window,
[&window, scene3dRepo](const QString& dsId, const QString& ddCode,
const QString& name) {
if (ddCode == QStringLiteral("dd_slice")) {
geopro::data::I3dSceneRepository::SliceSpec sp;
if (scene3dRepo->sliceSpec(dsId.toStdString(), sp)) {
geopro::app::SlicePropertiesDialog dlg(name, sp, &window);
dlg.exec();
}
} else { // dd_voxel三维体
geopro::data::Api3dRepository::VolumeInfo info;
if (scene3dRepo->volumeInfo(dsId.toStdString(), info)) {
geopro::app::VolumePropertiesDialog dlg(name, info, &window);
dlg.exec();
}
}
});
// 三维分析栏切片右键「删除」→ 删除 mock 切片 + 刷新列表(若在渲染,删后行消失→取消勾选→自动移除图元)。
QObject::connect(ca, &geopro::app::Column3DAnalysis::sliceDeleteRequested, &window,
[scene3dRepo, refreshAnalysis](const QString& dsId) {
scene3dRepo->deleteSlice(
dsId.toStdString(), [refreshAnalysis]() { refreshAnalysis(); },
[](const std::string&) {});
});
// 列表切片「保存」=把当前(可能被拖动过的)位姿覆盖更新到该 dd_slice须该切片正在渲染才有位姿可取。
QObject::connect(ca, &geopro::app::Column3DAnalysis::sliceSaveRequested, &window,
[&window, interactionMgr, scene3dRepo, sceneView](const QString& dsId) {
if (!interactionMgr->selectSavedSlice(dsId.toStdString())) {
QMessageBox::information(&window, QStringLiteral("保存"),
QStringLiteral("请先勾选该切片渲染后再保存其位姿。"));
return;
}
int axis = 3;
geopro::render::interact::Vec3 o{}, p1{}, p2{};
interactionMgr->selectedSlicePlane(axis, o, p1, p2);
geopro::data::I3dSceneRepository::SliceSpec spec;
spec.volumeDsId = sceneView->currentVolumeDsId();
spec.axis = axis;
spec.origin = o;
spec.point1 = p1;
spec.point2 = p2;
scene3dRepo->saveSlice(dsId.toStdString(), spec, []() {},
[](const std::string&) {});
});
// 列表切片「保存为」=以该切片当前(存储)位姿另存为新 dd_slice不依赖渲染
QObject::connect(ca, &geopro::app::Column3DAnalysis::sliceSaveAsRequested, &window,
[&window, scene3dRepo, refreshAnalysis](const QString& dsId) {
geopro::data::I3dSceneRepository::SliceSpec spec;
if (!scene3dRepo->sliceSpec(dsId.toStdString(), spec)) return;
bool ok = false;
const QString name = QInputDialog::getText(
&window, QStringLiteral("保存为"), QStringLiteral("新切片名称"),
QLineEdit::Normal, QStringLiteral("切片副本"), &ok);
if (!ok) return;
scene3dRepo->createSlice(
spec, name.isEmpty() ? std::string("切片副本") : name.toStdString(),
[refreshAnalysis](std::string) { refreshAnalysis(); },
[](const std::string&) {});
});
// 列表切片「导出▸图片」:定位到渲染中的该切片 → 导出其上色 2D 图。
QObject::connect(ca, &geopro::app::Column3DAnalysis::sliceExportImageRequested, &window,
[&window, interactionMgr](const QString& dsId) {
if (!interactionMgr->selectSavedSlice(dsId.toStdString())) {
QMessageBox::information(&window, QStringLiteral("导出"),
QStringLiteral("请先勾选该切片渲染后再导出。"));
return;
}
vtkSmartPointer<vtkImageData> img = interactionMgr->selectedSliceColorImage();
if (img == nullptr) return;
const QString path = QFileDialog::getSaveFileName(
&window, QStringLiteral("导出为图片"), QStringLiteral("slice.png"),
QStringLiteral("PNG 图片 (*.png)"));
if (!path.isEmpty() &&
!geopro::app::exportSliceImagePng(img, path.toStdString()))
QMessageBox::warning(&window, QStringLiteral("导出"),
QStringLiteral("导出失败。"));
});
// 列表切片「导出▸dat」定位到渲染中的该切片 → 导出其重采样标量网格。
QObject::connect(ca, &geopro::app::Column3DAnalysis::sliceExportDatRequested, &window,
[&window, interactionMgr](const QString& dsId) {
if (!interactionMgr->selectSavedSlice(dsId.toStdString())) {
QMessageBox::information(&window, QStringLiteral("导出"),
QStringLiteral("请先勾选该切片渲染后再导出。"));
return;
}
vtkImageData* img = interactionMgr->selectedSliceImage();
if (img == nullptr) return;
const QString path = QFileDialog::getSaveFileName(
&window, QStringLiteral("导出到 dat"), QStringLiteral("slice.dat"),
QStringLiteral("数据文件 (*.dat)"));
if (!path.isEmpty() &&
!geopro::app::exportSliceDat(img, path.toStdString()))
QMessageBox::warning(&window, QStringLiteral("导出"),
QStringLiteral("导出失败。"));
});
// 色阶(三维体/切片):复刻原版「色阶配置」对话框,确定后体素 + 其切片随新色阶重渲染。
// 仅对当前已渲染的三维体生效(切片色阶继承体色阶,经 InteractionManager 重建)。
QObject::connect(ca, &geopro::app::Column3DAnalysis::colorScaleRequested, &window,
[&window, &colorTplRepo, &nav, sceneCtrl, sceneView](const QString& qid) {
const std::string dsId = qid.toStdString();
if (sceneView->currentVolumeDsId() != dsId || !sceneView->hasVolume()) {
QMessageBox::information(
&window, QStringLiteral("色阶"),
QStringLiteral("请先勾选该三维体使其渲染后再编辑色阶。"));
return;
}
// 等积分层需原始标量:从当前体素 image 抽取(无则等积退化线性)。
// 大体素按步长抽样(等积分位无需全量点),避免主线程长循环卡 UI。
std::vector<double> samples;
if (vtkImageData* img = sceneView->currentVolumeImage()) {
if (vtkDataArray* sc = img->GetPointData()->GetScalars()) {
const vtkIdType n = sc->GetNumberOfTuples();
if (n > 0) {
constexpr vtkIdType kMaxSamples = 200000;
const vtkIdType stride =
(n > kMaxSamples) ? (n / kMaxSamples) : 1;
samples.reserve(
static_cast<std::size_t>(n / stride + 1));
for (vtkIdType i = 0; i < n; i += stride)
samples.push_back(sc->GetComponent(i, 0));
}
}
}
// 3D 无等值线,线形/标注配置忽略(用默认);仅取色阶应用。
// 「另存为/打开」与「新建色阶/配色方案」走色阶模板仓储projectId 取当前项目。
// 3D 体无来源 lvl 模板 → lvlTemplateId 传空(覆盖复选框禁用,行为不变)。
geopro::app::ColorScaleConfigDialog dlg(
sceneView->currentColorScale(), sceneView->currentVmin(),
sceneView->currentVmax(), std::move(samples), {}, &colorTplRepo,
nav.currentProjectId(), QString(), &window);
if (dlg.exec() == QDialog::Accepted)
sceneCtrl->setVolumeColorScale(dsId, dlg.colorScale());
});
// ── 3D 异常控制(#4c显示过滤 / 单条显隐 / 删除 → 驱动 VTK 异常渲染 ──────────
// 过滤档位变化 → 重算异常集合并重渲染 + 刷新列表(独立于体勾选)。
QObject::connect(ca, &geopro::app::Column3DAnalysis::anomalyDisplayFilterChanged, vtkWidget,
[refreshAnomalies](int) { refreshAnomalies(); });
// 单条显隐 → 切该异常 actor 可见性。
QObject::connect(ca, &geopro::app::Column3DAnalysis::anomalyVisibilityChanged, vtkWidget,
[sceneView, renderWindowPtr](const QString& id, bool vis) {
sceneView->setAnomalyVisible(id.toStdString(), vis);
renderWindowPtr->Render();
});
// 列表选中异常 → VTK 高亮联动R84list→VTK
QObject::connect(ca, &geopro::app::Column3DAnalysis::anomalySelected, vtkWidget,
[sceneView](const QString& id) {
sceneView->setSelectedAnomaly(id.toStdString());
});
// 双击异常 → 只读属性对话框R83名称/类型/标记/归属/坐标/备注)。
QObject::connect(ca, &geopro::app::Column3DAnalysis::anomalyPropertiesRequested, &window,
[&window](const geopro::core::Anomaly& a) {
geopro::app::AnomalyPropertiesDialog dlg(a, &window);
dlg.exec();
});
// 删除异常 → 删 mock + 刷新渲染/列表。
QObject::connect(ca, &geopro::app::Column3DAnalysis::anomalyDeleteRequested, &window,
[scene3dRepo, refreshAnomalies](const QString& id) {
scene3dRepo->deleteAnomaly(
id.toStdString(), [refreshAnomalies]() { refreshAnomalies(); },
[](const std::string&) {});
});
// ── 二维数据集栏:天地图底图开关(③,复用轨迹图 token经同一共享 GeoLocalFrame 配准)──
auto* basemap = new geopro::app::TileBasemap(*scene, renderWindowPtr, frame, &window);
// 当前底图选择(默认 天地图=Satellite对齐 Column2DDataset 默认项);数据重锚后据此在数据位置加载。
auto basemapKind =
std::make_shared<geopro::app::TileBasemap::Kind>(geopro::app::TileBasemap::Satellite);
QObject::connect(drawer->col2D(), &geopro::app::Column2DDataset::basemapChanged, basemap,
[basemap, basemapKind](int idx) {
// 地图下拉0 天地图(卫星影像) / 1 Google(暂未实现→隐藏) / 2 隐藏。
*basemapKind = (idx == 0) ? geopro::app::TileBasemap::Satellite
: geopro::app::TileBasemap::Hidden;
basemap->show(*basemapKind);
});
// ── 二维数据集栏:勾选足迹(测线/轨迹) → 平铺进 View3D 地图2D视图下拉控摆放高度 ──
// 足迹经控制器 loadMapLine(Api3dRepository 走 dd/ert/trajectory/line 端点) → addMapLine 至
// 当前摆放 Z与帘面/底图共享 GeoLocalFrame 配准。与 3D 勾选集独立、按 dsId 增量。
QObject::connect(drawer->col2D(), &geopro::app::Column2DDataset::checkedDatasetsChanged,
sceneCtrl, &geopro::controller::VtkSceneController::setChecked2DDatasets);
// 2D视图下拉(0关闭/1 Z=0/2顶部/3底部/4自定义) + 自定义 Z → 控制器重摆已勾选足迹。
auto custom2dZ = std::make_shared<double>(0.0);
// 默认 1(Z=0)与「2D视图」下拉可见默认项(setCurrentIndex(1))及控制器默认摆放一致——
// 组合框初始 view2DModeChanged 因 connect 前发射而丢失,此处不可依赖其同步初值。
auto view2dMode = std::make_shared<int>(1);
QObject::connect(drawer->col2D(), &geopro::app::Column2DDataset::view2DModeChanged, sceneCtrl,
[sceneCtrl, custom2dZ, view2dMode](int mode) {
*view2dMode = mode;
sceneCtrl->set2DPlacement(mode, *custom2dZ);
});
// 自定义 Z 变化:记录;若当前正处自定义模式则即时重摆(控制器内 changed 判定避免无谓重画)。
QObject::connect(drawer->col2D(), &geopro::app::Column2DDataset::customZChanged, sceneCtrl,
[sceneCtrl, custom2dZ, view2dMode](double z) {
*custom2dZ = z;
if (*view2dMode == 4) sceneCtrl->set2DPlacement(4, z);
});
// 首个真实剖面到达 → frame 重锚到数据 lat/lon 后,把选中的底图加载到数据所在位置
// (默认天地图即在此刻出现,避免启动时在样本原点拉无关瓦片)。
sceneView->onFrameReanchored = [basemap, basemapKind]() {
if (*basemapKind != geopro::app::TileBasemap::Hidden) basemap->show(*basemapKind);
};
// 相机程序化变化(取景/预设/缩放)后,底图按新视锥重算覆盖(治首帧部分瓦片需手动微动才出)。
sceneView->onCameraChanged = [basemap]() { basemap->refresh(); };
// 底图最大范围按当前勾选剖面合并范围动态定(随增删自动伸缩);刷新时实时查询。
basemap->setDataRadiusProvider([sceneView]() { return sceneView->dataHorizontalRadius(); });
// 垂直夸张:地形须与剖面用同一 VE 才对齐(都按真实高程×VE)。
QObject::connect(drawer->col3D(), &geopro::app::Column3DDataset::verticalExaggerationChanged,
basemap, [basemap](double ve) { basemap->setVerticalExaggeration(ve); });
// 单一来源kVerticalExaggeration 一处定义,组合根下发到 控制器(上方259) / 底图 / UI 显示。
basemap->setVerticalExaggeration(kVerticalExaggeration);
drawer->col3D()->setVerticalExaggeration(kVerticalExaggeration);
// ── 中央“空状态”引导浮层:未接入真实 sections 时,引导首次使用者从左侧入手。──
// 透明背景 + 鼠标穿透(不挡 QVTK 交互CenterOverlay 随视口尺寸保持居中;
// 接入真实中央数据后改成依 sections 是否为空调 setVisible 即可。
auto* emptyState = new QFrame(centerWidget);
emptyState->setObjectName(QStringLiteral("centralEmpty"));
emptyState->setAttribute(Qt::WA_TransparentForMouseEvents);
// 背景取 canvas/bg(#0B1320)——与画布同色:在原生 GL 上覆盖透明无法生效(会回退成不透明浅底),
// 故用与画布等色的不透明底,卡片即「无缝隐形」,浅色提示字稳稳浮于深底(与左上视图详情浮层同法)。
geopro::app::applyTokenizedStyleSheet(
emptyState, QStringLiteral("#centralEmpty { background: {{canvas/bg}}; }"
"#centralEmpty QLabel { background: transparent; }"));
auto* esLay = new QVBoxLayout(emptyState);
esLay->setContentsMargins(geopro::app::space::kXl, geopro::app::space::kXl,
geopro::app::space::kXl, geopro::app::space::kXl);
esLay->setSpacing(geopro::app::space::kMd);
esLay->setAlignment(Qt::AlignCenter);
auto* esIcon = new QLabel(emptyState);
esIcon->setPixmap(
geopro::app::makeGlyph(geopro::app::Glyph::Dataset,
geopro::app::tokenColor("canvas/text-dim"), 56)
.pixmap(56, 56));
esIcon->setAlignment(Qt::AlignCenter);
auto* esTitle = new QLabel(QStringLiteral("勾选左侧数据集开始渲染"), emptyState);
esTitle->setAlignment(Qt::AlignCenter);
esTitle->setWordWrap(true); // 窄时换行,不撑宽浮层
geopro::app::applyTokenizedStyleSheet(
esTitle, QStringLiteral("color:{{canvas/text}}; font-size:%1px; font-weight:%2;")
.arg(geopro::app::scaledPx(geopro::app::type::kHeading))
.arg(geopro::app::type::kWeightSemibold));
auto* esHint = new QLabel(QStringLiteral("在左侧「三维数据集 / 二维数据集 / 三维分析」栏勾选数据集,\n"
"在此叠加显示;可切换二维 / 三维视图。"),
emptyState);
esHint->setAlignment(Qt::AlignCenter);
esHint->setWordWrap(true); // 窄时换行,不撑宽浮层
geopro::app::applyTokenizedStyleSheet(
esHint,
QStringLiteral("color:{{canvas/text-dim}}; font-size:%1px;").arg(geopro::app::scaledPx(geopro::app::type::kBody)));
esLay->addWidget(esIcon);
esLay->addWidget(esTitle);
esLay->addWidget(esHint);
auto* emptyCentering = new CenterOverlay(emptyState, vtkWidget);
emptyCentering->reposition();
auto* vtkDock = new ads::CDockWidget(QStringLiteral("VTK视图"));
vtkDock->setWidget(centerWidget);
auto* centerDockArea = dockManager->addDockWidget(ads::CenterDockWidgetArea, vtkDock);
// ── 下方「数据详情」dock平面图表多 Tab 面板QGraphicsViewVTK 仅算几何)──
// 单击数据集 → 聚焦已开页;双击 → 新建/聚焦页(真实反演剖面/散点/异常/色阶)。
auto* detailPanel = new geopro::app::DatasetDetailPanel();
// 注入色阶模板仓储 + 当前 projectId 取值回调(网格剖面「色阶配置」编辑器的另存/打开/新建色阶)。
detailPanel->setColorTemplateRepo(&colorTplRepo, [&nav] { return nav.currentProjectId(); });
// 注入反演命令仓储measurement 反演运算/生成视电阻率。projectId 取值仍由页内 projectIdGetter 提供。
detailPanel->setCommandRepo(&cmdRepo);
auto* detailDock = new ads::CDockWidget(QStringLiteral("数据详情"));
// ForceNoScrollArea禁止 ADS 默认把整块内容(含标题栏/页签栏)包进 QScrollArea。
// 否则内容最小尺寸超过 dock 时,整个面板(标题+页签+图)一起滚动。改为内容自适应填充;
// 需要时由内层(图表内容区)自行滚动,标题/页签固定。
auto* detailHeader = wrapWithHeader(
geopro::app::Glyph::Detail, QStringLiteral("数据详情"), detailPanel,
{{geopro::app::Glyph::Fullscreen, QStringLiteral("全屏")}});
detailDock->setWidget(detailHeader, ads::CDockWidget::ForceNoScrollArea);
// 放在中央视图下方。
dockManager->addDockWidget(ads::BottomDockWidgetArea, detailDock, centerDockArea);
// 左上 dock对象树真实结构项目根 → GS → TM。被动视图数据由控制器推送。
auto* objectTree = new geopro::app::ObjectTreePanel();
auto* leftDock = new ads::CDockWidget(QStringLiteral("对象"));
auto* objectBox = wrapWithHeader(geopro::app::Glyph::Tree, QStringLiteral("对象"), objectTree,
{{geopro::app::Glyph::Filter, QStringLiteral("筛选")},
{geopro::app::Glyph::Plus, QStringLiteral("新建对象")}});
leftDock->setWidget(objectBox);
auto* leftArea = dockManager->addDockWidget(ads::LeftDockWidgetArea, leftDock);
// 左下 dock数据真实显示栏(选中测线后列其采集批次=数据集;tab 数据/文件)。
auto* datasetTabs = new QTabWidget();
// 数据页签:树形列表(原版 el-table 树——派生数据挂源数据下,按 DsRow.parentId 嵌套)。
auto* datasetList = new QTreeWidget();
datasetList->setHeaderHidden(true);
datasetList->setColumnCount(1);
datasetList->setRootIsDecorated(true); // 显展开/折叠箭头
datasetList->setIndentation(geopro::app::scaledPx(14));
datasetList->setExpandsOnDoubleClick(false); // 双击=打开详情,不切展开(展开靠箭头)
datasetList->setSelectionBehavior(QAbstractItemView::SelectRows);
geopro::app::applyDatasetCardDelegate(datasetList);
datasetTabs->addTab(datasetList, QStringLiteral("数据"));
auto* fileList = new QListWidget();
geopro::app::applyDatasetCardDelegate(fileList);
datasetTabs->addTab(fileList, QStringLiteral("文件"));
auto* datasetDock = new ads::CDockWidget(QStringLiteral("数据集"));
auto* datasetBox = wrapWithHeader(
geopro::app::Glyph::Dataset, QStringLiteral("数据集"), datasetTabs,
{{geopro::app::Glyph::Filter, QStringLiteral("筛选")},
{geopro::app::Glyph::Upload, QStringLiteral("上传")}});
datasetDock->setWidget(datasetBox);
// 动态标题:选中测线后改为「数据集显示栏 · ERTx」(对齐原型)。
auto* datasetTitle = datasetBox->findChild<QLabel*>(QStringLiteral("panelTitle"));
dockManager->addDockWidget(ads::BottomDockWidgetArea, datasetDock, leftArea);
// 右上 dock异常列表 / 对象属性 合并为带 Tab 表头的面板(对齐原型上半)。
auto* exceptionPanel = new geopro::app::ObjectExceptionPanel();
auto* objAttrView = new geopro::app::ObjectAttrPanel(projectRepo);
auto anomalyPanel = geopro::app::buildTabbedPanel(
{{geopro::app::Glyph::Anomaly, QStringLiteral("对象异常"), exceptionPanel, true},
{geopro::app::Glyph::Property, QStringLiteral("对象属性"), objAttrView, false}},
{{geopro::app::Glyph::Filter, QStringLiteral("筛选")},
{geopro::app::Glyph::Plus, QStringLiteral("添加异常")}});
auto* anomalyBadge = anomalyPanel.badges.value(0); // 异常列表 Tab 的数量徽标
// colorize(C):异常计数用语义 warning“需注意”变体区别于普通中性计数徽标
// 提示“这些异常点待复查”。改 objectName 后重新 polish 以应用 #panelBadgeWarn 样式。
// 注:徽标的填充/显隐由 exceptionTreeLoaded 连接驱动(勾选对象后按异常计数更新)。
if (anomalyBadge) {
anomalyBadge->setObjectName(QStringLiteral("panelBadgeWarn"));
anomalyBadge->style()->unpolish(anomalyBadge);
anomalyBadge->style()->polish(anomalyBadge);
}
auto* rightDock = new ads::CDockWidget(QStringLiteral("异常/对象属性"));
rightDock->setWidget(anomalyPanel.container);
auto* rightArea = dockManager->addDockWidget(ads::RightDockWidgetArea, rightDock);
// 右下 dock属性数据集属性只读元字段不可编辑
auto* propView = new geopro::app::DatasetAttrPanel();
auto* propDock = new ads::CDockWidget(QStringLiteral("数据集属性"));
propDock->setWidget(
wrapWithHeader(geopro::app::Glyph::Property, QStringLiteral("数据集属性"), propView));
dockManager->addDockWidget(ads::BottomDockWidgetArea, propDock, rightArea);
// 固定全部面板(对齐原型):移除 关闭/浮动/拖动/钉住 等子窗口操作,仅保留分隔条调整边界。
// 同时隐藏 ADS 自带标题栏——表头已由各面板内的自绘 PanelHeader 承担,避免“双标题”。
// 抽成 lambdaADS restoreState() 恢复布局时会重建停靠区并重新显示标题栏,
// 故须在恢复布局之后再调用一次,确保任何已保存布局下标题栏都稳定隐藏。
const auto hideDockTitleBars = [&]() {
for (ads::CDockWidget* d : {vtkDock, detailDock, leftDock, datasetDock, rightDock, propDock}) {
d->setFeatures(ads::CDockWidget::NoDockWidgetFeatures);
if (auto* area = d->dockAreaWidget())
if (auto* bar = area->titleBar()) bar->setVisible(false);
}
};
hideDockTitleBars();
// 中央渲染由 sceneCtrlVtkSceneController驱动勾选对象/2D-3D切换/图层勾选/主题 → 重建场景。
// (旧 rebuildCentral lambda + 裸 show* 标志已由控制器取代。)
// ── 单击左下数据列表的采集批次(DS) → 属性表单 + 聚焦详情已开页 ──
QObject::connect(datasetList, &QTreeWidget::itemClicked, datasetList,
[&nav, &detailCtrl](QTreeWidgetItem* item, int) {
if (item->data(0, geopro::app::kDsLoadMoreRole).toBool()) {
nav.loadMoreData();
return;
}
const QString dsId = item->data(0, geopro::app::kDsIdRole).toString();
if (dsId.isEmpty()) return;
nav.selectDataset(dsId); // 只读元字段表单datasetDetailLoaded
detailCtrl.focusDataset(dsId); // 单击=聚焦已开页
});
// ── 双击 → 打开/聚焦该数据集的详情图表页(拉真实反演剖面/散点/异常/色阶)──
QObject::connect(datasetList, &QTreeWidget::itemDoubleClicked, datasetList,
[&detailCtrl](QTreeWidgetItem* item, int) {
const QString dsId = item->data(0, geopro::app::kDsIdRole).toString();
const QString ddCode = item->data(0, geopro::app::kDsDdCodeRole).toString();
const QString dsName = item->data(0, geopro::app::kDsNameRole).toString();
// tmObjectId白化 structParentId从行读出透传使白化模板列表非空。
const QString tmObjectId =
item->data(0, geopro::app::kDsTmObjectIdRole).toString();
if (!dsId.isEmpty()) detailCtrl.openDataset(dsId, ddCode, dsName, tmObjectId);
});
// ── 控制器信号 → 详情面板tab 引擎):开页 / 页签就绪 / 加载中 / 聚焦 ──
QObject::connect(&detailCtrl, &geopro::controller::DatasetDetailController::datasetOpened,
detailPanel, &geopro::app::DatasetDetailPanel::onDatasetOpened);
QObject::connect(&detailCtrl, &geopro::controller::DatasetDetailController::tabReady,
detailPanel, &geopro::app::DatasetDetailPanel::onTabReady);
QObject::connect(&detailCtrl, &geopro::controller::DatasetDetailController::tabLoadStarted,
detailPanel, &geopro::app::DatasetDetailPanel::onTabLoadStarted);
QObject::connect(&detailCtrl, &geopro::controller::DatasetDetailController::focusRequested,
detailPanel, &geopro::app::DatasetDetailPanel::focusDataset);
// ── 页签懒加载lazy 页签首次激活 → 控制器按 (dsId,ddCode,tabIndex) 拉载荷 → 回填 ──
QObject::connect(detailPanel, &geopro::app::DatasetDetailPanel::tabNeeded, &detailCtrl,
&geopro::controller::DatasetDetailController::loadTab);
// ── 分页:分页器翻页/改每页条数 → 控制器按页加载 → 回填(同 tabReady 路径,刷新表格+分页器)──
QObject::connect(detailPanel, &geopro::app::DatasetDetailPanel::tabPageNeeded, &detailCtrl,
&geopro::controller::DatasetDetailController::loadTabPaged);
// context 用 detailPanel析构即自动断连避免野指针。window 比 detailPanel 活得久,
// 捕 &window 取状态栏安全。失败时清该页 lazy 遮罩(幂等)并状态栏提示。
QObject::connect(&detailCtrl, &geopro::controller::DatasetDetailController::loadFailed, detailPanel,
[&window, detailPanel](const QString& dsId, const QString& msg) {
detailPanel->onLoadFailed(dsId, msg);
window.statusBar()->showMessage(
QStringLiteral("数据集 %1 加载失败:%2").arg(dsId, msg), 5000);
});
// ── 详情面板切 Tab → 反向高亮数据集列表对应行 ──
QObject::connect(detailPanel, &geopro::app::DatasetDetailPanel::activeDatasetChanged,
datasetList, [datasetList](const QString& dsId) {
for (QTreeWidgetItemIterator it(datasetList); *it; ++it)
if ((*it)->data(0, geopro::app::kDsIdRole).toString() == dsId) {
datasetList->setCurrentItem(*it);
break;
}
});
// ── 左上对象树勾选 → 拉取各 TM 的 ds 子树按维度分发到三栏列表spec §6.1/§8──
// 渲染由三栏勾选框驱动Task 7Column3DDataset::checkedDatasetsChanged → setCheckedDatasets
auto generation = std::make_shared<unsigned long long>(0);
QObject::connect(
objectTree, &geopro::app::ObjectTreePanel::checkedTmsChanged, &window,
[&projectRepo, &nav, drawer, emptyState, generation, lastSourceRows,
refreshAnalysis](const QStringList& tmIds) {
const unsigned long long myGen = ++(*generation);
emptyState->setVisible(tmIds.isEmpty()); // 有勾选→隐藏引导层,露出中央渲染
if (tmIds.isEmpty()) {
*lastSourceRows = {};
refreshAnalysis(); // 清空 5 段(客户端三维体仍驻留) + col2D
return;
}
// 多 TM 异步汇总:每个 TM 取整棵 ds 子树,全部回来后 splitByCategory 分发到 5 段。
auto acc = std::make_shared<std::vector<geopro::data::DsRow>>();
auto remaining = std::make_shared<int>(tmIds.size());
auto finish = [acc, generation, myGen, lastSourceRows, refreshAnalysis]() {
if (*generation != myGen) return; // 已被更新的勾选批次取代→丢弃陈旧结果
*lastSourceRows = *acc; // 全部对象树 ds 作分析数据源
refreshAnalysis(); // splitByCategory→5段 + 合并三维体/切片 + dim2D→col2D
};
for (const QString& tm : tmIds) {
geopro::data::NavRequest* req = projectRepo.loadRowsAsync(
nav.currentProjectId().toStdString(), tm.toStdString(), 2, 3, 1, 100000);
QObject::connect(req, &geopro::data::NavRequest::done, drawer,
[acc, remaining, finish](const QVariant& v) {
auto page = qvariant_cast<geopro::data::DsPage>(v);
acc->insert(acc->end(), page.rows.begin(), page.rows.end());
if (--(*remaining) == 0) finish();
});
QObject::connect(req, &geopro::data::NavRequest::failed, drawer,
[remaining, finish](const QString&) {
if (--(*remaining) == 0) finish(); // 单个失败不卡死,其余照常分发
});
}
});
// ── 启动:建立一次中央视图。三栏重构后删除了 2D/3D 切换,统一固定为三维视图
// (帘面默认开启 showCurtain_=true勾选 dd_section → 帘面)。无勾选 → 空场景 + 背景。
sceneCtrl->setViewMode(geopro::controller::ViewMode::View3D);
// VTK 背景随主题切换:控制器重渲染(走完整渲染路径、末尾必 Render
// context 用 sceneCtrl非 windowThemeManager 是进程级单例,连接须随 sceneCtrl 析构自动断开,
// 否则 window 析构期间 sceneCtrl(其孙级子对象)已销毁、主题异步变化会触悬垂指针。
QObject::connect(&geopro::app::ThemeManager::instance(), &geopro::app::ThemeManager::changed, sceneCtrl,
[sceneCtrl]() { sceneCtrl->rebuild(); });
// 顶部应用区:单行工具条(工作空间/项目切换 + 一级菜单按钮 视图/项目管理/业务工具/设备
// + 帮助/通知/设置 + 用户)。菜单栏已去除,一级菜单改为工具条上的下拉按钮。
geopro::app::TopBar* topBar = new geopro::app::TopBar(&window);
window.setMenuWidget(topBar);
// ── 控制器 ↔ UI 信号接线(导航壳)──────────────────────────────────────
// "加载更多"行:列表末尾若已加载数 < 总数,放一行可点击的"加载更多(已/共)"。
auto removeLoadMore = [](QListWidget* lw) {
if (lw->count() > 0 &&
lw->item(lw->count() - 1)->data(geopro::app::kDsLoadMoreRole).toBool())
delete lw->takeItem(lw->count() - 1);
};
auto addLoadMore = [](QListWidget* lw, int total) {
const int loaded = lw->count();
if (loaded < total) {
auto* m = new QListWidgetItem(QStringLiteral("加载更多(%1/%2").arg(loaded).arg(total), lw);
m->setData(geopro::app::kDsLoadMoreRole, true);
m->setTextAlignment(Qt::AlignCenter);
}
return loaded;
};
// 数据树的「加载更多」:末尾顶层项;已加载数=树中非"加载更多"项总数(含各层子节点)。
auto removeTreeLoadMore = [](QTreeWidget* tw) {
const int n = tw->topLevelItemCount();
if (n > 0 && tw->topLevelItem(n - 1)->data(0, geopro::app::kDsLoadMoreRole).toBool())
delete tw->takeTopLevelItem(n - 1);
};
// total = 根节点总数控制器按根分页loaded 也按「第一层节点(根)」计 → 加载更多/页签数一致。
auto addTreeLoadMore = [](QTreeWidget* tw, int total) {
int loaded = 0;
for (int i = 0; i < tw->topLevelItemCount(); ++i)
if (!tw->topLevelItem(i)->data(0, geopro::app::kDsLoadMoreRole).toBool()) ++loaded;
if (loaded < total) {
auto* m = new QTreeWidgetItem(tw);
m->setText(0, QStringLiteral("加载更多(%1/%2").arg(loaded).arg(total));
m->setData(0, geopro::app::kDsLoadMoreRole, true);
m->setTextAlignment(0, Qt::AlignCenter);
}
return loaded;
};
QObject::connect(topBar, &geopro::app::TopBar::workspaceSwitchRequested, &nav,
&geopro::controller::WorkbenchNavController::switchWorkspace);
// 切换到「不同项目」时先清空中央区,避免新项目残留旧项目的三栏数据与 VTK 渲染。
// 仅真正换项目用delete-refresh 等 switchProject(currentProjectId) 不走此处,避免误清)。
auto clearCentral = [sceneCtrl, emptyState, checkedProfiles, checkedAnalysis,
pushChecked, lastSourceRows, refreshAnalysis, checkedSliceIds,
syncSlices, basemap, sceneView]() {
// 数据源清空 → 5 段 + col2D 清空refreshAnalysis 内 setBuckets/dim2D客户端三维体仍驻留
*lastSourceRows = {};
refreshAnalysis();
// 勾选集清空并下发空到 VTK帘面/体素/切片/2D 足迹全部撤场)。
checkedProfiles->clear();
checkedAnalysis->clear();
checkedSliceIds->clear();
pushChecked(); // setCheckedDatasets({}) → 帘面/体素清空
syncSlices(); // 切片随空勾选调和
sceneCtrl->setChecked2DDatasets({}); // 2D 足迹显式撤场(与 col2D 空勾选双保险)
// 复位重锚标志:增量勾选不走 clear(),不复位则旧标志残留 → 新项目数据不重锚 →
// onFrameReanchored 不触发 → 下面 hide() 的底图永不再显。复位后新项目首个数据重锚→重显。
sceneView->resetFrameAnchor();
basemap->hide(); // 底图瓦片清空(锚在旧项目位置;新项目数据到来 re-anchor 时按新位置重显)
// 空状态浮层恢复(对象树勾选会随 structureLoaded 重建而清,无需手动)。
emptyState->setVisible(true);
};
QObject::connect(topBar, &geopro::app::TopBar::projectSwitchRequested, &nav,
[&nav, clearCentral](const QString& id) {
if (id != nav.currentProjectId()) clearCentral(); // 真正换项目才清
nav.switchProject(id);
});
// 退出登录:清除记住的凭证(QtKeychain+QSettings) → 重启应用回到登录页。
QObject::connect(topBar, &geopro::app::TopBar::logoutRequested, &window, []() {
geopro::app::forgetSession();
QProcess::startDetached(QCoreApplication::applicationFilePath(),
QCoreApplication::arguments().mid(1));
qApp->quit();
});
// 设置:点齿轮 → 打开设置对话框(外观/关于)。
QObject::connect(topBar, &geopro::app::TopBar::settingsRequested, &window, [&window]() {
geopro::app::SettingsDialog dlg(&window);
dlg.exec();
});
QObject::connect(topBar, &geopro::app::TopBar::allProjectsRequested, &window,
[&projectRepo, &nav, topBar, &window, clearCentral]() {
auto* dlg = new geopro::app::ProjectListDialog(projectRepo, &window);
dlg->setAttribute(Qt::WA_DeleteOnClose);
QObject::connect(dlg, &geopro::app::ProjectListDialog::projectChosen, &nav,
[&nav, topBar, clearCentral](const QString& id,
const QString& name) {
topBar->setProjectButtonText(name);
if (id != nav.currentProjectId())
clearCentral(); // 真正换项目才清
nav.switchProject(id);
});
dlg->exec();
});
QObject::connect(objectTree, &geopro::app::ObjectTreePanel::objectClicked, &nav,
&geopro::controller::WorkbenchNavController::selectObject);
QObject::connect(objectTree, &geopro::app::ObjectTreePanel::checkedTmsChanged, &nav,
&geopro::controller::WorkbenchNavController::setCheckedTms);
// 单击对象 → 对象属性面板渲染可编辑表单projectId 取当前项目;项目根只读占位)。
QObject::connect(objectTree, &geopro::app::ObjectTreePanel::objectSelectedForEdit, objAttrView,
[objAttrView, objectTree, &nav](const QString& id, int confType,
const QString& typeId, const QString& name,
bool isRoot) {
objAttrView->loadObject(nav.currentProjectId(), typeId, id, confType, name,
isRoot, objectTree->parentObjectId(id));
});
// 当前选中的 TM idconfType==2 时记录,其它选中清空):数据集面板「上传」按钮据此定父对象。
auto currentTmId = std::make_shared<QString>();
QObject::connect(objectTree, &geopro::app::ObjectTreePanel::objectClicked, &window,
[currentTmId](const QString& objectId, int confType) {
*currentTmId = (confType == 2) ? objectId : QString();
});
// 切项目/重建结构 → 旧选中 TM 失效,清空(避免「上传」按钮误用跨项目的 TM
QObject::connect(&nav, &geopro::controller::WorkbenchNavController::structureLoaded, &window,
[currentTmId](const QString&, const std::vector<geopro::data::StructNode>&) {
currentTmId->clear();
});
// ── 交互操作接线(对象/数据集视图:右键菜单 + 快速筛选 + 增删2D/3D 相关占位)────────
auto* anomalyTabGroup = anomalyPanel.tabGroup; // 捕指针anomalyPanel 为局部,勿按引用捕获)
// 浮动轻提示(规范 §7.7 Toast底部居中浮出小卡片window 生命周期覆盖整个会话,按引用捕获安全)。
auto toast = [&window](const QString& msg) { geopro::app::showToast(&window, msg); };
// 表头操作按钮定位器:按 glyphId 在已包装面板表头里找具体 QToolButton。
auto findHeaderAction = [](QWidget* box, geopro::app::Glyph g) -> QToolButton* {
const int gid = static_cast<int>(g);
for (auto* b : box->findChildren<QToolButton*>(QStringLiteral("panelAction")))
if (b->property("glyphId").toInt() == gid) return b;
return nullptr;
};
// ── 全屏切换VTK视图 / 数据详情 表头右上角「全屏」按钮 ──────────────────────────
// 点击 → 目标 dock 全屏(隐藏其余所有 dock再点 → 还原(全部显示)。
// 使用 ADS CDockWidget::toggleView(bool) 控制可见性(标准 ADS APIv4+)。
{
const QList<ads::CDockWidget*> allDocks{vtkDock, detailDock, leftDock, datasetDock,
rightDock, propDock};
auto applyFullscreen = [](ads::CDockWidget* target,
const QList<ads::CDockWidget*>& all, bool on) {
for (ads::CDockWidget* d : all) {
if (d == target) continue;
d->toggleView(!on); // on=进入全屏→隐藏其它; off=还原→全部显示
}
};
auto* vtkFsBtn = findHeaderAction(viewHeader, geopro::app::Glyph::Fullscreen);
auto* detailFsBtn = findHeaderAction(detailHeader, geopro::app::Glyph::Fullscreen);
if (vtkFsBtn) {
vtkFsBtn->setCheckable(true);
QObject::connect(vtkFsBtn, &QToolButton::toggled, &window,
[applyFullscreen, vtkDock, allDocks, detailFsBtn, drawer](bool on) {
if (on && detailFsBtn && detailFsBtn->isChecked()) {
QSignalBlocker b(detailFsBtn);
detailFsBtn->setChecked(false);
}
// VTK 全屏含左侧三栏(drawer 本就在 vtkDock 内):进入时确保展开可见。
if (on) drawer->expand();
applyFullscreen(vtkDock, allDocks, on);
});
}
if (detailFsBtn) {
detailFsBtn->setCheckable(true);
QObject::connect(detailFsBtn, &QToolButton::toggled, &window,
[applyFullscreen, detailDock, allDocks, vtkFsBtn](bool on) {
if (on && vtkFsBtn && vtkFsBtn->isChecked()) {
QSignalBlocker b(vtkFsBtn);
vtkFsBtn->setChecked(false);
}
applyFullscreen(detailDock, allDocks, on);
});
}
}
// 对象树右键菜单动作路由。
QObject::connect(
objectTree, &geopro::app::ObjectTreePanel::contextActionRequested, &window,
[&nav, &projectRepo, &window, anomalyTabGroup, toast, objAttrView, objectTree](
const QString& action, const QString& id, int confType, const QString& typeId,
const QString& name) {
if (action == QStringLiteral("properties")) {
nav.selectObject(id, confType);
// 右键「属性」:用可编辑面板渲染(与左键单击同口径)。
objAttrView->loadObject(nav.currentProjectId(), typeId, id, confType, name, false,
objectTree->parentObjectId(id));
if (anomalyTabGroup)
if (auto* b = anomalyTabGroup->button(1)) b->click(); // 切到「对象属性」页签
} else if (action == QStringLiteral("exceptionDetail")) {
nav.showObjectExceptions(id, confType);
if (anomalyTabGroup)
if (auto* b = anomalyTabGroup->button(0)) b->click(); // 切到「对象异常」页签
} else if (action == QStringLiteral("delete")) {
const auto r = QMessageBox::question(
&window, QStringLiteral("删除确认"),
QStringLiteral("确定删除「%1」该操作不可撤销。").arg(name),
QMessageBox::Yes | QMessageBox::No, QMessageBox::No);
if (r == QMessageBox::Yes) nav.deleteObject(id, confType);
} else if (action == QStringLiteral("newTm")) {
// 新建 TM对话框拉 tmList(全局方法类型)选类型 → getDynamicForm(type=2) → POST /tmObject。
// 父对象:在 GS/项目根上=该节点;在 TM 上=其父 GS/根(即新建同级 TM
const QString tmParent =
(confType == 2) ? objectTree->parentObjectId(id) : id;
auto* dlg = new geopro::app::ObjectFormDialog(projectRepo, nav.currentProjectId(),
&window);
dlg->setAttribute(Qt::WA_DeleteOnClose);
dlg->newTm(tmParent);
QObject::connect(dlg, &geopro::app::ObjectFormDialog::submitted, &window,
[&nav, toast](int) {
toast(QStringLiteral("新建成功"));
nav.switchProject(nav.currentProjectId());
});
dlg->open();
} else if (action == QStringLiteral("newGs")) {
// 新建 GS对话框拉 gsList 选类型 → getDynamicForm(type=1) → POST /gsObject。
// 父对象 = 右键所在节点(GS/项目根) id。
auto* dlg = new geopro::app::ObjectFormDialog(projectRepo, nav.currentProjectId(),
&window);
dlg->setAttribute(Qt::WA_DeleteOnClose);
dlg->newGs(id);
QObject::connect(dlg, &geopro::app::ObjectFormDialog::submitted, &window,
[&nav, toast](int) {
toast(QStringLiteral("新建成功"));
nav.switchProject(nav.currentProjectId());
});
dlg->open();
} else if (action == QStringLiteral("importDs")) {
// 导入 DSTM 右键 → 选数据类型/脚本/文件 → checkImport → importmultipart
auto* dlg = new geopro::app::ImportDatasetDialog(projectRepo, nav.currentProjectId(),
id, &window);
dlg->setAttribute(Qt::WA_DeleteOnClose);
QObject::connect(dlg, &geopro::app::ImportDatasetDialog::imported, &window,
[&nav, toast]() {
toast(QStringLiteral("导入成功"));
nav.switchProject(nav.currentProjectId());
});
dlg->open();
} else if (action == QStringLiteral("showHide") || action == QStringLiteral("locate")) {
toast(QStringLiteral("「%1」需要二维/三维视图,开发中").arg(name));
} else {
toast(QStringLiteral("该功能开发中,即将接入"));
}
});
// 对象属性面板保存成功 → toast + 刷新结构(重载当前项目,回填最新属性)。
QObject::connect(objAttrView, &geopro::app::ObjectAttrPanel::saved, &window,
[&nav, toast]() {
toast(QStringLiteral("保存成功"));
nav.switchProject(nav.currentProjectId());
});
// 增删改结果 → 状态栏反馈(成功后控制器已自行刷新)。
QObject::connect(&nav, &geopro::controller::WorkbenchNavController::mutationSucceeded, &window,
[toast](const QString& msg) { toast(msg); });
QObject::connect(&nav, &geopro::controller::WorkbenchNavController::mutationFailed, &window,
[&window](const QString& msg) {
auto* sb = window.statusBar();
sb->setStyleSheet(QStringLiteral("QStatusBar{color:%1;}")
.arg(geopro::app::token("status/danger")));
sb->showMessage(QStringLiteral("操作失败:%1").arg(msg), 6000);
QTimer::singleShot(6000, sb, [sb]() { sb->setStyleSheet(QString()); });
});
// 对象树表头「筛选」按钮 → 快速筛选弹出菜单(按类型批量勾选/反选 TM
if (auto* objFilterBtn = findHeaderAction(objectBox, geopro::app::Glyph::Filter)) {
objFilterBtn->setToolTip(QStringLiteral("快速筛选"));
QObject::connect(objFilterBtn, &QToolButton::clicked, objectTree,
[objectTree, objFilterBtn]() {
QMenu m(objectTree);
m.addAction(QStringLiteral("全选测线"), objectTree,
[objectTree]() { objectTree->setAllTmsChecked(true); });
m.addAction(QStringLiteral("取消全选"), objectTree,
[objectTree]() { objectTree->setAllTmsChecked(false); });
m.addAction(QStringLiteral("反选"), objectTree,
[objectTree]() { objectTree->invertTmChecks(); });
m.exec(objFilterBtn->mapToGlobal(QPoint(0, objFilterBtn->height())));
});
}
// 对象树表头「新建对象」按钮 → 小菜单(新建检测对象/新建方法对象,复用右键 newGs/newTm 流程)。
// 父对象 = 当前选中节点;未选中则取项目根(由 ObjectTreePanel::currentParentForNew 决定)。
if (auto* objAddBtn = findHeaderAction(objectBox, geopro::app::Glyph::Plus)) {
objAddBtn->setToolTip(QStringLiteral("新建对象"));
QObject::connect(
objAddBtn, &QToolButton::clicked, objectTree,
[objAddBtn, objectTree, &projectRepo, &nav, &window, toast]() {
const QString parentId = objectTree->currentParentForNew();
if (parentId.isEmpty()) {
toast(QStringLiteral("请先选择项目"));
return;
}
// 与右键 newGs/newTm 完全一致的对话框流程(文案统一:新建检测对象/新建方法对象)。
auto openForm = [&projectRepo, &nav, &window, toast, parentId](bool gs) {
auto* dlg = new geopro::app::ObjectFormDialog(projectRepo, nav.currentProjectId(),
&window);
dlg->setAttribute(Qt::WA_DeleteOnClose);
if (gs) dlg->newGs(parentId); else dlg->newTm(parentId);
QObject::connect(dlg, &geopro::app::ObjectFormDialog::submitted, &window,
[&nav, toast](int) {
toast(QStringLiteral("新建成功"));
nav.switchProject(nav.currentProjectId());
});
dlg->open();
};
// 选 项目根/GS → 可新建 GS+TM。选中 TM 时按钮已被禁用(测线下不能新增对象),
// 故此处仅处理非 TM父对象由 currentParentForNew() 给出GS/根→自身、未选→根)。
QMenu m(objectTree);
m.addAction(QStringLiteral("新建检测对象"), objectTree,
[openForm]() { openForm(true); });
m.addAction(QStringLiteral("新建方法对象"), objectTree,
[openForm]() { openForm(false); });
m.exec(objAddBtn->mapToGlobal(QPoint(0, objAddBtn->height())));
});
// 选中 TM方法对象confType=2→ 禁用「新增」:测线下不能新增对象。
// 选根/GS 恢复可用;切项目/重载结构后选中清空,亦恢复可用。
QObject::connect(objectTree, &geopro::app::ObjectTreePanel::objectSelectedForEdit, objAddBtn,
[objAddBtn](const QString&, int confType, const QString&, const QString&,
bool) { objAddBtn->setEnabled(confType != 2); });
QObject::connect(&nav, &geopro::controller::WorkbenchNavController::structureLoaded, objAddBtn,
[objAddBtn](const QString&, const std::vector<geopro::data::StructNode>&) {
objAddBtn->setEnabled(true);
});
}
// 模型/插件目录:全局一次性拉取并缓存,供数据集右键「插件」子菜单同步填充。
auto modelsCache = std::make_shared<std::vector<geopro::data::ModelInfo>>();
{
auto* mReq = projectRepo.listModelsAsync();
QObject::connect(mReq, &geopro::data::NavRequest::done, &window,
[modelsCache](const QVariant& v) {
*modelsCache = qvariant_cast<std::vector<geopro::data::ModelInfo>>(v);
});
}
// ── 数据集列表右键菜单(数据集详情 / 属性 / 插件 / 导出 / 删除)──
datasetList->setContextMenuPolicy(Qt::CustomContextMenu);
QObject::connect(
datasetList, &QWidget::customContextMenuRequested, datasetList,
[datasetList, &detailCtrl, &nav, &projectRepo, &window, toast, modelsCache, propView](
const QPoint& pos) {
QTreeWidgetItem* item = datasetList->itemAt(pos);
if (!item || item->data(0, geopro::app::kDsLoadMoreRole).toBool()) return;
const QString dsId = item->data(0, geopro::app::kDsIdRole).toString();
if (dsId.isEmpty()) return;
const QString ddCode = item->data(0, geopro::app::kDsDdCodeRole).toString();
const QString dsName = item->data(0, geopro::app::kDsNameRole).toString();
// tmObjectId白化 structParentId从行读出透传使白化模板列表非空。
const QString tmObjectId = item->data(0, geopro::app::kDsTmObjectIdRole).toString();
QMenu menu(datasetList);
menu.addAction(QStringLiteral("数据集详情"), datasetList,
[&detailCtrl, dsId, ddCode, dsName, tmObjectId]() {
detailCtrl.openDataset(dsId, ddCode, dsName, tmObjectId);
});
menu.addAction(QStringLiteral("属性"), datasetList, [&nav, dsId]() {
nav.selectDataset(dsId); // 只读元字段
});
menu.addSeparator();
QMenu* plugins = menu.addMenu(QStringLiteral("插件"));
if (modelsCache->empty()) {
plugins->addAction(QStringLiteral("(模型列表加载中…)"))->setEnabled(false);
} else {
for (const auto& m : *modelsCache) {
const QString mn = QString::fromStdString(m.scriptName);
plugins->addAction(mn, datasetList, [toast, mn]() {
toast(QStringLiteral("插件「%1」调用待接入").arg(mn));
});
}
}
menu.addAction(QStringLiteral("导出…"), datasetList, [toast]() {
// ds 右键上下文拿不到 tmTypeBaseConfId空配置打开会直接「加载模板失败」。
// 暂不开对话框提示改用批量导出ExportDatasetDialog 保留供有 confId 场景调用)。
toast(QStringLiteral("从此处导出暂不可用(缺方法配置),请从批量导出使用"));
});
menu.addSeparator();
menu.addAction(QStringLiteral("删除"), datasetList, [&nav, &window, dsId, dsName]() {
const auto r = QMessageBox::question(
&window, QStringLiteral("删除确认"),
QStringLiteral("确定删除数据集「%1」该操作不可撤销。").arg(dsName),
QMessageBox::Yes | QMessageBox::No, QMessageBox::No);
if (r == QMessageBox::Yes) nav.deleteDataset(dsId);
});
menu.exec(datasetList->viewport()->mapToGlobal(pos));
});
// 数据集表头「筛选」按钮 → 按类型 + 创建日期快速筛选(客户端隐藏不匹配行;状态跨弹出保留)。
if (auto* dsFilterBtn = findHeaderAction(datasetBox, geopro::app::Glyph::Filter)) {
dsFilterBtn->setToolTip(QStringLiteral("快速筛选"));
auto hiddenTypes = std::make_shared<QSet<QString>>(); // 当前被取消勾选的类型
auto minDate = std::make_shared<QDate>(); // 创建日期下限(无效=不限)
auto reapply = [datasetList, hiddenTypes, minDate]() {
QSet<QString> visible;
for (const QString& x : geopro::app::collectDatasetTypeNames(datasetList))
if (!hiddenTypes->contains(x)) visible.insert(x);
geopro::app::applyDatasetFilter(datasetList, visible, *minDate);
};
QObject::connect(
dsFilterBtn, &QToolButton::clicked, datasetList,
[datasetList, dsFilterBtn, hiddenTypes, minDate, reapply]() {
const QStringList types = geopro::app::collectDatasetTypeNames(datasetList);
QMenu m(datasetList);
if (types.isEmpty()) {
m.addAction(QStringLiteral("(当前无数据集)"))->setEnabled(false);
}
for (const QString& t : types) {
QAction* a = m.addAction(t);
a->setCheckable(true);
a->setChecked(!hiddenTypes->contains(t));
QObject::connect(a, &QAction::toggled, datasetList,
[hiddenTypes, reapply, t](bool on) {
if (on) hiddenTypes->remove(t);
else hiddenTypes->insert(t);
reapply();
});
}
m.addSeparator();
QMenu* dm = m.addMenu(QStringLiteral("创建日期"));
dm->addAction(QStringLiteral("全部"), datasetList,
[minDate, reapply]() { *minDate = QDate(); reapply(); });
dm->addAction(QStringLiteral("近 7 天"), datasetList, [minDate, reapply]() {
*minDate = QDate::currentDate().addDays(-7);
reapply();
});
dm->addAction(QStringLiteral("近 30 天"), datasetList, [minDate, reapply]() {
*minDate = QDate::currentDate().addDays(-30);
reapply();
});
m.addAction(QStringLiteral("清除筛选"), datasetList,
[hiddenTypes, minDate, reapply]() {
hiddenTypes->clear();
*minDate = QDate();
reapply();
});
m.exec(dsFilterBtn->mapToGlobal(QPoint(0, dsFilterBtn->height())));
});
}
// 数据集表头「上传」按钮 → 导入数据集(复用 ImportDatasetDialog父对象=当前选中 TM
// 未选中 TM 时按钮禁用并提示先选方法对象(随选中变化动态启停)。
if (auto* dsUploadBtn = findHeaderAction(datasetBox, geopro::app::Glyph::Upload)) {
dsUploadBtn->setToolTip(QStringLiteral("导入数据集(需先选方法对象)"));
auto syncUploadEnabled = [dsUploadBtn, currentTmId]() {
const bool hasTm = !currentTmId->isEmpty();
dsUploadBtn->setEnabled(hasTm);
dsUploadBtn->setToolTip(hasTm ? QStringLiteral("导入数据集…")
: QStringLiteral("导入数据集(需先选方法对象)"));
};
syncUploadEnabled(); // 初始:未选 TM → 禁用
QObject::connect(objectTree, &geopro::app::ObjectTreePanel::objectClicked, dsUploadBtn,
[syncUploadEnabled](const QString&, int) { syncUploadEnabled(); });
QObject::connect(&nav, &geopro::controller::WorkbenchNavController::structureLoaded,
dsUploadBtn,
[syncUploadEnabled](const QString&,
const std::vector<geopro::data::StructNode>&) {
syncUploadEnabled();
});
QObject::connect(
dsUploadBtn, &QToolButton::clicked, &window,
[currentTmId, &projectRepo, &nav, &window, toast]() {
if (currentTmId->isEmpty()) {
toast(QStringLiteral("请先选择方法对象TM再导入数据集"));
return;
}
auto* dlg = new geopro::app::ImportDatasetDialog(
projectRepo, nav.currentProjectId(), *currentTmId, &window);
dlg->setAttribute(Qt::WA_DeleteOnClose);
QObject::connect(dlg, &geopro::app::ImportDatasetDialog::imported, &window,
[&nav, toast]() {
toast(QStringLiteral("导入成功"));
nav.switchProject(nav.currentProjectId());
});
dlg->open();
});
}
// 控制器异常/数据集表单 → 被动面板。
// 对象属性改为可编辑面板:由 ObjectTreePanel::objectSelectedForEdit 直接驱动(见下),
// 不再消费只读的 objectDetailLoaded。
QObject::connect(&nav, &geopro::controller::WorkbenchNavController::exceptionTreeLoaded,
exceptionPanel,
[exceptionPanel, anomalyBadge](
const std::vector<geopro::data::ObjectExceptionGroup>& groups, int total) {
exceptionPanel->setGroups(groups);
if (anomalyBadge) {
anomalyBadge->setText(QString::number(total));
anomalyBadge->setVisible(total > 0);
}
});
QObject::connect(&nav, &geopro::controller::WorkbenchNavController::datasetDetailLoaded, propView,
[propView](const geopro::data::DynamicForm& form) { propView->setForm(form); });
QObject::connect(&nav, &geopro::controller::WorkbenchNavController::workspacesLoaded, topBar,
[topBar](const std::vector<geopro::data::Workspace>& list, const QString& cur) {
topBar->setWorkspaces(list, cur);
});
QObject::connect(&nav, &geopro::controller::WorkbenchNavController::projectsLoaded, topBar,
[topBar](const std::vector<geopro::data::ProjectSummary>& list,
const QString& cur, int total) {
topBar->setProjects(list, cur, total > static_cast<int>(list.size()));
});
QObject::connect(&nav, &geopro::controller::WorkbenchNavController::structureLoaded, objectTree,
[objectTree, datasetList, fileList, datasetTitle, datasetTabs, exceptionPanel,
objAttrView, propView, anomalyBadge](
const QString& projectName,
const std::vector<geopro::data::StructNode>& nodes) {
objectTree->setStructure(projectName, nodes);
datasetList->clear();
fileList->clear();
exceptionPanel->showMessage(QStringLiteral("(勾选对象后显示其异常 / 异常体)"));
objAttrView->showMessage(QStringLiteral("(选中对象后显示其属性)"));
propView->showMessage(QStringLiteral("(单击数据集查看属性)"));
if (anomalyBadge) anomalyBadge->setVisible(false);
if (datasetTitle) datasetTitle->setText(QStringLiteral("数据集"));
datasetTabs->setTabText(0, QStringLiteral("数据"));
datasetTabs->setTabText(1, QStringLiteral("文件"));
});
QObject::connect(&nav, &geopro::controller::WorkbenchNavController::datasetsLoaded, datasetList,
[removeTreeLoadMore, addTreeLoadMore, datasetList, datasetTitle, datasetTabs](
const QString& tmObjectId, const std::vector<geopro::data::DsRow>& rows,
int total, bool append) {
removeTreeLoadMore(datasetList);
// tmObjectId本批所属 TM 对象 id存入每项 → 白化对话框透传用structParentId
geopro::app::populateDatasetList(datasetList, rows, append, tmObjectId);
const int loaded = addTreeLoadMore(datasetList, total);
if (datasetTitle) datasetTitle->setText(QStringLiteral("数据集"));
datasetTabs->setTabText(
0, total > 0 ? QStringLiteral("数据 (%1/%2)").arg(loaded).arg(total)
: QStringLiteral("数据"));
});
QObject::connect(&nav, &geopro::controller::WorkbenchNavController::filesLoaded, fileList,
[removeLoadMore, addLoadMore, fileList, datasetTabs](
const QString&, const std::vector<geopro::data::DsRow>& rows, int total,
bool append) {
removeLoadMore(fileList);
geopro::app::populateFileList(fileList, rows, append);
const int loaded = addLoadMore(fileList, total);
datasetTabs->setTabText(
1, total > 0 ? QStringLiteral("文件 (%1/%2)").arg(loaded).arg(total)
: QStringLiteral("文件"));
});
QObject::connect(fileList, &QListWidget::itemClicked, fileList,
[&nav](QListWidgetItem* item) {
if (item->data(geopro::app::kDsLoadMoreRole).toBool()) nav.loadMoreFiles();
});
QObject::connect(&nav, &geopro::controller::WorkbenchNavController::loadFailed, objectTree,
[objectTree, &window](const QString& stage, const QString& msg) {
if (stage == QStringLiteral("structure") ||
stage == QStringLiteral("projects"))
objectTree->showMessage(QStringLiteral("加载失败:%1").arg(msg));
// 状态栏错误反馈:临时染 danger 红8s 后随消息超时还原全局中性色。
auto* sb = window.statusBar();
sb->setStyleSheet(QStringLiteral("QStatusBar{color:%1;}")
.arg(geopro::app::token("status/danger")));
sb->showMessage(QStringLiteral("加载失败(%1%2").arg(stage, msg), 8000);
QTimer::singleShot(8000, sb, [sb]() { sb->setStyleSheet(QString()); });
});
QObject::connect(&nav, &geopro::controller::WorkbenchNavController::busyChanged, &window,
[](bool busy) {
if (busy)
QApplication::setOverrideCursor(Qt::WaitCursor);
else
QApplication::restoreOverrideCursor();
});
// 底部状态栏常驻显示坐标系与世界系原点wayfinding用户随时知道当前空间基准
window.statusBar()->showMessage(
QStringLiteral("就绪 | 坐标系 %1 | 世界系原点 %2, %3")
.arg(QString::fromUtf8(kProjectCrs))
.arg(lat0, 0, 'f', 5)
.arg(lon0, 0, 'f', 5));
// ── dock 布局/窗口几何持久化 ──────────────────────────────────────────
// 恢复上次的停靠布局与窗口几何(须在全部 dock 创建后ADS 按 dock 标题作键匹配)。
{
const QSettings settings;
const QByteArray geo = settings.value(QStringLiteral("ui/geometry")).toByteArray();
if (!geo.isEmpty()) window.restoreGeometry(geo);
// 注意ADS 按 dock 唯一名作键。改过 dock 名后旧布局会失配 → bump 此键丢弃旧布局,
// 回落到下方 addDockWidget 的默认排布(再改 dock 名时同样要 bump 版本号)。
const QByteArray dockState = settings.value(QStringLiteral("ui/dockState_v3")).toByteArray();
if (!dockState.isEmpty()) {
dockManager->restoreState(dockState);
// restoreState 重建停靠区会重新显示 ADS 标题栏,再隐藏一次保持“无双标题”。
hideDockTitleBars();
}
}
// 退出时保存当前布局与几何aboutToQuit 早于 window 析构dockManager/window 仍存活)。
QObject::connect(qApp, &QCoreApplication::aboutToQuit, dockManager, [dockManager, &window]() {
QSettings settings;
settings.setValue(QStringLiteral("ui/geometry"), window.saveGeometry());
settings.setValue(QStringLiteral("ui/dockState_v3"), dockManager->saveState());
});
}
} // namespace
namespace {
// 顶层异常护栏:任何 slot / 事件处理器抛出的 C++ 异常都会经过 QApplication::notify。
// 默认 Qt 不允许异常穿透事件循环 → terminate崩溃。这里拦截 + 记录(异常信息 + 接收者
// 对象类名 + 事件类型,足以定位崩点),并吞掉以**保证后端故障等异常不致整个客户端退出**。
// 注:吞异常后该次事件处理中断,可能留下局部不一致;但"不崩 + 有日志"优先于直接退出。
class GuardedApplication : public QApplication {
public:
using QApplication::QApplication;
bool notify(QObject* receiver, QEvent* e) override {
try {
return QApplication::notify(receiver, e);
} catch (const std::exception& ex) {
qCritical("[guard] 拦截未捕获异常: %s | type=%s | receiver=%s | event=%d",
ex.what(), typeid(ex).name(),
receiver ? receiver->metaObject()->className() : "null",
e ? static_cast<int>(e->type()) : 0);
} catch (...) {
qCritical("[guard] 拦截未捕获非 std 异常 | receiver=%s | event=%d",
receiver ? receiver->metaObject()->className() : "null",
e ? static_cast<int>(e->type()) : 0);
}
return false;
}
};
} // namespace
int main(int argc, char* argv[])
{
// Qt WebEngine地图页签的 QWebEngineView必须在 QApplication 构造前初始化,
// 且需启用跨上下文共享 OpenGLQtWebEngine 与 QVTK 同进程共用 GL context避免黑屏/崩溃)。
// AA_ShareOpenGLContexts 须在 QApplication 之前设置QtWebEngineQuick::initialize() 同样须前置。
QCoreApplication::setAttribute(Qt::AA_ShareOpenGLContexts);
QtWebEngineQuick::initialize();
// 高 DPI 缩放采用直通策略:在 125%/150% 等分数缩放下字体/图标按真实比例渲染,更清晰。
// 必须在 QApplication 构造前设置。
QApplication::setHighDpiScaleFactorRoundingPolicy(
Qt::HighDpiScaleFactorRoundingPolicy::PassThrough);
// QVTK 默认 surface format 必须在 QApplication 之前设置(全局一次,两个 QVTK widget 共用)。
QSurfaceFormat::setDefaultFormat(QVTKOpenGLStereoWidget::defaultFormat());
GuardedApplication app(argc, argv); // 顶层异常护栏slot/事件里的异常不致客户端崩溃
// 异步 ApiCall::finished 等信号携带 ApiResponse注册元类型以支持跨 QueuedConnection 传递
// (当前详情链路为同线程 DirectConnection非严格必需但作防御性注册见 spec §5.1)。
qRegisterMetaType<geopro::net::ApiResponse>();
// 组织/应用名QSettings 持久化dock 布局、登录记忆等)按此定位存储位置。
QCoreApplication::setOrganizationName(QStringLiteral("Geomative"));
QCoreApplication::setApplicationName(QStringLiteral("Geopro3"));
// 日志 + 崩溃捕获:尽早安装(依赖上面的 Org/App 名定位日志目录)。生产桌面端问题可回溯。
geopro::app::initLogging();
// 主题与字号:应用持久化偏好(跟随系统/浅/深 + 字号缩放),再套用 Fusion + 全局 QSS + 调色板。
// 在弹登录窗之前完成 → 登录页与主页 明暗/字号 统一。
geopro::app::applyPersistedThemeMode();
geopro::app::applyPersistedFontScale();
geopro::app::applyThemeMode(app, geopro::app::isDarkTheme());
// PROJ 数据(proj.db)定位:体素配准的 CrsTransform 需要。优先已设环境变量;
// 否则按 exe 旁 / 构建目录候选设置。部署时须随包附带 proj 数据并设此变量。
if (qEnvironmentVariableIsEmpty("PROJ_DATA")) {
const QString appDir = QCoreApplication::applicationDirPath();
const QStringList candidates = {
appDir + "/proj",
appDir + "/../../vcpkg_installed/x64-windows/share/proj",
QStringLiteral(
"D:/Git/lanbingtech/geopro/build/release/vcpkg_installed/x64-windows/share/proj"),
};
for (const auto& c : candidates) {
if (QFile::exists(c + "/proj.db")) {
qputenv("PROJ_DATA", c.toUtf8());
break;
}
}
}
// 网络层:共享会话 ApiClient + 登录编排 AuthService。
// RSA 登录公钥内嵌于二进制qrc :/keys不依赖外部文件路径——部署到任意机器均可用。
geopro::net::ApiClient api(QStringLiteral("http://tenant.geomative.cn/pop-api"));
std::string pem;
{
QFile pemFile(QStringLiteral(":/keys/rsa_public_key.pem"));
if (pemFile.open(QIODevice::ReadOnly)) {
const QByteArray bytes = pemFile.readAll();
pem.assign(bytes.constData(), static_cast<size_t>(bytes.size()));
}
}
geopro::net::AuthService auth(api, pem);
// 记住登录:若上次勾选「记住」且未超 30 天,凭证库里有有效 token → 免登录直接进。
QString token = geopro::app::recallValidToken(30);
if (token.isEmpty()) {
geopro::app::LoginWindow login(auth);
if (login.exec() != QDialog::Accepted) return 0;
token = login.token();
if (login.remember())
geopro::app::rememberSession(token); // 安全存 token + 时间戳
else
geopro::app::forgetSession(); // 未勾选:清除旧记忆
}
api.setToken(token); // 注入 token 供后续 API 使用
// 本地样本演示数据目录优先随安装包目录exe 旁 sampledata/),回退源码树开发路径。
// 不依赖写死的开发机绝对路径——部署到任意机器均可用。
const std::string sampleDir = []() -> std::string {
const QString appDir = QCoreApplication::applicationDirPath();
const QStringList candidates = {
appDir + QStringLiteral("/sampledata"),
QStringLiteral("D:/Git/lanbingtech/geopro/docs/剖面网格数据的色阶数据2等文件"),
};
for (const auto& c : candidates) {
if (QDir(c).exists()) {
const QByteArray u8 = (c + QStringLiteral("/")).toUtf8();
return std::string(u8.constData(), static_cast<size_t>(u8.size()));
}
}
// 都不存在:返回首选路径,交由下游报错并被启动防护捕获(提示安装不完整)。
const QByteArray u8 = (candidates.front() + QStringLiteral("/")).toUtf8();
return std::string(u8.constData(), static_cast<size_t>(u8.size()));
}();
// 登录成功 → 构建并显示工作台。
geopro::data::LocalSampleRepository repo(sampleDir);
// 导航仓储 + 控制器(接口/逻辑层):用同一共享会话 ApiClient。
geopro::data::ApiProjectRepository projectRepo(api);
geopro::controller::WorkbenchNavController nav(projectRepo);
// 数据详情仓储 + 控制器(接真实反演 API同一共享会话 ApiClient。
geopro::data::ApiDatasetRepository datasetRepo(api);
// 色阶模板仓储lvl 模板 + clr 色阶):同一共享会话 ApiClient注入 2D/3D 色阶编辑器。
geopro::data::ApiColorTemplateRepository colorTplRepo(api);
// 反演命令仓储(反演运算 / 生成视电阻率 / 模型列表 / 动态表单):同一共享会话 ApiClient。
geopro::data::ApiDatasetCommandRepository cmdRepo(api);
// 策略注册表须比 detailCtrl 活得久(同作用域、在前声明)。
geopro::controller::ChartStrategyRegistry chartRegistry;
chartRegistry.add(std::make_unique<geopro::app::ErtInversionStrategy>());
chartRegistry.add(std::make_unique<geopro::app::MeasurementStrategy>());
chartRegistry.add(std::make_unique<geopro::app::GrMeasurementStrategy>());
chartRegistry.add(std::make_unique<geopro::app::TrajectoryStrategy>());
chartRegistry.add(std::make_unique<geopro::app::GridStrategy>());
geopro::controller::DatasetDetailController detailCtrl(datasetRepo, chartRegistry);
// ── 外壳:标准 QMainWindow原生标题栏。buildWorkbench 直接用其
// setCentralWidget/setMenuWidget/statusBar 承载工作台。
const QString kTitle = QStringLiteral("Geopro 3.0 — 项目分析视图 (M1)");
auto* window = new QMainWindow;
window->setWindowTitle(kTitle);
window->resize(1280, 800);
window->setMinimumSize(1024, 680);
// 启动防护:工作台构建期间任何同步加载失败(如样本数据缺失)都不应让进程静默退出,
// 而是给出可见错误提示。否则登录后窗口未显示、进程消失,用户无从排查。
try {
buildWorkbench(*window, repo, projectRepo, datasetRepo, colorTplRepo, cmdRepo, nav,
detailCtrl);
} catch (const std::exception& e) {
QMessageBox::critical(
nullptr, QStringLiteral("启动失败"),
QStringLiteral("工作台初始化失败:\n%1\n\n请确认安装完整(样本数据 / 运行库未缺失)。")
.arg(QString::fromUtf8(e.what())));
return 1;
}
// 主题桥ThemeManager 明/暗切换 → 重应用全局 QSS+调色板(标准控件 + ADS内联 chrome 经各自连接)。
QObject::connect(&geopro::app::ThemeManager::instance(), &geopro::app::ThemeManager::changed,
window, [&app]() { geopro::app::applyThemeMode(app, geopro::app::isDarkTheme()); });
// 主题切换快捷键 Ctrl+Shift+T持久化设置→外观 亦可改)。
auto* themeSc = new QShortcut(QKeySequence(QStringLiteral("Ctrl+Shift+T")), window);
QObject::connect(themeSc, &QShortcut::activated, window, [] {
geopro::app::setThemeModePreference(geopro::app::isDarkTheme() ? QStringLiteral("light")
: QStringLiteral("dark"));
});
window->show();
nav.start(); // 进入工作台后拉真实 空间/项目/结构
return app.exec();
}