// 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 #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 "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 #include #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(); // 浮层尺寸钳到不超过 host:host 比内容小(窗口/抽屉收窄)时不再溢出视图。 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 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(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 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& 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>(); 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 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>(); auto refreshAnalysis = [drawer, scene3dRepo, lastSourceRows]() { std::vector 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(); auto checkedAnalysis = std::make_shared(); 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& 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 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(); }); // ── 三维分析 tab(5 段)信号接线(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(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点位置/字体本期 stub(TODO P4:弹框)。 QObject::connect(c3, &geopro::app::Column3DDataset::oPointClicked, vtkWidget, []() { /* TODO P4: O点位置弹框 */ }); QObject::connect(c3, &geopro::app::Column3DDataset::fontClicked, vtkWidget, []() { /* TODO P4: 字体弹框 */ }); // 三维数据集栏右键「生成三维体」:弹参数对话框 → 客户端 createVolume(mock)→ 刷新三维分析栏 // (新三维体作为"分析产物"出现在三维分析栏,勾选即渲染体)。 QObject::connect(c3, &geopro::app::Column3DDataset::generateVolumeRequested, &window, [&window, scene3dRepo, refreshAnalysis](const QStringList& sourceIds) { geopro::app::VolumeParamsDialog dlg(static_cast(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 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 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(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 高亮联动(R84,list→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::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(0.0); // 默认 1(Z=0):与「2D视图」下拉可见默认项(setCurrentIndex(1))及控制器默认摆放一致—— // 组合框初始 view2DModeChanged 因 connect 前发射而丢失,此处不可依赖其同步初值。 auto view2dMode = std::make_shared(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 面板(QGraphicsView,VTK 仅算几何)── // 单击数据集 → 聚焦已开页;双击 → 新建/聚焦页(真实反演剖面/散点/异常/色阶)。 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(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 承担,避免“双标题”。 // 抽成 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(); // 中央渲染由 sceneCtrl(VtkSceneController)驱动:勾选对象/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 7:Column3DDataset::checkedDatasetsChanged → setCheckedDatasets)。 auto generation = std::make_shared(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>(); auto remaining = std::make_shared(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(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(非 window):ThemeManager 是进程级单例,连接须随 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 id(confType==2 时记录,其它选中清空):数据集面板「上传」按钮据此定父对象。 auto currentTmId = std::make_shared(); 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&) { 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(g); for (auto* b : box->findChildren(QStringLiteral("panelAction"))) if (b->property("glyphId").toInt() == gid) return b; return nullptr; }; // ── 全屏切换:VTK视图 / 数据详情 表头右上角「全屏」按钮 ────────────────────────── // 点击 → 目标 dock 全屏(隐藏其余所有 dock);再点 → 还原(全部显示)。 // 使用 ADS CDockWidget::toggleView(bool) 控制可见性(标准 ADS API,v4+)。 { const QList allDocks{vtkDock, detailDock, leftDock, datasetDock, rightDock, propDock}; auto applyFullscreen = [](ads::CDockWidget* target, const QList& 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")) { // 导入 DS:TM 右键 → 选数据类型/脚本/文件 → checkImport → import(multipart)。 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&) { objAddBtn->setEnabled(true); }); } // 模型/插件目录:全局一次性拉取并缓存,供数据集右键「插件」子菜单同步填充。 auto modelsCache = std::make_shared>(); { auto* mReq = projectRepo.listModelsAsync(); QObject::connect(mReq, &geopro::data::NavRequest::done, &window, [modelsCache](const QVariant& v) { *modelsCache = qvariant_cast>(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>(); // 当前被取消勾选的类型 auto minDate = std::make_shared(); // 创建日期下限(无效=不限) auto reapply = [datasetList, hiddenTypes, minDate]() { QSet 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&) { 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& 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& tmObjectId, const std::vector& 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& 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(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[]) { // Qt WebEngine(地图页签的 QWebEngineView):必须在 QApplication 构造前初始化, // 且需启用跨上下文共享 OpenGL(QtWebEngine 与 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(); // 组织/应用名: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(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(u8.size())); } } // 都不存在:返回首选路径,交由下游报错并被启动防护捕获(提示安装不完整)。 const QByteArray u8 = (candidates.front() + QStringLiteral("/")).toUtf8(); return std::string(u8.constData(), static_cast(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()); chartRegistry.add(std::make_unique()); 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); // 启动防护:工作台构建期间任何同步加载失败(如样本数据缺失)都不应让进程静默退出, // 而是给出可见错误提示。否则登录后窗口未显示、进程消失,用户无从排查。 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(); }