// M1 工作台(视图重构 Task B):正确产品模型。 // - 左上 对象显示栏:GS→TM(测线,复选框)。勾选测线 → 在中央显示其 dd_section,可多条共存。 // - 左下 数据真实显示栏:单击测线 → 列其采集批次(数据集,tab 数据/文件)。单击采集批次 → 数据详情+异常+属性。 // - 中央「二维地图 / 三维视图」:两个互斥视图(内容不同,不是同一物体换相机)。 // 二维地图 = 对每个勾选数据集 buildSurveyLine(lat/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 彩色散点(buildScatter,x=距离/y=深度,按散点自带色阶上色)。 // 显示异常 = 在上图叠加异常圈定(buildAnomalies,dashed 折线,同纵向夸张对齐)。 // 两者皆平躺俯视正交 + 属性。 // - 右 属性:选中数据集属性文本。 // 世界系:启动 loadGrid("grid1") 取一次,用其 lat/lon 中位/均值作 GeoLocalFrame(全项目共享,保证多视图配准)。 #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "model/ColorScale.hpp" #include "model/Field.hpp" #include "repo/LocalSampleRepository.hpp" #include "ApiClient.hpp" #include "AuthService.hpp" #include "Credential.hpp" #include "Glyphs.hpp" #include "Logging.hpp" #include "PanelHeader.hpp" #include "Theme.hpp" #include "SettingsDialog.hpp" #include "TopBar.hpp" #include "CentralScene.hpp" #include "ProjectListDialog.hpp" #include "WorkbenchNavController.hpp" #include "DatasetDetailController.hpp" #include "panels/chart/ErtInversionStrategy.hpp" #include "panels/chart/MeasurementStrategy.hpp" #include "panels/chart/GrMeasurementStrategy.hpp" #include "api/ApiProjectRepository.hpp" #include "api/ApiDatasetRepository.hpp" #include "panels/ObjectTreePanel.hpp" #include "login/LoginWindow.hpp" #include "panels/DatasetListPanel.hpp" #include "panels/DatasetDetailPanel.hpp" #include "panels/DynamicFormView.hpp" #include "panels/ObjectExceptionPanel.hpp" #include "CameraPreset.hpp" #include "ColorLutBuilder.hpp" #include "Scene.hpp" #include "VoxelFromScatters.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 #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include 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(); const QSize o = overlay_->size(); overlay_->move(host_->x() + (h.width() - o.width()) / 2, host_->y() + (h.height() - o.height()) / 2); 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_; }; // 读取 RSA 公钥 PEM 全文(登录时密码加密用)。读不到返回空串,登录将报错。 std::string readPem(const std::string& path) { std::ifstream in(path, std::ios::binary); if (!in) return {}; std::ostringstream ss; ss << in.rdbuf(); return ss.str(); } // 取 vector 中位数(用于由测线 lat/lon 推世界系原点)。空则返回 0。 double median(std::vector 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]); } // 当前中央视图(默认二维地图)。二维地图=测线红线俯视;三维视图=断面墙。 using geopro::app::ViewMode; // 纵向夸张倍数(Z 基准统一,M-3):全项目共用同一倍数,使 帘面(z) / 体素 / 切片 / // 数据详情剖面(y) / 地形(relief) 的纵向比例一致——避免「剖面×1.5、帘面×3」不一致。 // 单一可调常量:要整体调纵向观感改这一处即可。 constexpr double kVerticalExaggeration = 2.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::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(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(); QObject::connect(vtkWidget, &QObject::destroyed, [scene]() { delete scene; }); vtkNew renderWindow; vtkWidget->setRenderWindow(renderWindow); renderWindow->AddRenderer(scene->renderer()); vtkRenderer* rendererPtr = scene->renderer(); vtkGenericOpenGLRenderWindow* renderWindowPtr = renderWindow.Get(); // 当前视图模式(全局共享,切视图/勾选时据此重建内容)。默认二维地图。 auto viewMode = std::make_shared(ViewMode::Map2D); // 三维图层显隐(由「视图详情」浮层控制)+ 项目 CRS→WGS84(体素配准)。 auto showCurtain = std::make_shared(true); // 帘面,默认显示 auto showVoxel = std::make_shared(false); // 体素,默认关 auto showTerrain = std::make_shared(false); // 地形(DEM+影像),默认关 auto showSlice = std::make_shared(false); // dd_slice 交互切片,默认关 // 持久的切片 widget(挂 interactor,跨重建保活;rebuildCentral 据条件创建/拆除)。 auto slicePlane = std::make_shared>(); std::shared_ptr crs; // PROJ 失败→空→体素层无效(不崩) try { crs = std::make_shared(kProjectCrs, kWgs84); } catch (const std::exception&) { crs.reset(); } // 停靠系统配置(必须在 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& 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; }; // 中央容器:顶部「二维地图/三维视图」工具条 + 下方 QVTK 视图。 auto* centerWidget = new QWidget(); auto* centerLayout = new QVBoxLayout(centerWidget); centerLayout->setContentsMargins(0, 0, 0, 0); centerLayout->setSpacing(0); // 「二维地图/三维视图」分段切换表头:与「异常/属性」面板表头同款(42px 表头底 + 强调色下划线页签)。 auto seg = geopro::app::buildSegmentedHeader( {QStringLiteral("二维地图"), QStringLiteral("三维视图")}, {{geopro::app::Glyph::Collapse, QStringLiteral("折叠")}, {geopro::app::Glyph::Download, QStringLiteral("导出")}}); auto* viewHeader = seg.header; auto* act2D = seg.buttons[0]; auto* act3D = seg.buttons[1]; centerLayout->addWidget(viewHeader); centerLayout->addWidget(vtkWidget, 1); // ──「视图详情」图层浮层(对齐原型 3D 视图左上):浮在 QVTK 之上,控制三维图层显隐。 // 仅三维视图显示;含 帘面 / 体素 勾选(体素=两交叉测线散点配准 IDW 的派生层,正确归宿)。 auto* layerPanel = new QFrame(centerWidget); layerPanel->setFrameShape(QFrame::StyledPanel); geopro::app::applyTokenizedStyleSheet( layerPanel, // 不设 border-radius:浮层是 centerWidget 的子控件,悬于原生 GL 画布上,圆角四角处会 // 露出父级浅底(浅色模式下即四个白直角)。改为直角矩形,不透明底铺满整块,无四角伪影。 QStringLiteral("QFrame{background:{{canvas/bg-soft}};border:1px solid {{canvas/grid}};}" "QCheckBox{padding:2px 1px;color:{{canvas/text}};}" "QCheckBox:disabled{color:{{canvas/text-dim}};}")); auto* layerLayout = new QVBoxLayout(layerPanel); // 浮层内边距取间距令牌:左右 lg(12)、上下 ml(10),对称(原 13/10/15/11 是手调奇数)。 layerLayout->setContentsMargins(geopro::app::space::kLg, geopro::app::space::kMl, geopro::app::space::kLg, geopro::app::space::kMl); layerLayout->setSpacing(geopro::app::space::kSm); auto* layerTitle = new QLabel(QStringLiteral("视图详情")); geopro::app::applyTokenizedStyleSheet( layerTitle, QStringLiteral("font-weight:%1;color:{{canvas/text}};border:none;background:transparent;" "padding-bottom:3px;font-size:%2px;") .arg(geopro::app::type::kWeightSemibold) .arg(geopro::app::scaledPx(geopro::app::type::kTitle))); auto* chkCurtain = new QCheckBox(QStringLiteral("帘面(断面墙)")); chkCurtain->setChecked(true); auto* chkVoxel = new QCheckBox(QStringLiteral("体素(dd_voxel)")); chkVoxel->setChecked(false); auto* chkTerrain = new QCheckBox(QStringLiteral("地形(DEM+影像)")); chkTerrain->setChecked(false); auto* chkSlice = new QCheckBox(QStringLiteral("切片(dd_slice)")); chkSlice->setChecked(false); if (!crs) { // PROJ 不可用 → 体素/切片/地形层(都需配准)禁用并提示 const QString tip = QStringLiteral("PROJ 数据(proj.db)缺失,配准不可用"); chkVoxel->setEnabled(false); chkVoxel->setToolTip(tip); chkTerrain->setEnabled(false); chkTerrain->setToolTip(tip); chkSlice->setEnabled(false); chkSlice->setToolTip(tip); } // 本轮中央不接真实派生层:体素/切片/地形勾选置灰,待下一轮接入对应数据源。 for (QCheckBox* c : {chkVoxel, chkSlice, chkTerrain}) { c->setEnabled(false); c->setToolTip(QStringLiteral("(下一轮接入真实数据源)")); } layerLayout->addWidget(layerTitle); layerLayout->addWidget(chkCurtain); layerLayout->addWidget(chkVoxel); layerLayout->addWidget(chkSlice); layerLayout->addWidget(chkTerrain); layerPanel->setVisible(false); // 默认二维,不显示图层浮层 // ── 中央“空状态”引导浮层:未接入真实 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); 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); 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("二维地图/三维视图")); vtkDock->setWidget(centerWidget); auto* centerDockArea = dockManager->addDockWidget(ads::CenterDockWidgetArea, vtkDock); // ── 下方「数据详情」dock:平面图表多 Tab 面板(QGraphicsView,VTK 仅算几何)── // 单击数据集 → 聚焦已开页;双击 → 新建/聚焦页(真实反演剖面/散点/异常/色阶)。 auto* detailPanel = new geopro::app::DatasetDetailPanel(); auto* detailDock = new ads::CDockWidget(QStringLiteral("数据详情")); // ForceNoScrollArea:禁止 ADS 默认把整块内容(含标题栏/页签栏)包进 QScrollArea。 // 否则内容最小尺寸超过 dock 时,整个面板(标题+页签+图)一起滚动。改为内容自适应填充; // 需要时由内层(图表内容区)自行滚动,标题/页签固定。 detailDock->setWidget(wrapWithHeader( geopro::app::Glyph::Detail, QStringLiteral("数据详情"), detailPanel), ads::CDockWidget::ForceNoScrollArea); // 放在中央视图下方。 dockManager->addDockWidget(ads::BottomDockWidgetArea, detailDock, centerDockArea); // 左上 dock:对象树(真实结构:项目根 → GS → TM)。被动视图,数据由控制器推送。 auto* objectTree = new geopro::app::ObjectTreePanel(); auto* leftDock = new ads::CDockWidget(QStringLiteral("对象")); leftDock->setWidget(wrapWithHeader(geopro::app::Glyph::Tree, QStringLiteral("对象"), objectTree, {{geopro::app::Glyph::Plus, QStringLiteral("新建对象")}})); 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(QStringLiteral("panelTitle")); dockManager->addDockWidget(ads::BottomDockWidgetArea, datasetDock, leftArea); // 右上 dock:异常列表 / 对象属性 合并为带 Tab 表头的面板(对齐原型上半)。 auto* exceptionPanel = new geopro::app::ObjectExceptionPanel(); auto* objAttrView = new geopro::app::DynamicFormView(); 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::DynamicFormView(); auto* propDock = new ads::CDockWidget(QStringLiteral("数据集属性")); propDock->setWidget( wrapWithHeader(geopro::app::Glyph::Property, QStringLiteral("数据集属性"), propView)); dockManager->addDockWidget(ads::BottomDockWidgetArea, propDock, rightArea); // 固定全部面板(对齐原型):移除 关闭/浮动/拖动/钉住 等子窗口操作,仅保留分隔条调整边界。 // 同时隐藏 ADS 自带标题栏——表头已由各面板内的自绘 PanelHeader 承担,避免“双标题”。 // 抽成 lambda:ADS 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(); // 中央编排已解耦到 CentralScene::rebuildCentralScene(数据驱动)。本轮空 sections → 空背景占位。 // 下一轮:用真实 DS 数据构建 sections 调同一 helper 即复活。 auto rebuildCentral = [scene, rendererPtr, renderWindowPtr, viewMode, showCurtain, frame]() { geopro::app::rebuildCentralScene(*scene, rendererPtr, renderWindowPtr, *viewMode, std::vector{}, *showCurtain, *frame, kVerticalExaggeration); }; // ── 单击左下数据列表的采集批次(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); // 属性表单(现状) 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(); if (!dsId.isEmpty()) detailCtrl.openDataset(dsId, ddCode, dsName); }); // ── 控制器信号 → 详情面板(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); // 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; } }); // 「视图详情」浮层显隐:仅三维显示,置于 QVTK 左上(工具条下方)并置顶。 auto showLayerPanel = [layerPanel, viewHeader](bool show3D) { if (show3D) { layerPanel->move(14, viewHeader->height() + 12); layerPanel->adjustSize(); layerPanel->setVisible(true); layerPanel->raise(); } else { layerPanel->setVisible(false); } }; // ── 工具条「二维地图/三维视图」:切换互斥视图 → 重建内容 + 图层浮层显隐 ── QObject::connect(act2D, &QAbstractButton::clicked, vtkWidget, [viewMode, rebuildCentral, showLayerPanel]() { *viewMode = ViewMode::Map2D; showLayerPanel(false); rebuildCentral(); }); QObject::connect(act3D, &QAbstractButton::clicked, vtkWidget, [viewMode, rebuildCentral, showLayerPanel]() { *viewMode = ViewMode::View3D; showLayerPanel(true); rebuildCentral(); }); // ──「视图详情」图层勾选 → 更新图层显隐 → 重建中央 ── QObject::connect(chkCurtain, &QCheckBox::toggled, vtkWidget, [showCurtain, rebuildCentral](bool on) { *showCurtain = on; rebuildCentral(); }); QObject::connect(chkVoxel, &QCheckBox::toggled, vtkWidget, [showVoxel, rebuildCentral](bool on) { *showVoxel = on; rebuildCentral(); }); QObject::connect(chkTerrain, &QCheckBox::toggled, vtkWidget, [showTerrain, rebuildCentral](bool on) { *showTerrain = on; rebuildCentral(); }); QObject::connect(chkSlice, &QCheckBox::toggled, vtkWidget, [showSlice, rebuildCentral](bool on) { *showSlice = on; rebuildCentral(); }); // ── 启动:建立一次空背景中央视图(真实 sections 数据由下一轮接入)。 rebuildCentral(); // VTK 背景随主题切换:直接重跑 rebuildCentral(走完整渲染路径、末尾必 Render, // 比手动 SetBackground+Render 稳;兼顾 syncSystemTheme 异步切暗的时序)。 QObject::connect(&geopro::app::ThemeManager::instance(), &geopro::app::ThemeManager::changed, &window, [rebuildCentral]() { rebuildCentral(); }); // 顶部应用区(静态视觉壳,对齐原型):上=菜单栏(视图/项目管理/业务工具/设备), // 下=工具条(工作空间切换 + 项目 + 帮助/通知/设置 + 用户)。纵向堆叠后挂到主窗口顶部。 geopro::app::TopBar* topBar = nullptr; { auto* topChrome = new QWidget(&window); auto* topLayout = new QVBoxLayout(topChrome); topLayout->setContentsMargins(0, 0, 0, 0); topLayout->setSpacing(0); topLayout->addWidget(geopro::app::buildMenuBar(topChrome)); topBar = new geopro::app::TopBar(topChrome); topLayout->addWidget(topBar); window.setMenuWidget(topChrome); } // ── 控制器 ↔ 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); QObject::connect(topBar, &geopro::app::TopBar::projectSwitchRequested, &nav, &geopro::controller::WorkbenchNavController::switchProject); // 退出登录:清除记住的凭证(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]() { auto* dlg = new geopro::app::ProjectListDialog(projectRepo, &window); dlg->setAttribute(Qt::WA_DeleteOnClose); QObject::connect(dlg, &geopro::app::ProjectListDialog::projectChosen, &nav, [&nav, topBar](const QString& id, const QString& name) { topBar->setProjectButtonText(name); 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); // 控制器详情/异常/数据集表单 → 三个被动面板。 QObject::connect(&nav, &geopro::controller::WorkbenchNavController::objectDetailLoaded, objAttrView, [objAttrView](const QString&, const geopro::data::DynamicForm& form) { objAttrView->setForm(form); }); QObject::connect(&nav, &geopro::controller::WorkbenchNavController::exceptionTreeLoaded, exceptionPanel, [exceptionPanel, anomalyBadge]( const std::vector& 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& list, const QString& cur) { topBar->setWorkspaces(list, cur); }); QObject::connect(&nav, &geopro::controller::WorkbenchNavController::projectsLoaded, topBar, [topBar](const std::vector& list, const QString& cur, int total) { topBar->setProjects(list, cur, total > static_cast(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& 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&, const std::vector& rows, int total, bool append) { removeTreeLoadMore(datasetList); geopro::app::populateDatasetList(datasetList, rows, append); 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& 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(QString::fromUtf8(geopro::app::semantic::kDanger))); 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_v2")).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_v2"), 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(e->type()) : 0); } catch (...) { qCritical("[guard] 拦截未捕获非 std 异常 | receiver=%s | event=%d", receiver ? receiver->metaObject()->className() : "null", e ? static_cast(e->type()) : 0); } return false; } }; } // namespace int main(int argc, char* argv[]) { // 高 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(); // 组织/应用名: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 公钥从 resources 读取)。 geopro::net::ApiClient api(QStringLiteral("http://tenant.geomative.cn/pop-api")); const std::string pem = readPem("D:/Git/lanbingtech/geopro/resources/rsa_public_key.pem"); 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 使用 // 登录成功 → 构建并显示工作台。 geopro::data::LocalSampleRepository repo( "D:/Git/lanbingtech/geopro/docs/剖面网格数据的色阶数据2等文件/"); // 导航仓储 + 控制器(接口/逻辑层):用同一共享会话 ApiClient。 geopro::data::ApiProjectRepository projectRepo(api); geopro::controller::WorkbenchNavController nav(projectRepo); // 数据详情仓储 + 控制器(接真实反演 API):同一共享会话 ApiClient。 geopro::data::ApiDatasetRepository datasetRepo(api); // 策略注册表须比 detailCtrl 活得久(同作用域、在前声明)。 geopro::controller::ChartStrategyRegistry chartRegistry; chartRegistry.add(std::make_unique()); chartRegistry.add(std::make_unique()); chartRegistry.add(std::make_unique()); 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); buildWorkbench(*window, repo, projectRepo, nav, detailCtrl); // 主题桥: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(); }