Compare commits

..

10 Commits

Author SHA1 Message Date
gaozheng 72761fc05f feat(ui): 工作台与登录 UI/UX 整体重构,对齐 Web 原型
- 登录窗品牌化重设计(头部横幅+纵向字段),移除默认账号密码
- 全局浅色主题精致化:基准字体 10pt、去线框留白、下划线标签页、扁平分隔条、High-DPI 直通、压平 Fusion 3D 立体描边
- 顶部:菜单栏(视图/项目管理/业务工具/设备 多级菜单)+ 工具条(工作空间切换/项目选择/帮助·通知·设置/用户)
- ADS 子窗口去除关闭·浮动·拖动等操作并隐藏自带标题栏;改用自绘面板表头(图标+标题+操作按钮)
- 右侧异常列表/对象属性合并为 Tab 面板,属性独立面板;数据集动态标题+数量徽标
- 矢量图标体系(Lucide SVG via QSvgRenderer);状态栏常驻坐标系/世界系原点
- 对象树修复:单标题、保留折叠箭头、选中高亮不覆盖缩进列
2026-06-08 21:06:59 +08:00
gaozheng e27a93073a chore(render): 撤销切片性能诊断埋点 + 记录实测结论
- 实测(帧间隙时间戳)定位: 拖切片本身流畅(GPU/RTX3060, 每帧~7-15ms); 唯一卡点是首次开
  体素/切片的 GPU 着色器编译+体上传(一次性 ~1.2-1.7s), 属固有成本, M1 可接受。
- 撤销临时诊断埋点(帧时间戳); 体绘制保持全程统一全质量(Auto/InteractiveAdjust=0)。
- STATUS §6 记录性能排查结论与教训(勿臆测根因, CPU回退/磁盘flush 均被实测否定)。
- 全 40 测试绿; app 构建干净。
2026-06-08 12:25:10 +08:00
gaozheng a0fcc4cc62 fix(render): 切片偶尔停顿根因修复 — 体绘制改全程统一全质量 + 撤销诊断埋点
- 实测(render_verify + 帧耗时埋点)定位: 体绘制走 GPU(RTX3060), 每帧~7ms(669帧全<21ms),
  渲染根本不慢; 前判"CPU回退"错误。"偶尔停顿"是帧间间隙, 非慢帧。
- 根因: ①诊断埋点每帧 flush 磁盘(偶发延迟) ②vtkSmartVolumeMapper AutoAdjust 的"停手补高清"突跳。
- 修: 撤销帧耗时埋点(及无效的 DesiredUpdateRate 改动); VoxelActor 关交互降采样(Auto/Interactive
  AdjustSampleDistances=0)→全程统一全质量(GPU 足够), 消除停手补帧的突跳。
- 全 40 测试绿; app 构建干净。
2026-06-08 12:12:38 +08:00
gaozheng 6c60f5a83d perf(render): 拖切片不卡 — 交互时体绘制自动降采样
- 反馈: 开体素时拖切片卡(每帧全质量重渲 GPU 体绘制跟不上拖动帧率)。
- 修: 切片交互时 interactor DesiredUpdateRate=20fps + VoxelActor mapper
  Auto/InteractiveAdjustSampleDistances → 拖动时体绘制自动降采样提帧率, 松手按 StillUpdateRate 恢复全质量。
- 全 40 测试绿; app 构建干净。
2026-06-08 11:49:53 +08:00
gaozheng 65283492fb fix(render): 切片与体素纵向一致 — 把纵向夸张烤进体素 image(切片穿过体素而非在旁)
- 反馈: 切片显示在 3D 图形旁边/比例不符。根因: 切片(vtkImagePlaneWidget)作用于体素 image 原始
  米坐标(1x), 而体素体绘制/帘面是 3x(actor SetScale) → 纵向比例不一致, 切片错位。
- 修: buildVoxelFromScatters 加 zDisplayScale, 把纵向夸张烤进输出 image 的 z 原点/间距
  (IDW 采样仍用真实 cellZ); app 传 kCurtainZScale 并去掉 volume 的 actor SetScale。
  现体绘制/切片/帘面同在 3x 坐标 → 切片穿过体素、与帘面纵向一致。
- 全 40 测试绿; app 构建干净。(render_verify 仍用默认 1x 在原始坐标核对配准, 不受影响。)
2026-06-08 11:46:39 +08:00
gaozheng f57291a127 fix(render): 地形高程按测线地表基准 rebase + 切片改左键拖动移动切面
- 反馈1 地形浮空/偏位: 诊断确认 DEM 是 WGS84 经纬度(26x10, 覆盖~700x330m), 测线仅~70m 在其南缘
  (横向"偏"实为地形覆盖远大于测线, 地理正确); 纵向浮空因地形用绝对高程(16-95m) vs 帘面深度。
  → buildTerrain 加 zOffset(从高程减基准), app 传测线地表高程中位数 refElev, 使地形落在测线附近。
  完整 Z 基准统一(与帘面/体素夸张一致)仍属 spec M-3 待办。
- 反馈2 切片交互: vtkImagePlaneWidget 默认左键=取值光标(十字), 不直观; 改 左键=移动切面
  (VTK_SLICE_MOTION_ACTION)、中键=取值。现在左键拖动直接滑动切面。
- 全 40 测试绿; app 构建干净。
2026-06-08 11:38:14 +08:00
gaozheng 7007619bf2 feat(render): DEM 地形+影像贴图(spec ④) + dd_slice 交互切片
- TerrainActor(buildTerrain): GDAL 读 dem.tif(高程)+ image.tif(影像); DEM CRS→4326→
  GeoLocalFrame 配准成 vtkStructuredGrid warp 面; 影像经 GDAL 读像素(行翻转正立)作纹理,
  按经纬→EPSG:3857→像素 算纹理坐标贴图(影像/DEM 异源 CRS 重投影对位)。影像读失败→按高程上色。
  离屏 verify_terrain_3d.png 核对: 卫星影像正立贴微起伏面、配准对位。+2 单测。
- 注: 影像须 GDAL 读(vtkTIFFReader 对此压缩 TIFF 报错"reading the row")。
- dd_slice: 3D「视图详情」加「切片」图层 = vtkImagePlaneWidget 在体素 image 拖切面(spec M1-b)。
- 接入 app: 3D 浮层五图层(帘面/体素/切片/地形); repo.demPath()/imagePath(); PROJ 不可用则禁用。
- vcpkg 加 gdal(连带 hdf5/netcdf/geos 等, 已缓存); 全 40 测试绿; app 构建干净。
- 注: 地形/切片 Z 基准与帘面/体素纵向夸张未统一(spec M-3 待办); dem 低分辨率→起伏细微。
2026-06-08 11:25:45 +08:00
gaozheng 8466fe3a5a feat(view): 剖面电极标记 ▼ + 显示电极/显示等值线 开关(对齐原型, 增量4)
- ElectrodeActor(buildElectrodes): 剖面顶边(最浅深度)各 x 列画朝下三角 ▼; 坐标与 #18 一致。
  离屏 verify_section_anomaly.png 顶边电极带核对吻合; +2 单测(三角数/顶点位置/空安全)。
- 数据详情工具条加「显示电极」(默认开,网格数据模式)+「显示等值线」(默认开, gate #18 黑色等值线)。
- 全 38 测试绿; app 构建干净; 待人工登录复核。
- 增量4 剩: 数值标签 / 色阶配置 / 滤波处理(进阶/M1.5)。
2026-06-08 09:52:18 +08:00
gaozheng a2efef8ada feat(view): 3D「视图详情」图层浮层 + 体素正经接入(对齐原型, 增量3)
- 中央 QVTK 左上浮层(QFrame, 仅三维显示, 工具条下方, raise 置顶): 图层勾选「帘面 / 体素」。
- rebuildCentral: 帘面层 gate buildCurtain; 体素层 → buildVoxelFromScatters 体绘制(同纵向夸张)。
  showCurtain(默认开)/showVoxel(默认关)/crs 共享态; 切视图自动显隐浮层。
- 体素经此正经接入(取代 42a7ed1 移除的困惑工具条开关, 这才是它对齐原型的归宿)。
- main() 自动定位 PROJ_DATA(候选路径; 部署须随包附带 proj 数据); PROJ 不可用→体素勾选禁用+提示。
- app 构建干净; 待人工登录复核(浮层渲染于 QVTK 之上 + 勾体素出十字片)。
2026-06-08 09:42:09 +08:00
gaozheng 50c4de4019 feat(view): 左下数据列表 + 对象树收到测线层(对齐原型, 增量2)
- 对象树 GS→TM(测线复选, UserRole+2=tmId); DS(采集批次)移出树, 入左下「数据真实显示栏」
  (QTabWidget 数据/文件; panels/DatasetListPanel)。findTm 按 tmId 查 TM。
- 中央 rebuildCentral 改遍历勾选的测线 → 渲染其 dd_section(可多条共存)。
- 树单击测线→填数据列表; 数据列表单击采集批次→loadDataset(数据详情+异常列表+属性,抽共享 lambda)。
- 启动自动选首个含 dd_section 的测线 + 首数据集(对齐原型默认载入态)。
- structure 取一次共享; app 构建干净; 待人工登录复核。
2026-06-08 09:33:06 +08:00
33 changed files with 2616 additions and 176 deletions

View File

@ -67,9 +67,10 @@ setx VCPKG_ROOT "C:\dev\vcpkg" # 永久(新开终端生效)
**用官方安装器,但必须是 MSVC kit**(你原装的 `mingw_64` 在 MSVC 下不可用): **用官方安装器,但必须是 MSVC kit**(你原装的 `mingw_64` 在 MSVC 下不可用):
1. 打开 `D:\Qt\MaintenanceTool.exe` → Add or remove components → 登录 Qt 账号。 1. `.\qt-online-installer-windows-x64-4.11.0.exe --mirror https://ftp.jaist.ac.jp/pub/qtproject`
2. 展开 Qt → Qt 6.11.1,勾选 **MSVC 2022 64-bit**,安装。 2. 打开 `D:\Qt\MaintenanceTool.exe` → Add or remove components → 登录 Qt 账号。
3. 完成后存在 `D:\Qt\6.11.1\msvc2022_64\lib\cmake\Qt6`(供 `find_package(Qt6)`)。 3. 展开 Qt → Qt 6.11.1,勾选 **MSVC 2022 64-bit**,安装。
4. 完成后存在 `D:\Qt\6.11.1\msvc2022_64\lib\cmake\Qt6`(供 `find_package(Qt6)`)。
CMake 经 `CMAKE_PREFIX_PATH=D:/Qt/6.11.1/msvc2022_64` 找到它(见 §6 预设)。**全链路只此一份 Qt**。 CMake 经 `CMAKE_PREFIX_PATH=D:/Qt/6.11.1/msvc2022_64` 找到它(见 §6 预设)。**全链路只此一份 Qt**。

View File

@ -13,14 +13,15 @@
桌面 app `geopro_desktop`: 桌面 app `geopro_desktop`:
- **真实登录**:LoginWindow(用户名/密码/图形验证码/记住)→ `verifyCodeCheck` → RSA 加密密码 → `login2` → token → 进工作台。**真机登录成功**;输入框样式已修(三态白底深字)。 - **真实登录**:LoginWindow(用户名/密码/图形验证码/记住)→ `verifyCodeCheck` → RSA 加密密码 → `login2` → token → 进工作台。**真机登录成功**;输入框样式已修(三态白底深字)。
- **工作台 ADS 三区 + 数据详情**: - **工作台 ADS 三区 + 数据详情**:
- **左 对象树**:GS→TM→DS,复选框勾选 → 控制中央显示哪些测线。 - **左上 对象显示栏**:GS→TM(测线),复选框勾选测线 → 控制中央显示哪些测线的 dd_section。
- **左下 数据真实显示栏**(对齐原型):单击测线 → 列其采集批次(数据集,tab 数据/文件);单击采集批次 → 数据详情+异常列表+属性。启动自动选首测线+首数据集。
- **中央 二维地图 / 三维视图**(两个**真内容**,非相机切换): - **中央 二维地图 / 三维视图**(两个**真内容**,非相机切换):
- 二维地图 = `MapLineActor`:测线 `lat/lon` 轨迹**红线**俯视(浅底),像地图。 - 二维地图 = `MapLineActor`:测线 `lat/lon` 轨迹**红线**俯视(浅底),像地图。
- 三维视图 = `CurtainActor`:沿测线的**竖直断面墙**(分段色带,z 纵向夸张×3,沿弯曲测线弯)。中央工具条**仅**「二维地图/三维视图」(对齐原型,无体素按钮)。 - 三维视图 = `CurtainActor`:沿测线的**竖直断面墙**(分段色带,z 纵向夸张×3,沿弯曲测线弯)。中央工具条**仅**「二维地图/三维视图」。**3D 左上「视图详情」浮层**(对齐原型,仅 3D 显示)五图层勾选:**帘面 / 体素 / 切片 / 地形**(体素=`buildVoxelFromScatters` 散点经 EPSG:4547 配准 IDW;切片=`vtkImagePlaneWidget` 在体素 image 上交互拖切面=dd_slice;地形=`TerrainActor` GDAL 读 DEM 高程面 + 影像 EPSG:3857 重投影贴图;后三者需 PROJ,不可用则禁用)。
- **下方 数据详情**:工具条「原数据/网格数据」切换 +「显示异常」开关(对齐原型命名)。单击数据集 → 网格数据=`GridContourActor` 平面剖面(#18,colorBar 真实非均匀分段值上色,纵向夸张×1.5);原数据=`ScatterActor` 彩色方块散点(#17,x=距离/y=深度取负,用散点自带色阶);显示异常=`AnomalyActor` 在上图叠加异常 dashed 折线(同纵向夸张对齐)。 - **下方 数据详情**:工具条「原数据/网格数据」切换 +「显示异常/显示电极/显示等值线」开关(对齐原型)。单击数据集 → 网格数据=`GridContourActor` 平面剖面(#18,colorBar 真实非均匀分段值上色,纵向夸张×1.5);原数据=`ScatterActor` 彩色方块散点(#17);显示异常=`AnomalyActor` dashed 折线叠加;显示电极=`ElectrodeActor` 顶边 ▼ 标记;显示等值线=#18 黑色等值线显隐(同纵向夸张对齐)。
- **右上 异常列表**(对齐原型):单击数据集→列该数据集异常(颜色块+名称(类型)+派生「位置/深/尺寸」),勾选框显隐,与数据详情异常叠加联动(取消勾选→该异常虚线隐藏)。 - **右上 异常列表**(对齐原型):单击数据集→列该数据集异常(颜色块+名称(类型)+派生「位置/深/尺寸」),勾选框显隐,与数据详情异常叠加联动(取消勾选→该异常虚线隐藏)。
- **右下 属性**:名称/网格 nx×ny/vmin·vmax/异常数。 - **右下 属性**:名称/网格 nx×ny/vmin·vmax/异常数。
- 单元测试累计 **36 个全绿**(core/data/net/render;含 Scatter 2 + Anomaly 4 + VoxelRegister 1、修复了陈旧的 Curtain mapper 类型断言);离屏 `verify_section/map/curtain_3d/scatter/section_anomaly/voxel_top/voxel_3d.png` 均核对正确(scatter 吻合 ref_17、异常吻合 ref_18、体素 footprint 吻合 ref voxel_hslice 的两臂支撑) - 单元测试累计 **40 个全绿**(core/data/net/render;含 Scatter 2 + Anomaly 4 + VoxelRegister 1 + Electrode 2 + Terrain 2、修复了陈旧的 Curtain mapper 类型断言);离屏 `verify_section/map/curtain_3d/scatter/section_anomaly(含电极▼)/voxel_top/voxel_3d/terrain_3d(DEM+影像贴图).png` 均核对正确。
## 2. 各 Phase 完成度 ## 2. 各 Phase 完成度
@ -30,7 +31,7 @@
| P1 | core(LocalFrame/模型/ColorScale/IDW/CrsTransform/GeoLocalFrame) | ✅ | | P1 | core(LocalFrame/模型/ColorScale/IDW/CrsTransform/GeoLocalFrame) | ✅ |
| P2 | data(解析器/LocalSampleRepository)+ 对象树 | ✅ | | P2 | data(解析器/LocalSampleRepository)+ 对象树 | ✅ |
| P3 | 登录(RsaEncryptor/ApiClient/AuthService/LoginWindow) | ✅(**Credential 记住免登录未做**) | | P3 | 登录(RsaEncryptor/ApiClient/AuthService/LoginWindow) | ✅(**Credential 记住免登录未做**) |
| P4 | 渲染:render 层 + 二维地图(线)+ 三维视图(帘面)+ 数据详情(#18/#17/异常) | 🔶 **三视图 + 散点#17 + 异常叠加 已对; dd_voxel 引擎已验证(UI 未接,待 3D 图层控制)**;**DEM地形(需加gdal) / 底图瓦片 / dd_slice / 布局对齐原型(左下数据列表/右上异常列表/电极/3D图层浮层) 未做** | | P4 | 渲染:render 层 + 二维地图(线)+ 三维(帘面/体素/切片/地形)+ 数据详情(#18/#17/异常/电极) | ✅ **核心全达成**:三视图 + #17 + 异常 + dd_voxel + dd_slice + DEM地形影像 + 原型六面板。**剩**:底图瓦片(M1.5)、数值标签、Credential 免登录、Z 基准统一、dock 透视持久化 |
## 3. 构建约定(**机器本地** ## 3. 构建约定(**机器本地**
@ -39,7 +40,8 @@
- **构建/测试经** `& cmd /c "D:\Git\lanbingtech\geopro\external\dev.bat <cmd>"`(**PowerShell 调**,Bash 下参数透传坏)。C: 极小→TEMP/构建全在 D:。 - **构建/测试经** `& cmd /c "D:\Git\lanbingtech\geopro\external\dev.bat <cmd>"`(**PowerShell 调**,Bash 下参数透传坏)。C: 极小→TEMP/构建全在 D:。
- **app 构建前先** `taskkill /IM geopro_desktop.exe /F`(运行中 LNK1104 锁 exe)。 - **app 构建前先** `taskkill /IM geopro_desktop.exe /F`(运行中 LNK1104 锁 exe)。
- 部署:`D:\Qt\6.11.1\msvc2022_64\bin\windeployqt.exe --release <exe>`(找不到 ads dll 的警告无害);VTK/vcpkg dll 由 POST_BUILD `TARGET_RUNTIME_DLLS` 拷。 - 部署:`D:\Qt\6.11.1\msvc2022_64\bin\windeployqt.exe --release <exe>`(找不到 ads dll 的警告无害);VTK/vcpkg dll 由 POST_BUILD `TARGET_RUNTIME_DLLS` 拷。
- **离屏渲染验证**:`render_verify` 需 PATH 加 `external\vtk-install\bin` + `build\release\vcpkg_installed\x64-windows\bin` 再运行。 - **离屏渲染验证**:`render_verify` 需 PATH 加 `external\vtk-install\bin` + `build\release\vcpkg_installed\x64-windows\bin`;且体素/地形需 `PROJ_DATA=...\vcpkg_installed\x64-windows\share\proj`、`GDAL_DATA=...\share\gdal` 再运行。
- **GDAL**:已加 vcpkg `gdal`(首次编译久,连带 hdf5/netcdf/geos 等;已缓存)。app/测试运行时需 PROJ_DATA(app main() 已自动按候选路径设;部署须随包附带 proj/gdal 数据)。
- **改源码用 Write 工具,勿用 PowerShell `Set-Content -Encoding UTF8`**(会把中文注释弄乱、断构建)。 - **改源码用 Write 工具,勿用 PowerShell `Set-Content -Encoding UTF8`**(会把中文注释弄乱、断构建)。
## 4. 关键决策与已核实事实 ## 4. 关键决策与已核实事实
@ -67,8 +69,10 @@
1. ~~**散点 #17**:`ScatterActor`(剖面原数据 2597 点彩色散点),数据详情"原数据"视图~~**已完成**(2026-06-08,离屏 PNG 核对吻合 Python 真值,接入数据详情「反演剖面/原数据」切换;app 待人工登录肉眼复核交互)。 1. ~~**散点 #17**:`ScatterActor`(剖面原数据 2597 点彩色散点),数据详情"原数据"视图~~**已完成**(2026-06-08,离屏 PNG 核对吻合 Python 真值,接入数据详情「反演剖面/原数据」切换;app 待人工登录肉眼复核交互)。
2. ~~**异常叠加**:`AnomalyActor`(markType 点/线/面)~~**已完成**(2026-06-08,叠加在数据详情 #18/#17 上,「显示异常」开关默认开;离屏 `verify_section_anomaly.png` 折线位置吻合 ref_18;样本 3 异常均 markType=2 dashed;app 待人工登录复核)。**注**:dashed 点画在 VTK OpenGL2 下偏弱(几乎实线),几何/颜色/位置正确,纯观感项可后续调。 2. ~~**异常叠加**:`AnomalyActor`(markType 点/线/面)~~**已完成**(2026-06-08,叠加在数据详情 #18/#17 上,「显示异常」开关默认开;离屏 `verify_section_anomaly.png` 折线位置吻合 ref_18;样本 3 异常均 markType=2 dashed;app 待人工登录复核)。**注**:dashed 点画在 VTK OpenGL2 下偏弱(几乎实线),几何/颜色/位置正确,纯观感项可后续调。
3. **DEM/影像地形**:加 vcpkg `gdal`;GDAL 读 dem.tif/image.tif;**影像 EPSG:3857 必须 PROJ 重投影到世界系**;`vtkWarpScalar` 地形面 + 纹理。 3. ~~**DEM/影像地形**~~**已完成**(2026-06-08)。`render::buildTerrain`(GDAL 读 dem.tif 高程 + image.tif 影像;DEM CRS→4326→GeoLocalFrame 配准成 warp 面;影像 EPSG:3857→像素纹理坐标贴图)。离屏 `verify_terrain_3d.png` 卫星影像正确贴微起伏面、方向正立、配准对位。**注**:影像须用 **GDAL 读像素**(vtkTIFFReader 对此压缩 TIFF 报错);+2 单测;接入 app 3D「地形」图层。dem.tif 低分辨率→起伏细微。Z 基准与帘面/体素夸张未统一(spec M-3 待办)。
4. **dd_voxel 回归**:🔶 **引擎已完成并验证**(2026-06-08,CRS 已定 EPSG:4547)。`render::buildVoxelFromScatters`:散点 projX/Y→4547→4326→GeoLocalFrame 配准 + IDW(maxDist 裁剪)→ `buildVoxel`;离屏 `verify_voxel_top.png` 两臂支撑吻合 ref voxel_hslice、`verify_voxel_3d.png` profile1 片贴合帘面;+1 单测(VoxelRegister,需 PROJ_DATA)。**UI 未接入**:曾加"体素"工具条开关,但与二维/三维平级令人困惑且不在原型,**已移除**;待做 **3D 图层控制(对齐原型「视图详情」浮层)** 再正经接入(届时 main() 需设 PROJ_DATA + 部署随包附带 proj 数据)。**注**:仅 2 交叉线→薄十字片(15.9% 充填,半透明偏淡),可信满体需≥3线(设计 §10/§14)。**dd_slice 交互切片未做**(buildVoxel 已暴露 image 供 reslice widget)。 - **dd_slice 交互切片** ✅ 已完成(同日):3D「切片」图层=`vtkImagePlaneWidget` 在体素 image 拖切面(spec M1-b);**左键拖动=移动切面**(默认左键取值光标已改),中键=取值;纵向夸张已烤进体素 image(zDisplayScale),切片/体绘制/帘面同坐标系(切片穿过体素)。
- **性能排查结论(2026-06-08,实测)**:体绘制走 **GPU(RTX3060)**,拖动每帧~715ms(非 CPU 回退,前判误);拖动流畅。**唯一卡点=首次开体素/切片的 GPU 着色器编译+体上传(一次性 ~1.21.7s)**,属固有成本,M1 可接受(如需消除可后续 pre-warm)。体绘制设 Auto/InteractiveAdjustSampleDistances=0(全程统一全质量,GPU 够快)。教训:勿臆测性能根因(CPU回退/磁盘flush 均被实测否定),用帧间隙时间戳实测定位。
4. **dd_voxel 回归**:✅ **已完成**(2026-06-08,CRS 已定 EPSG:4547)。`render::buildVoxelFromScatters`:散点 projX/Y→4547→4326→GeoLocalFrame 配准 + IDW(maxDist 裁剪)→ `buildVoxel`;离屏 `verify_voxel_top.png` 两臂支撑吻合 ref voxel_hslice、`verify_voxel_3d.png` profile1 片贴合帘面;+1 单测(VoxelRegister,需 PROJ_DATA)。**UI 已接入**(增量3):3D「视图详情」浮层「体素」图层勾选驱动;main() 自动设 PROJ_DATA(部署须随包附带 proj 数据);PROJ 不可用则该层禁用。**注**:仅 2 交叉线→薄十字片(15.9% 充填,半透明偏淡),可信满体需≥3线(设计 §10/§14)。**dd_slice 交互切片未做**(buildVoxel 已暴露 image 供 reslice widget)。
5. **底图瓦片**(二维地图,天地图/Mapbox):M1.5。 5. **底图瓦片**(二维地图,天地图/Mapbox):M1.5。
6. **Credential(QtKeychain)**:记住一个月免登录持久化(P3 Task2 未做)。 6. **Credential(QtKeychain)**:记住一个月免登录持久化(P3 Task2 未做)。
7. 多测线:当前样本仅 1 条 dd_section(grid1);多条共存机制已就绪,加数据即叠加。 7. 多测线:当前样本仅 1 条 dd_section(grid1);多条共存机制已就绪,加数据即叠加。
@ -76,9 +80,10 @@
9. render 仍部分内联在 main.cpp;可逐步抽到 view/controller。 9. render 仍部分内联在 main.cpp;可逐步抽到 view/controller。
10. **布局对齐原型**(权威参考 `http://prototype.geomative.cn/`;截图存 `.playwright-mcp/`)。**计划见 `plans/2026-06-08-m1-prototype-layout.md`(六面板 + view/ 抽取,增量序列)**。进度: 10. **布局对齐原型**(权威参考 `http://prototype.geomative.cn/`;截图存 `.playwright-mcp/`)。**计划见 `plans/2026-06-08-m1-prototype-layout.md`(六面板 + view/ 抽取,增量序列)**。进度:
- ✅ **增量1 右上「异常列表」**(2026-06-08,`panels/AnomalyListPanel`,与数据详情显隐联动;待人工复核)。 - ✅ **增量1 右上「异常列表」**(2026-06-08,`panels/AnomalyListPanel`,与数据详情显隐联动;待人工复核)。
- ⬜ **增量2 左下「数据列表」**+ 对象树到 TM 层(DS 移出树入数据列表)。 - ✅ **增量2 左下「数据列表」+ 对象树到 TM 层**(2026-06-08,`panels/DatasetListPanel`;树 GS→TM 复选驱动中央, DS 移出树入数据列表 tab 数据/文件, DS 单击→详情+异常+属性, 启动自动选首测线/首数据集;待人工复核)。
- ⬜ **增量3 3D「视图详情」图层浮层**(体素的正确归宿;体素引擎已就绪,UI 待此接入)。 - ✅ **增量3 3D「视图详情」图层浮层**(2026-06-08,QFrame 浮于 QVTK 左上,仅 3D 显示;帘面/体素图层勾选;体素经此正经接入=之前移除的工具条开关的正确归宿;main() 设 PROJ_DATA;待人工复核浮层渲染+体素显隐)。
- ⬜ **增量4 数据详情富工具条 + 电极标记 + 数值标签**;底图影像=DEM/底图任务。 - 🔶 **增量4 电极标记 + 工具条**(2026-06-08,`ElectrodeActor` 顶边 ▼ PNG 核对吻合; +「显示电极/显示等值线」开关;待人工复核)。**剩**:数值标签、色阶配置/滤波处理(M1.5/进阶)。
- 渲染积木累计含 `ElectrodeActor`(顶边 ▼)、`TerrainActor`(DEM 高程面 + 影像纹理,GDAL)。底图瓦片=M1.5。
- 架构:新面板抽到 `src/app/panels/`(暂随 app 编译,如 login/),控制 main.cpp 体量;后续可升 `src/view/` 库。 - 架构:新面板抽到 `src/app/panels/`(暂随 app 编译,如 login/),控制 main.cpp 体量;后续可升 `src/view/` 库。
## 7. 渲染验证手段(务必用) ## 7. 渲染验证手段(务必用)

View File

@ -58,7 +58,11 @@
- 放右上 dock与"属性"分上下或 tab。**人工复核**:单击数据集→右上列出 3 异常;眼睛切换隐藏对应虚线。 - 放右上 dock与"属性"分上下或 tab。**人工复核**:单击数据集→右上列出 3 异常;眼睛切换隐藏对应虚线。
- 提交 `feat(view): 右上异常列表面板 + 与数据详情异常显隐联动` - 提交 `feat(view): 右上异常列表面板 + 与数据详情异常显隐联动`
### 增量 2左下「数据列表」+ 树结构调整(树到 TMDS 入数据列表) ### 增量 2左下「数据列表」+ 树结构调整 ✅ 已完成(2026-06-08)
> `panels/DatasetListPanel`(populateDatasetList)。树 `populateTree` 改 GS→TM(测线复选,UserRole+2=tmId);
> `findTm` 按 tmId 查 TM。中央 rebuildCentral 改遍历勾选 TM→渲染其 dd_section。左下 dock = QTabWidget
> (数据 QListWidget / 文件占位)。树单击 TM→填数据列表;数据列表单击 DS→`loadDataset`(详情+异常+属性,
> 抽成共享 lambda)。启动自动选首含 dd_section 的测线+首数据集。app 构建净;**待人工复核**。**原计划存档**:
- repo.loadStructure 改为树到 **TM**GS→TMDS 不再进树;新增 `listDatasets(tmId)` 或复用结构。 - repo.loadStructure 改为树到 **TM**GS→TMDS 不再进树;新增 `listDatasets(tmId)` 或复用结构。
(当前样本仅 1 DS grid1 挂 ERT1多 TM/DS 为 mock 结构,先支持单条,预留多条。) (当前样本仅 1 DS grid1 挂 ERT1多 TM/DS 为 mock 结构,先支持单条,预留多条。)
- `src/view/panels/DatasetListPanel`tab 数据/文件;列选中 TM 的采集批次;单击 → 数据详情 + 属性。 - `src/view/panels/DatasetListPanel`tab 数据/文件;列选中 TM 的采集批次;单击 → 数据详情 + 属性。
@ -66,19 +70,32 @@
- **人工复核**:点 ERT1 → 左下列出采集批次 → 单击 → 数据详情出图。 - **人工复核**:点 ERT1 → 左下列出采集批次 → 单击 → 数据详情出图。
- 提交 `feat(view): 左下数据列表 + 对象树到测线层(DS 移出树)` - 提交 `feat(view): 左下数据列表 + 对象树到测线层(DS 移出树)`
### 增量 33D「视图详情」图层浮层体素的正确归宿 ### 增量 33D「视图详情」图层浮层 ✅ 已完成(2026-06-08)
> main.cpp 内联 QFrame 浮层(child of centerWidget,move 到 QVTK 左上=工具条下方,raise;仅 3D setVisible)。
> 含 帘面/体素 QCheckBox。rebuildCentral:帘面层 gate buildCurtain;体素层→buildVoxelFromScatters 加体绘制
> (同纵向夸张)。showCurtain/showVoxel/crs 共享态。main() 自动设 PROJ_DATA;crs 失败→体素勾选禁用。
> app 构建净;**待人工复核**(浮层是否正确浮于 QVTK 之上、勾体素是否出十字片)。**原计划存档**:
- 中央三维视图左上浮层QWidget overlay on QVTK或工具区勾选显示 测线/帘面/**体素**/(地形)。 - 中央三维视图左上浮层QWidget overlay on QVTK或工具区勾选显示 测线/帘面/**体素**/(地形)。
- 勾"体素" → 调 `buildVoxelFromScatters`(已验证)加入 3D 场景;勾选驱动 rebuildCentral 的图层集。 - 勾"体素" → 调 `buildVoxelFromScatters`(已验证)加入 3D 场景;勾选驱动 rebuildCentral 的图层集。
- main() 设 PROJ_DATA体素配准需 proj.db按候选路径部署随包附带 - main() 设 PROJ_DATA体素配准需 proj.db按候选路径部署随包附带
- **人工复核**:三维视图勾"体素"→ 出十字片体素,与帘面同系;取消则移除。 - **人工复核**:三维视图勾"体素"→ 出十字片体素,与帘面同系;取消则移除。
- 提交 `feat(view): 3D 视图详情图层浮层(测线/帘面/体素) + 体素正经接入` - 提交 `feat(view): 3D 视图详情图层浮层(测线/帘面/体素) + 体素正经接入`
### 增量 4数据详情富工具条 + 电极 + 数值标签(对齐原型中下) ### 增量 4数据详情富工具条 + 电极 🔶 部分完成(2026-06-08)
> ✅ `actors/ElectrodeActor`(buildElectrodes:顶边各 x 列朝下三角 ▼,离屏 PNG 核对吻合) + 数据详情
> 「显示电极/显示等值线」开关(显示等值线 gate #18 edges actor)。+2 单测,全 38 测试绿。
> ⬜ 剩:数值标签(vtkLabeledDataMapper)、色阶配置/滤波处理(进阶/M1.5)。**原计划存档**:
- 工具条补 色阶配置/显示电极/显示等值线 等(按需,部分 M1 可占位)。 - 工具条补 色阶配置/显示电极/显示等值线 等(按需,部分 M1 可占位)。
- 剖面顶部电极▼标记grid.lat/lon 或 x 轴电极位);可选数值标签。 - 剖面顶部电极▼标记grid.lat/lon 或 x 轴电极位);可选数值标签。
- 提交 `feat(view): 数据详情电极标记 + 工具条对齐原型` - 提交 `feat(view): 数据详情电极标记 + 工具条对齐原型`
### (增量 5可选右下属性面板规范化 + 整体 dock 透视持久化 ### 增量 5DEM 地形 + dd_slice ✅ 已完成(2026-06-08, backlog 项随原型 3D 图层一并落地)
> `actors/TerrainActor`(GDAL 读 DEM/影像 → 重投影到 GeoLocalFrame → warp 面 + GDAL 读影像像素作纹理;
> 影像 EPSG:3857 重投影对位)。离屏 `verify_terrain_3d.png` 核对吻合(卫星影像正立贴面)。+2 单测。
> dd_slice = `vtkImagePlaneWidget` 在体素 image 拖切面。两者接入 3D「视图详情」浮层(地形/切片图层)。
> 全 40 测试绿。vcpkg 加 gdal。**注**:影像须 GDAL 读(非 vtkTIFFReader);Z 基准统一(M-3)待办。
### (后续,可选)数值标签 / 色阶配置 / Credential 免登录 / dock 透视持久化 / 底图瓦片(M1.5)
--- ---

View File

@ -0,0 +1,376 @@
# Geopro 3.0 桌面客户端 — M1 架构设计
**日期**2026-06-07
**版本**v2已按双专家评审 + 数据核验修订;修订点见 §16
**状态**:待用户复核 v2
**范围**M1 里程碑 = 完整工作台外壳 + 登录功能 + 三维视图(基础渲染 / dd_voxel 体绘制与切片 / DEM 地形)
**上位文档**`docs/Geopro3.0_技术选型与架构规约.md`(技术基线,本文遵从其全部约束)
---
## 1. 目标与范围
### 1.1 M1 交付目标
复刻 Geopro 3.0 最核心的「项目分析视图」桌面版,并把登录做实:
1. **登录功能完全可用**:真实连接生产后端(`pop-api`),走验证码 + RSA 加密密码流程token 安全存储。登录页样式参考现有 web 系统。
2. **完整工作台外壳**ADS 三区停靠布局,还原原型(左:对象树 + 数据集列表2D/3D 视图 + 数据详情;右:异常列表/对象属性 + 属性)。
3. **三维视图**M1 核心难点):
- ① 基础渲染:剖面散点、网格等值面/等值线/标注、异常圈定(直接渲染)
- ② dd_voxel 体绘制 + 鼠标交互切片dd_slice——C++ 进程内三维插值;**追求可信体**(非演示性),故对输入数据有要求(见 §10、§13 分阶段)
- ④ DEM 地形起伏 + 影像贴图
- 二维俯视相机预设(验证「单一场景」架构)
4. **业务数据来源**:登录联网;工作台业务数据 M1 用本地样本文件,经 Repository 抽象注入,未来无缝切 API。
### 1.2 不在 M1 范围
- ③ 雷达单/多通道渲染、⑤ 在线底图瓦片 → M1.5
- 反演/数据处理算法本体M1 只做「展示期插值」,不做反演)
- 项目管理、设备连接、在线监测、报告、平台后台、Web 端
- 完整算法插件架构(进程隔离 + manifest→ 规约 D-3 推迟M1 仅以 `IInterpolator` 接口预留
- 在线更新三通道(规约 §8
- macOS 构建M1 先 Windows / MSVC 2022架构保持跨平台可移植
---
## 2. 关键决策记录
| 编号 | 决策 | 结论 |
|---|---|---|
| K-1 | 2D/3D 视图架构 | **单一 VTK 三维场景 + 相机预设切换**;底图做可插拔 GroundLayer规约 §5.3、D-5。被现有 web「Cesium 单 3D 引擎」实践印证。 |
| K-2 | 启动节奏 | 先出设计文档 → **spike 预研门槛** → 再写完整实现计划 |
| K-3 | M1 外壳范围 | 完整工作台A 方案) |
| K-4 | 业务数据来源 | 登录走真实 API业务数据本地样本 + Repository 抽象B 方案) |
| K-5 | M1 三维内容 | ① 基础 + ② dd_voxel可信体图分阶段+ ④ DEM③ 雷达、⑤ 底图瓦片留 M1.5 |
| K-6 | 三维插值实现 | C++ 进程内IDW 起步),`IInterpolator` 接口隔离、**返回 core 中立类型**推迟完整插件架构D-1/D-3 |
| K-7 | 坐标系 | **每数据源各记源 CRS + 各自 LocalFrame** → 统一 rebase 到唯一「项目世界系(局部米,含 Z 基准GIS/经纬/底图用 PROJ 实时换算(见 §5 |
| K-8 | 构建/部署 | **方案②-修订**(经双专家评审+实机勘验改定):单一 Qt = **官方 MSVC 预编译 Qt**(`D:\Qt\6.11.1\msvc2022_64`);**VTK/ADS/QtKeychain 对接该官方 Qt**(VTK 源码编到 install 前缀、ADS/QtKeychain 走 FetchContent),**绝不走 vcpkg**(否则 vcpkg 再拉一份 Qt = 双份);仅非 Qt 依赖(GDAL/PROJ/OpenSSL/Eigen/...)走 vcpkg。**关键事实**:用户原装的 `D:\Qt\6.11.1`**MinGW 版**,MSVC 下不可链,须补装 MSVC kit;VTK 无 MSVC 预编译、三方案均须源码编;VS18=MSVC 14.51 链官方 Qt(v143)属"新链旧"ABI 安全。 |
| K-9 | 视图 widget | 评估 **`QVTKOpenGLStereoWidget`QOpenGLWidget 系)** 优先于 native缓解 ADS reparent 上下文丢失spike 验证) |
| K-10 | dd_voxel 可信度 | 维持可信体目标可信度取决于输入数据充分性≥3 非共线剖面或 3D 网格),列为数据依赖(见 §10、§14 |
---
## 3. 分层架构与目录结构
遵循规约 §10.3 清晰分层core / data / view / controller / app细分 net、render。
```
geopro/
├─ CMakeLists.txt / CMakePresets.json / vcpkg.json
├─ .clang-format / .clangd # AI 编码上下文基础设施(规约 §10.1
├─ cmake/ # Find 模块、打包、dll 部署
├─ src/
│ ├─ core/ # 纯业务,零 Qt / 零 VTK 依赖(可独立单测)
│ │ ├─ model/ # Project, GsObject, TmObject, DsObject, Anomaly, ColorScale, Grid, ScatterField, ScalarVolume
│ │ ├─ geo/ # LocalFrame原点+Z基准+轴向、CrsTransformPROJ 封装,多 CRS
│ │ └─ algo/ # IInterpolator 接口 + IdwInterpolator返回 core 的 ScalarVolume绝不含 VTK
│ ├─ data/ # 数据访问层(异步契约)
│ │ ├─ repo/ # IProjectRepository, IDatasetRepositoryQFuture/回调 + 取消 + 分页)
│ │ ├─ local/ # LocalSampleRepositoryQtConcurrent 线程池跑解析)+ 各格式解析器
│ │ ├─ api/ # ApiRepositoryM1 骨架,签名对齐)
│ │ └─ dto/ # 后端 JSON DTO + → model 映射
│ ├─ net/ # ApiClientQtNetwork/ AuthService验证码+RSA+login2/ CredentialQtKeychain
│ ├─ render/ # VTK 渲染层(独占 vtkRenderWindow统一管理所有 actor
│ │ ├─ Scene # 场景图、世界坐标空间、可见性;持有 RenderWindow
│ │ ├─ actors/ # ScatterActor, GridContourActor, VoxelVolumeActor, AnomalyActor, TerrainActor
│ │ ├─ color/ # ColorLutBuildercolorBar → vtkLookupTable 离散阶梯), ScalarBar
│ │ ├─ camera/ # CameraPresetTop2D / Free3D
│ │ ├─ interact/ # InteractionManager + InteractionToolMeasureTool/SliceTool/PickSelectTool
│ │ └─ ground/ # IGroundLayer + DemImageGroundLayerM1TileGroundLayerM1.5 预留)
│ ├─ view/ # QtWidgets 视图(被动;持有 VTK widget 外壳,不 new actor
│ │ ├─ login/ # LoginWindow样式参考 web
│ │ ├─ panels/ # ObjectTreePanel, DatasetListPanel, MapViewPanel(QVTKOpenGLStereoWidget),
│ │ │ # DataDetailPanel, AnomalyPanel, ObjectPropertyPanel, PropertyPanel
│ │ └─ widgets/ # ColorScaleEditor, ToolbarBits
│ ├─ controller/ # 联动编排(按交互闭环拆分,避免 God Object
│ │ ├─ SelectionController # 勾选/选中状态
│ │ ├─ RenderSyncController # 状态→Scene 渲染同步
│ │ └─ DetailSyncController # 列表↔详情↔视图定位三向联动
│ └─ app/ # main / MainWindowADS 布局、主题)/ AppContextDI 根)
├─ resources/ # QSS 主题、QtAwesome、登录页素材
├─ tests/ # gtestcore/data/algo+ Qt Testview/controller
└─ docs/
```
**架构铁律(写入 .clangd 供 AI 读取)**
- `core` 绝不 `#include` 任何 Qt / VTK 头(含 `IInterpolator`,返回 `core::ScalarVolume`)。
- VTK actor / RenderWindow 一律由 `render` 层创建与持有;`view` 只持有 `QVTKOpenGLStereoWidget` 外壳,把其 interactor 注入 render并将**拾取/交互事件回流**给 controller见 §4.4),禁止直接 new actor。
- 数据流双向已显式化:`view → render`(交互注入)与 `render → controller`(拾取/选择出站信号)。
- 信号槽连接集中在各 `*Controller` / `MainWindow``wireUp()`
- 所有落盘路径经 `QStandardPaths`(规约 §7.1)。
---
## 4. 渲染核心:单一 VTK 场景K-1
> **⚠️ 实现修正2026-06-07经离屏 PNG 核对;权威做法以此为准,详见 `plans/2026-06-07-m1-view-redesign.md` + STATUS**
> 本节 §4.2「2D/3D 仅切相机预设、零数据重建」的理想对**当前 M1 测线数据不成立**:剖面是竖直帘面,俯视只剩一条发丝线 → 俯视图空白。M1 落地做法:
> - **二维地图****三维视图** 是**两种不同渲染内容**(非同一物体换相机):二维地图 = 测线 `lat/lon` 轨迹**线**(`MapLineActor`,俯视);三维视图 = 沿测线的**竖直帘面**(`CurtainActor`,z 取负、纵向夸张、分段色带)。
> - **数据详情**(独立 QVTK)才显示单条数据集的 **#18 平面反演剖面**(`GridContourActor`,y 取负、显式 structuredGrid、colorBar 真实分段值、纵向夸张)。
> - 坐标统一用 `core::GeoLocalFrame`(经纬→局部米)。**dd_voxel/dd_slice 搁置**(散点 projX/Y 真实 CRS 未确认,无法与 lat/lon 配准)。
> - K-1「单场景 + 相机预设」仍是**长期目标**,但需要从俯视/透视都可读的内容(如带底图的地面 + 测线落地线 + 帘面共存)才成立。
> - **等值线/体素着色必须用 colorBar 真实非均匀分段值**(均匀分级会一片蓝)。**渲染改动必须用 `tests/spike/render_verify.cpp` 离屏 PNG 核对**。
### 4.1 Scene 与 RenderWindow 所有权
- `render::Scene` 持有**唯一** `vtkRenderWindow` + `vtkRenderer` + 项目世界坐标空间里的全部 actor维护当前色阶与坐标系。
- **单一 `QVTKOpenGLStereoWidget`**K-9QOpenGLWidget 系FBO 合成、reparent 友好)承载渲染窗口,**不放进 Tab**;中央面板的「二维/三维」是工具栏上的模式切换,不是两个 widget。
- view 仅持有该 widget 外壳RenderWindow/Interactor 所有权归 render。
### 4.2 2D / 3D = 三要素组合
| 模式 | CameraPreset | InteractorStyle | 典型可见性 |
|---|---|---|---|
| 二维 | Top2D正交投影、俯视、Z 锁定 | Locked2D禁旋转平移/缩放/正南正北 | 地面 + 平面要素 + 俯视散点/网格 |
| 三维 | Free3D透视、自由轨道 | Orbit3D自由旋转/缩放/平移 | 全部 actor体素、剖面、地形起伏 |
切换 = 切相机预设 + 交互器样式 + 工具集 + actor 可见性,**零数据重建**。
### 4.3 数据 → VTK 管线映射(已按评审修正)
| 数据类型 | 来源 | VTK 管线(修正后) | 备注 |
|---|---|---|---|
| 剖面散点 | 剖面原数据2597 点) | `vtkPolyData`(verts) + `vtkLookupTable` 着色 | 图 #17 |
| 网格等值面/线/标注 | 网格数据(规则栅格 x[100]×y[22]v[22][100]z 抬升) | **`vtkImageData`origin+spacingz 抬升用 `vtkWarpScalar`)→ `vtkDataSetSurfaceFilter`/`vtkGeometryFilter` → `vtkBandedPolyDataContourFilter`(开 `GenerateContourEdgesOn()` 一次产 banded 面+等值线,共用阈值)→ `vtkLabeledDataMapper` 标注** | 图 #18**不可让 structured/image 直连 banded filter**B-1 |
| dd_voxel 体绘制 | 多剖面散点 → `IInterpolator``core::ScalarVolume` | `ScalarVolume`render 转)`vtkImageData` → `vtkGPUVolumeRayCastMapper` + 颜色/不透明度传递函数 | 图 #09插值域受限§10 |
| dd_slice 切片 | voxel + 受控切面 | **`vtkResliceCursorWidget` / `vtkImageReslice`**(受控正交/任意切片),随相机模式启停 | 替代 `vtkImagePlaneWidget`避免与交互器抢事件M-2 |
| 异常圈定 | 异常数据markType 1点/2线/3面 + legend + z/elevation | **按 markType 三条子管线**:点 `vtkGlyph3D`(pointShape)、线 polydata+dashed、面 `vtkPolygon`/`vtkTriangleFilter` 填充+边框;标注屏幕空间 billboard | legend 的 `*NoOpacity` 0100 → 归一 [0,1]z 取值同剖面 Z 基准§5 |
| DEM 地形 + 影像 | dem.tif + image.tif + tfw**可能异源 CRS** | GDAL 读 → **PROJ/GDAL 重投影到项目世界 CRS**`vtkImageData``vtkWarpScalar` 抬升 + 影像纹理 | 图 #05;影像实测为 EPSG:3857须重投影§5、M-1 |
| 色阶 | colorBar[值, 颜色] 阶梯 | `vtkLookupTable`(离散阶梯,取下界)+ `vtkScalarBarActor` | 见 §7 |
### 4.4 模态交互与拾取回流M-2、B-3
- `InteractionManager` 管理**模态工具**激活互斥与 VTK observer 优先级:`MeasureTool`、`SliceTool`、`PickSelectTool`。工具激活/退出负责其临时 actor 生命周期。
- 3D Widget切片与自定义 InteractorStyle 共享同一 interactor须显式管理 `SetEnabled()` 与事件优先级,避免抢事件。
- **拾取回流通道**`render` 拾取到对象 → 经 view 中转发出出站信号 → `DetailSyncController` → 列表/详情定位。此箭头在分层图中显式存在§3
### 4.5 GroundLayer 可插拔
`IGroundLayer { build(Scene&); setVisible(bool); }`M1 `DemImageGroundLayer`M1.5 `TileGroundLayer`。若 VTK 贴瓦片体验差D-5可仅替换二维为 MapLibre 而不动 data/render 的 actor 体系。
---
## 5. 坐标系设计K-7评审最大短板已重写
数据现实(已核验真实样本):
- 剖面/网格/异常:带 GIS 投影 `projectX`≈516868=**Easting**、`projectY`≈2494259=**Northing**;另带局部米 `xlist/ylist`(各数据集自原点起算)。
- ⚠️ **CRS 待确认(Phase 1 用 PROJ 实测纠正)**:`projectX/Y` 的真实 CRS **不是 EPSG:32649**。PROJ 实测 `(516868,2494259)` 在 EPSG:32649 下解出 lon≈**111.16°E**,而网格自带 lat/lon 是 **114.16°E**(docx 标明为**香港** Volia 数据,香港≈114°E)——真实 CRS 的中央经线在 ~114°E(疑为港式/自定义 TM)。**做底图/影像配准(M1.5)前必须向客户确认项目 CRS**。
- **对 M1 core 无影响**:`LocalFrame` 用相对米(减原点,CRS 无关),网格自带 lat/lon;`CrsTransform` 已实现并单测验证 PROJ 机制本身。
- 影像 `image.tfw`:原点 (12708343, 2577685) = **EPSG:3857Web 墨卡托)**,与剖面**不同投影**。
- 网格另带 `elevation[100]` / `lat/lon`经纬度EPSG:4326
- API 几何 `tm/geometry/get` 返回 **EPSG:4326**
**设计**
1. **唯一权威系 = 项目世界系**:局部米,含明确 Z 基准;选定一个工作平面 CRS默认项目 UTM如 EPSG:32649+ 双精度原点偏移。
2. **每数据源各记源 CRS + 源 LocalFrame**:领域模型为每个数据集保存其源 CRS 与(如有)自身局部原点。**不假设全项目单一 CRS**。
3. **统一 rebase 管线**(显式步骤,非一句话):任何几何进入 Scene 前,`CrsTransform`PROJ`源局部米 →(源原点)→ 源 CRS GIS → 项目世界 CRS →(减项目原点)→ 项目世界米`。多数据集因此对齐到同一世界系(解决 B-1 多原点冲突)。
4. **轴向约定钉死**world.x = Easting = `projectX`world.y = Northing = `projectY`world.z 向上为正、单位米。**解析器不信 `eastCoord/northCoord` 字段名**(实测与值颠倒),按 projectX/Y 取值单测对照B-2
5. **垂向Z基准统一**M-3`LocalFrame` 定义高程基准面、向上为正、单位米、可选垂向夸张 z-scale。网格 z剖面深度/构造面、DEM `elevation`(地表高程)、体素 Z 在进入 Scene 前统一归算到该基准,避免地形与剖面垂直穿插。
6. **影像/DEM 重投影**:装载时经 GDAL/PROJ 重投影到项目世界 CRS 再贴地,**不能简单减原点**M-1
7. **float 精度**:世界=局部米(小数值)从根本规避 VTK float 大坐标抖动。
---
## 6. 数据层RepositoryK-4异步契约
接口即按 **API 现实形态**定义(异步 + 分页 + 取消),本地实现用 QtConcurrent 满足同一签名M-1
```
IProjectRepository {
QFuture<Project> loadProject(id);
QFuture<vector<GsObject>> loadStructure(projectId); // GS/TM 树
}
IDatasetRepository {
QFuture<Page<DsObject>> listDatasets(tmObjectId, PageReq); // 分页
QFuture<ScatterField> loadScatter(dsId);
QFuture<Grid> loadGrid(dsId);
QFuture<ColorScale> loadColorScale(dsId);
QFuture<vector<Anomaly>> loadAnomalies(dsId);
QFuture<TerrainTile> loadTerrain(...);
// 大数据(体素/雷达):返回带取消句柄 + 进度回调M1.5 走 FlatBuffers/Protobuf 流
RequestHandle loadVolumeStream(dsId, sink, onProgress); // 可 cancel
}
```
- 切换 ds 时取消上一个未完成请求;列表类带游标/分页;大数据流式 + 进度 + 取消。
- **M1**`LocalSampleRepository` 读样本目录解析器映射成领域模型DTO ↔ model 在 `data/dto` 隔离)。
- **未来**`ApiRepository` 同签名对接 `pop-api`
### 6.1 样本文件 → 模型解析约定(已核对真实样本)
| 文件 | 结构 | 解析要点 |
|---|---|---|
| 剖面原数据N.txt | `{data:{min,max,projectXList,projectYList,vlist,xlist,ylist,hlist}}` | 2597 点local(x,y)+gis(projX=East,projY=North)+value |
| 剖面网格数据N.txt | `{data:{x[100],y[22],v[22][100],z[22][100],elevation[100],lat[100],lon[100],vmin,vmax,overlayCoordinate,overlayElevation}}` | **规则栅格**(dx≈0.709,dy≈0.704 恒定)→ vtkImageData**v/z 为 [j=y][i=x],灌点序 i 最快**;无顶层 min/max对未知字段宽容 |
| 剖面网格数据的色阶数据N.txt | `{data:{properties:{colorBar:[[值,rgba]],lineConfig,labelConfig,lvlMinMax}}}` | 17 段阶梯;`lineType` 实测 "solid"(以配置为准,勿硬编码 dashed |
| 剖面网格数据N——对应的异常圈定数据.txt | `{data:[{exceptionName,exceptionMarkType(1点/2线/3面),legend{point*/polyline*/polygon*},location:{coordinate[{x,y}]},zlist?,elevationList?,geographicalCoordinates{projectX,projectY,...}}]}` | 字段比早期列举多;`eastCoord/northCoord` 名值颠倒,按 projectX/Y 取 |
| dem.tif / image.tif / image.tfw | GeoTIFF + world file | **影像 tfw 为 EPSG:3857**GDAL 读 + PROJ 重投影到世界 CRS |
| test_001_A*.head/.data/.cor | GPR 原始462×4100×int16多通道分文件 | **属 M1.5 雷达LocalSampleRepository 不解析** |
---
## 7. 色阶ColorScale
`colorBar``[值, 颜色]` 阶梯数组(颜色支持 `#RRGGBB``rgba(r,g,b,a)`)。映射:值落相邻两 stop 间取**下界 stop** 颜色(阶梯,非线性插值)。
- **实现统一为离散 `vtkLookupTable`**贴合「取下界阶梯」语义2D/3D 共用同一可信源),显式定义 under低于首 stop/ over高于末 stop/ NaN 颜色。
- **alpha 量纲按色阶来源文件类型判定**(网格色阶 0255、LVL 色阶 01解析器入口带 source 标记,**不按数值范围猜**m-2
- `lineConfig`:等值线显隐/颜色/`lineType`(以配置为准)/zmin/zmax`labelConfig`:标注显隐/颜色;`equalAreaLayerCount`/`logLinesCount`。
- 视图层 `ColorScaleEditor`M1 读取与基本调整;命名保存对接后续色阶模板 API。
---
## 8. 登录与网络层M1 必做,真实流程已抓取)
### 8.1 已确认的生产实现细节
- **API 基址** `http://tenant.geomative.cn/pop-api`openresty 反代OpenAPI 的 `/admin/*`、`/business/*` 加 `/pop-api` 前缀)。
- **认证头** `geomativeauthorization: Geomative <token>`(不透明会话令牌,非 JWT
- **登录三步**:① `GET /business/system/personalUser/getImageCode`→验证码图+`codeId` → ② `POST /business/system/personalUser/verifyCodeCheck {code,codeId}` → ③ `POST /admin/tenant/auth/login2 {username, password=RSA加密, checkCode}`→token。
- **密码加密 = JSEncrypt RSA-2048**(前端 vendor 用 JSEncrypt 库;密文 base64 ~344 字符 = 256 字节。token 取响应 **`data.accessToken`**(值即 `"Geomative <hash>"`,存 web localStorage `token`)。
- 另有 `/email`、`/phone` 登录支线(非 M1
- 登录后:`getInfo` / `list-menus` / `enterprise/info` / `enterprise/joined/list`
### 8.2 实现要点
- `AuthService`:取验证码→展示→校验→**OpenSSL RSA** 加密密码→login2→持有 token。
- `Credential`**QtKeychain**token 存平台密钥库,严禁明文(规约 §7.4)。
- `ApiClient`:注入 `geomativeauthorization`、基址、超时、错误码、401 处理QtNetwork 原生。
- **登录窗 UI**:样式参考现有 web 登录页(实现阶段截图复刻)。
### 8.3 ⚠️ 前置确认项(与 RSA 同级M-5
抓取的真实流程里**未见 refresh-token 实际使用login2 只返不透明会话 token**。因此:
- **RSA 公钥已取得 ✅**(Phase 3,用 Playwright `page.route` 拦截 JS chunk 给 `setPublicKey` 注入 hook + 缓存绕过强制加载补丁版,触发一次真登录捕获)。RSA-2048 SPKI,存于 `resources/rsa_public_key.pem`。加密用 PKCS#1 v1.5(JSEncrypt 默认),`RsaEncryptor`(OpenSSL)已实现+单测。
- **token 生命周期 / 是否有 refresh 机制**待确认。据此二选一设计:
- (a) 有 refresh token → 标准静默刷新、401 静默续期。
- (b) 仅会话 token → 「免登录」= 持久化会话 token 至其有效期;**到期/401 引导用户重新登录(含验证码),不声称静默重登**。
- 本项在 spike/实现前向后端确认spec 不把「静默刷新」当既定能力。
---
## 9. UI 外壳完整工作台K-3
- **停靠框架**ADSLGPL规约 §6.2)。**VTK 面板默认不可浮动**(或浮动时占位、停靠回重建),缓解 reparent 上下文问题spike 验证M-4
- **三区布局**(还原原型):左(对象树 + 数据集列表)/ 中2D-3D 视图 + 数据详情)/ 右(异常-对象属性 + 属性)。
- **主题**QSS + QDarkStyleSheet 打底 + QtAwesome 图标。
- **布局持久化**ADS 透视图 + 窗口几何存 QSettingsWindows 强制 INI规约 §7.2)。
- **联动**controller 按闭环拆分§3勾选 GS/TM→按 dd 类型筛选 ds→勾选 ds→渲染列表↔详情↔视图定位三向色阶调整两视角实时更新。
---
## 10. 算法展示期三维插值K-6、K-10
- `core/algo/IInterpolator``core::ScalarVolume interpolate(const PointSet& pts, const GridSpec& spec)`——**返回 core 中立类型**dims/spacing/origin/double 数组),绝不含 VTKM-2。render 层 `VoxelVolumeActor``ScalarVolume`→`vtkImageData`。
- M1 实现 `IdwInterpolator`反距离加权Eigen 辅助2597 点规模**不需要 PCL/KD-tree**m-1
- **可信度与数据依赖K-10、B-3**:可信体素需 **≥3 条非共线剖面或真实 3D 网格dd_Property3D/ 反演网格** 输入。仅两条近平行剖面 IDW 会得到「夹层片状」幻影。故:
- 插值**限定在输入包络内 + 最大距离 clamp**,包络外置 blank/透明,避免 ray cast 渲染整盒幻影。
- M1 体绘制按 §13 分阶段:先在**充分输入**数据上出可信体;输入不足的复杂体后置。
- **数据依赖**需客户提供达到可信度的体素级输入数据≥3 剖面 / 3D 网格)——列入 §14 待办。
- **不做反演**上游、Python 生态 ResIPy 等),未来按规约 §8.3 进程隔离接入M1 仅接口预留。
---
## 11. 构建与依赖K-8方案②-修订:官方 MSVC Qt + 源码 VTK + vcpkg 非 Qt 依赖)
- **构建**CMake 3.21+。MSVC 工具集 **VS18 / 14.51**实机C++17生成 `compile_commands.json`
- **单一 Qt 纪律(核心)**:全链路只用**一份官方 MSVC 预编译 Qt**`D:\Qt\6.11.1\msvc2022_64`,经 `CMAKE_PREFIX_PATH`)。**凡依赖 Qt 的组件都不走 vcpkg**vcpkg 任何 Qt 依赖端口都会再编一份 qtbase = 双份冲突,已核 `ports/vtk/vcpkg.json``vtk[qt]`→`qtbase`+`qtdeclarative`)。
- **VTK**:无 MSVC 预编译,**必须源码编**。预先用官方 Qt 把 VTK 9.3 配置/编译/`install` 到 `external/vtk-install``-DVTK_GROUP_ENABLE_Qt=YES -DQt6_DIR=...`app 经 `VTK_DIR` `find_package(VTK)`。一次编好、隔离于 app 构建。
- **ADS / QtKeychain**:经 **FetchContent** 对接同一份官方 Qt体量小源码编可接受**不走 vcpkg**。
- **非 Qt 依赖经 vcpkg**GDAL/PROJ/OpenSSL/Eigen/spdlog/fmt/nlohmann-json/gtest这些不拉 Qt
- **M1 依赖矩阵**
| 依赖 | 来源 | 用途 | 许可证 |
|---|---|---|---|
| Qt 6.11.1msvc2022_64预编译 | 官方安装器(MSVC kit) | UI/网络/SQL/并发 | LGPLv3动态 商务 D-2 |
| VTK 9.3[qt,opengl] + gdal/proj 可选) | **源码编→install 前缀** | 三维渲染 + QVTK widget | BSD ✅ |
| ADSQt-Advanced-Docking-System | FetchContent对接官方 Qt | 停靠布局 | LGPL v2.1 ✅ |
| QtKeychain | FetchContent对接官方 Qt | 凭证存储 | BSD ✅ |
| gdal / proj | vcpkg | DEM/影像/坐标重投影 | MIT 类 ✅ |
| openssl | vcpkg | RSA/HTTPS | Apache 2.0 ✅ |
| eigen3 | vcpkg头文件 | 数值/插值 | MPL2 ✅ |
| spdlog / fmt | vcpkg | 日志 | MIT ✅ |
| nlohmann-json | vcpkg头文件 | JSON | MIT ✅ |
| gtest | vcpkg | 单测 | BSD ✅ |
- ~~PCL~~**M1 移除**(点规模不需要)。
- **ABI**:官方 Qt 为 MSVC 2022(v143)预编译,本机 VS18(14.51)编 VTK/app/ADS/QtKeychain"新链旧"在 MSVC v14x 兼容区内**安全**,全程动态 CRT `/MD[d]`、Release 链 Release、Debug 链 Debug,不跨配置混链。
- 部署:用**官方 Qt 的 `windeployqt`**(`D:\Qt\6.11.1\msvc2022_64\bin\windeployqt.exe`)部署 Qt + 插件;VTK/vcpkg dll 用 `TARGET_RUNTIME_DLLS` 拷贝。确保 exe 目录只有这一份 Qt。
- **二进制缓存**vcpkg 实测无缓存,落地前先配 `VCPKG_BINARY_SOURCES`(省 GDAL/PROJ 等重编)。
- 环境从零搭建:见 `docs/ENV_SETUP_Windows.md`(方案②-修订)。
---
## 12. 测试策略(规约 §10.2
- `core`/`data`/`algo`gtest坐标 rebase/轴向、Z 基准归算、colorBar LUT 映射、v[j][i] 灌点序、样本解析、IDW 正确性)。
- `view`/`controller`Qt Test联动、模态工具互斥
- 失败路径必测:登录失败/验证码错/token 失效、样本缺失/损坏、空数据集、异源 CRS 重投影。
- 渲染以小基准截图人工核对(对照图 #17/#18/#09)。
- clang-tidy + cppcheck 入 CIDebug 启用 ASan/UBSan。
---
## 13. M1 验收标准图分阶段K-5/K-10
**M1-a先达成**
1. 启动→登录窗(近 web→真连 `pop-api` 登录成功。
2. 完整工作台 ADS 三区停靠;对象树/数据集来自本地样本。
3. ① 渲染剖面散点(图 #17 样)、网格 banded 等值面+等值线+标注(图 #18 样)、异常按 markType 圈定。
4. ④ DEM 地形起伏 + 影像(经重投影对齐)。
5. 色阶离散 LUT 可调,两视角实时联动;二维俯视相机预设可切。
**M1-b在充分输入数据上达成可信体**
6. 由 ≥3 剖面/3D 网格经 IDW 生成**可信** dd_voxel 体绘制,插值域受限;`vtkResliceCursorWidget` 交互切片得 dd_slice。
**通用**core 单测通过clang-tidy 无新增告警;布局/偏好持久化生效2D/3D 切换零数据重建、坐标对齐正确。
---
## 14. 风险与待决(承接规约 §11
| 风险/待决 | 说明 | 处理 |
|---|---|---|
| D-2 Qt 许可证 | LGPL 动态 vs 商业 | M1 按 LGPL 动态链接;商务并行 |
| RSA 公钥来源 + token 生命周期 | 登录加密公钥、是否有 refresh | spike 前向后端确认§8.3 |
| 可信体输入数据 | 2 平行剖面不足以出可信体K-10 | 需客户提供 ≥3 剖面/3D 网格M1-b 验收依赖此 |
| ADS 端口 + QVTK reparent | vcpkg 端口可用性、浮动黑屏 | spike 门槛验证§15 |
| 全 vcpkg 首编译耗时 | VTK+Qt 编译久 | 二进制缓存 `VCPKG_BINARY_SOURCES` |
| 异源 CRS 配准 | 影像 3857 vs 剖面 32649 | GDAL/PROJ 重投影§5 |
| macOS / OpenGL 废弃 | 规约 §3.3 | M1 仅 Windows保持可移植 |
---
## 15. Spike 预研门槛K-2进入完整实现计划前必过
第一周先跑通三个高风险点,任一不过则调整方案后再展开计划:
1. **构建/部署 spike**:全 vcpkgqtbase + vtk[qt] 共用一份 Qt配置、编译、出 exe、单一链路部署、无双 Qt 冲突。
2. **UI 上下文 spike**ADSvcpkg 或 FetchContent+ `QVTKOpenGLStereoWidget`,验证停靠/浮动/重停靠不黑屏、相机预设切换稳定。
3. **渲染管线 spike**:用真实样本跑通 `vtkImageData(+warp) → geometry filter → vtkBandedPolyDataContourFilter(GenerateContourEdges) → 标注`,目视对照图 #18;散点 + 离散 LUT 色阶对照图 #17
---
## 16. v2 修订记录(对应评审)
- 网格管线改 `vtkImageData(+warp)→geometry filter→banded contour(GenerateContourEdges)`B-1/B-2 code
- 坐标系重写:多源 CRS + 各自 LocalFrame + 统一 rebase + 轴向钉死 + Z 基准 + 影像重投影B-1/B-2 arch、M-1/M-3 code、M-3 arch
- dd_voxel 维持可信体但列数据依赖、插值域受限、验收分阶段B-3 code、K-10
- Repository 改异步契约(分页/取消/流M-1 arch
- `IInterpolator` 返回 core `ScalarVolume`,去 VTKM-2 arch
- 交互:模态工具抽象 + 拾取回流 + 切片改 `vtkResliceCursorWidget`B-3 arch、M-2 code
- widget 改 `QVTKOpenGLStereoWidget` + VTK 面板不可浮动 + spikeM-4 code
- 构建改全 vcpkg已核 vtk[qt]→qtbase 依赖)、删 PCLM-5 code、m-1
- 登录 refresh/token 生命周期降为前置确认M-5 arch
- 色阶离散 LUT + under/over/NaN + alpha 按来源 + lineType 以配置为准m-1/m-2/m-4 各)。
- controller 拆 Selection/RenderSync/DetailSyncm-5 arch
- 新增 §15 spike 门槛K-2
---
*v2 经双专家评审 + 数据核验修订。下一步spike 预研 → writing-plans。*

View File

@ -0,0 +1,25 @@
对象:项目/gs/tm对象鼠标右键tm导入ds编辑在右侧面板直接做gs也有导入ds功能
数据集:单击更新属性,双击时打开或选中数据详情,选中三维视图;文件列表,双击按支持的文件格式预览,不支持的文件格式提示+下载;
二维三维视图对象视图中选中的TM需要使用树形列表展示可渲染图形的tm/ds树并支持选中或取消某个tm/ds决定是否渲染展示异常鼠标点选图形时如果在垂直屏幕方向有多个图形叠加需要把多个图形的信息列出来方便用户选中被遮住的图形
数据详情:
异常gs选择后展示下面的合集异常体不渲染通过类型或状态过滤支持多选异常创建异常体支持将异常拖入异常体
项目列表:新增项目,最后访问时间倒序;项目列表可切换地图模式;
分析视图、大屏视图web按项目类型设置绑定视图客户端可切换分析视图默认
个人资料:显示基本信息、修改邮箱、修改密码;
大文件上传ds中的地形文件需要走独立的接口

View File

@ -11,16 +11,22 @@ find_package(VTK REQUIRED COMPONENTS
FiltersModeling FiltersModeling
) )
find_package(nlohmann_json CONFIG REQUIRED) find_package(nlohmann_json CONFIG REQUIRED)
find_package(Qt6 REQUIRED COMPONENTS Svg)
add_executable(geopro_desktop WIN32 add_executable(geopro_desktop WIN32
main.cpp main.cpp
Theme.cpp
TopBar.cpp
Glyphs.cpp
PanelHeader.cpp
login/LoginWindow.cpp login/LoginWindow.cpp
panels/AnomalyListPanel.cpp) panels/AnomalyListPanel.cpp
panels/DatasetListPanel.cpp)
target_include_directories(geopro_desktop PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) target_include_directories(geopro_desktop PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
target_link_libraries(geopro_desktop PRIVATE target_link_libraries(geopro_desktop PRIVATE
Qt6::Core Qt6::Gui Qt6::Widgets Qt6::Core Qt6::Gui Qt6::Widgets Qt6::Svg
${VTK_LIBRARIES} ${VTK_LIBRARIES}
ads::qt6advanceddocking ads::qt6advanceddocking
nlohmann_json::nlohmann_json nlohmann_json::nlohmann_json

153
src/app/Glyphs.cpp Normal file
View File

@ -0,0 +1,153 @@
#include "Glyphs.hpp"
#include <QByteArray>
#include <QDir>
#include <QImage>
#include <QPainter>
#include <QPen>
#include <QPixmap>
#include <QPointF>
#include <QRectF>
#include <QString>
#include <QSvgRenderer>
namespace geopro::app {
namespace {
// 每个图标的 SVG 内容线性风格24x24 视窗,描边由外层注入颜色)。
// 路径取自 Lucide 图标集MIT 许可,业界通用),保证专业、清晰、语义正确。
QString svgPathFor(Glyph t)
{
switch (t) {
case Glyph::Tree:
return QStringLiteral(
"<path d='M21 12h-8'/><path d='M21 6H8'/><path d='M21 18h-8'/>"
"<path d='M3 6v4c0 1.1.9 2 2 2h3'/><path d='M3 10v6c0 1.1.9 2 2 2h3'/>");
case Glyph::Dataset:
return QStringLiteral(
"<ellipse cx='12' cy='5' rx='9' ry='3'/>"
"<path d='M3 5v14a9 3 0 0 0 18 0V5'/><path d='M3 12a9 3 0 0 0 18 0'/>");
case Glyph::Map:
return QStringLiteral(
"<path d='m12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 "
"0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z'/>"
"<path d='m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65'/>"
"<path d='m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65'/>");
case Glyph::Detail:
return QStringLiteral(
"<path d='M3 3v18h18'/><path d='M18 17V9'/><path d='M13 17V5'/><path d='M8 17v-3'/>");
case Glyph::Anomaly:
return QStringLiteral(
"<path d='m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 "
"1.73-3Z'/><path d='M12 9v4'/><path d='M12 17h.01'/>");
case Glyph::Property:
return QStringLiteral(
"<circle cx='12' cy='12' r='10'/><path d='M12 16v-4'/><path d='M12 8h.01'/>");
case Glyph::Plus:
return QStringLiteral("<path d='M5 12h14'/><path d='M12 5v14'/>");
case Glyph::Filter:
return QStringLiteral(
"<polygon points='22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3'/>");
case Glyph::Upload:
return QStringLiteral(
"<path d='M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4'/>"
"<path d='M17 8l-5-5-5 5'/><path d='M12 3v12'/>");
case Glyph::Download:
return QStringLiteral(
"<path d='M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4'/>"
"<path d='M7 10l5 5 5-5'/><path d='M12 15V3'/>");
case Glyph::Collapse:
return QStringLiteral("<path d='m17 11-5-5-5 5'/><path d='m17 18-5-5-5 5'/>");
case Glyph::Workspace:
return QStringLiteral(
"<rect width='7' height='7' x='3' y='3' rx='1'/>"
"<rect width='7' height='7' x='14' y='3' rx='1'/>"
"<rect width='7' height='7' x='14' y='14' rx='1'/>"
"<rect width='7' height='7' x='3' y='14' rx='1'/>");
case Glyph::Folder:
return QStringLiteral(
"<path d='M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 "
"0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z'/>");
case Glyph::Help:
return QStringLiteral(
"<circle cx='12' cy='12' r='10'/>"
"<path d='M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3'/><path d='M12 17h.01'/>");
case Glyph::Bell:
return QStringLiteral(
"<path d='M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9'/>"
"<path d='M10.3 21a1.94 1.94 0 0 0 3.4 0'/>");
case Glyph::Gear:
return QStringLiteral(
"<path d='M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 "
"0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 "
"1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73 "
".73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 "
"2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 "
"2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 "
".73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 "
"1-1-1.73V4a2 2 0 0 0-2-2z'/><circle cx='12' cy='12' r='3'/>");
}
return QString();
}
} // namespace
QIcon makeGlyph(Glyph type, const QColor& color, int px)
{
const QString svg =
QStringLiteral("<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' "
"stroke='%1' stroke-width='2' stroke-linecap='round' "
"stroke-linejoin='round'>%2</svg>")
.arg(color.name(), svgPathFor(type));
QSvgRenderer renderer(svg.toUtf8());
// 以 3x 超采样渲染再设 devicePixelRatio保证在任意缩放/DPI 下都清晰。
constexpr qreal kSuper = 3.0;
const int dim = qRound(px * kSuper);
QImage img(dim, dim, QImage::Format_ARGB32_Premultiplied);
img.fill(Qt::transparent);
QPainter p(&img);
p.setRenderHint(QPainter::Antialiasing, true);
renderer.render(&p, QRectF(0, 0, dim, dim));
p.end();
QPixmap pm = QPixmap::fromImage(img);
pm.setDevicePixelRatio(kSuper);
return QIcon(pm);
}
QString writeChevronIcon(bool open, const QColor& color)
{
constexpr int px = 16;
constexpr int kScale = 3;
QPixmap pm(px * kScale, px * kScale);
pm.fill(Qt::transparent);
QPainter p(&pm);
p.setRenderHint(QPainter::Antialiasing, true);
QPen pen(color, px * kScale * 0.1);
pen.setCapStyle(Qt::RoundCap);
pen.setJoinStyle(Qt::RoundJoin);
p.setPen(pen);
const double w = px * kScale;
const double h = px * kScale;
if (open) { // ▼ 向下
p.drawLine(QPointF(w * 0.32, h * 0.42), QPointF(w * 0.5, h * 0.60));
p.drawLine(QPointF(w * 0.5, h * 0.60), QPointF(w * 0.68, h * 0.42));
} else { // ▶ 向右
p.drawLine(QPointF(w * 0.42, h * 0.32), QPointF(w * 0.60, h * 0.5));
p.drawLine(QPointF(w * 0.60, h * 0.5), QPointF(w * 0.42, h * 0.68));
}
p.end();
const QString path =
QDir(QDir::tempPath()).filePath(open ? QStringLiteral("geopro_tree_open.png")
: QStringLiteral("geopro_tree_closed.png"));
pm.save(path, "PNG");
return path;
}
} // namespace geopro::app

41
src/app/Glyphs.hpp Normal file
View File

@ -0,0 +1,41 @@
#pragma once
// 程序绘制的扁平线性图标(无需图片资源):用于面板表头与表头操作按钮,
// 风格对齐原型(细线、圆角端点、单色随主题)。仅外观,无交互逻辑。
#include <QColor>
#include <QIcon>
#include <QString>
namespace geopro::app {
enum class Glyph {
// 面板表头图标
Tree, // 对象显示栏(层级)
Dataset, // 数据真实显示栏(数据集/数据库)
Map, // 二维/三维视图(图层)
Detail, // 数据详情(图表)
Anomaly, // 异常列表(警示三角)
Property, // 属性(信息)
// 表头操作按钮图标
Plus, // 新建/添加
Filter, // 筛选(漏斗)
Upload, // 上传
Download, // 导出/下载
Collapse, // 折叠(双箭头)
// 顶部应用栏图标
Workspace, // 工作空间2x2 宫格)
Folder, // 项目(文件夹)
Help, // 帮助(?
Bell, // 通知(铃铛)
Gear, // 设置(齿轮)
};
// 生成指定颜色、像素尺寸的图标(默认 16px内部按 2x 绘制保证清晰)。
QIcon makeGlyph(Glyph type, const QColor& color, int px = 16);
// 生成树展开/折叠箭头 PNG 到临时目录,返回文件路径(供树的 QSS `image: url(...)` 引用)。
// 配合 QTreeView::branch 背景使用,可让选中高亮不覆盖左侧缩进/箭头列且不丢箭头。
QString writeChevronIcon(bool open, const QColor& color);
} // namespace geopro::app

151
src/app/PanelHeader.cpp Normal file
View File

@ -0,0 +1,151 @@
#include "PanelHeader.hpp"
#include <QButtonGroup>
#include <QColor>
#include <QHBoxLayout>
#include <QLabel>
#include <QSize>
#include <QStackedWidget>
#include <QToolButton>
#include <QVBoxLayout>
#include <QWidget>
namespace geopro::app {
namespace {
// ── 专业图标/字号尺寸(统一放大)──
constexpr int kHeaderHeight = 42;
constexpr int kTitleIcon = 20; // 表头标题图标
constexpr int kActionIcon = 19; // 表头操作按钮图标
constexpr int kTabIcon = 19; // Tab 图标
// 表头统一样式(标准表头 + Tab 表头共用)。
const char* kHeaderQss =
"#panelHeader { background:#FFFFFF; border-bottom:1px solid #E6EAF1; }"
"#panelTitle { color:#1F2A3D; font-size:14px; font-weight:600; }"
"#panelBadge { background:#EAEEF5; color:#5A6B85; border-radius:9px;"
" padding:1px 7px; font-size:12px; font-weight:600; }"
"QToolButton#panelAction { border:none; border-radius:7px; padding:5px; }"
"QToolButton#panelAction:hover { background:#EEF3FB; }"
"QToolButton#tabBtn { border:none; border-bottom:2px solid transparent; color:#5A6B85;"
" padding:8px 4px; font-size:14px; }"
"QToolButton#tabBtn:hover { color:#1F2A3D; }"
"QToolButton#tabBtn:checked { color:#1F2A3D; font-weight:600;"
" border-bottom:2px solid #2D6CB5; }";
// 数量徽标(默认隐藏,调用方 setText+setVisible 显示)。
QLabel* makeBadge(QWidget* parent)
{
auto* badge = new QLabel(parent);
badge->setObjectName(QStringLiteral("panelBadge"));
badge->setAlignment(Qt::AlignCenter);
badge->setMinimumWidth(16);
badge->setVisible(false);
return badge;
}
// 表头操作按钮(静态占位)。
QToolButton* makeActionButton(QWidget* parent, const HeaderAction& a)
{
auto* btn = new QToolButton(parent);
btn->setObjectName(QStringLiteral("panelAction"));
btn->setIcon(makeGlyph(a.first, QColor("#5A6B85"), kActionIcon));
btn->setIconSize(QSize(kActionIcon, kActionIcon));
btn->setCursor(Qt::PointingHandCursor);
btn->setToolTip(a.second + QStringLiteral("(占位)"));
btn->setAutoRaise(true);
return btn;
}
} // namespace
QWidget* buildPanelHeader(Glyph icon, const QString& title, const QVector<HeaderAction>& actions)
{
auto* header = new QWidget();
header->setObjectName(QStringLiteral("panelHeader"));
header->setFixedHeight(kHeaderHeight);
header->setStyleSheet(QString::fromUtf8(kHeaderQss));
auto* lay = new QHBoxLayout(header);
lay->setContentsMargins(12, 0, 8, 0);
lay->setSpacing(8);
auto* iconLbl = new QLabel(header);
iconLbl->setPixmap(makeGlyph(icon, QColor("#44546B"), kTitleIcon).pixmap(kTitleIcon, kTitleIcon));
lay->addWidget(iconLbl);
auto* titleLbl = new QLabel(title, header);
titleLbl->setObjectName(QStringLiteral("panelTitle"));
lay->addWidget(titleLbl);
lay->addWidget(makeBadge(header)); // 默认隐藏,调用方可经 findChild 更新
lay->addStretch();
for (const auto& a : actions) lay->addWidget(makeActionButton(header, a));
return header;
}
TabbedPanel buildTabbedPanel(const QVector<PanelTab>& tabs, const QVector<HeaderAction>& actions)
{
auto* box = new QWidget();
auto* v = new QVBoxLayout(box);
v->setContentsMargins(0, 0, 0, 0);
v->setSpacing(0);
auto* header = new QWidget(box);
header->setObjectName(QStringLiteral("panelHeader"));
header->setFixedHeight(kHeaderHeight);
header->setStyleSheet(QString::fromUtf8(kHeaderQss));
auto* hlay = new QHBoxLayout(header);
hlay->setContentsMargins(10, 0, 8, 0);
hlay->setSpacing(2);
auto* stack = new QStackedWidget(box);
auto* group = new QButtonGroup(box);
group->setExclusive(true);
TabbedPanel result;
result.container = box;
for (int i = 0; i < tabs.size(); ++i) {
const PanelTab& t = tabs[i];
auto* btn = new QToolButton(header);
btn->setObjectName(QStringLiteral("tabBtn"));
btn->setText(t.title);
btn->setIcon(makeGlyph(t.icon, QColor("#5A6B85"), kTabIcon));
btn->setIconSize(QSize(kTabIcon, kTabIcon));
btn->setToolButtonStyle(Qt::ToolButtonTextBesideIcon);
btn->setCheckable(true);
btn->setCursor(Qt::PointingHandCursor);
btn->setAutoRaise(true);
group->addButton(btn, i);
hlay->addWidget(btn);
QLabel* badge = nullptr;
if (t.hasBadge) {
badge = makeBadge(header);
hlay->addWidget(badge);
}
result.badges.append(badge);
stack->addWidget(t.content);
hlay->addSpacing(10);
}
hlay->addStretch();
for (const auto& a : actions) hlay->addWidget(makeActionButton(header, a));
QObject::connect(group, &QButtonGroup::idClicked, stack, &QStackedWidget::setCurrentIndex);
if (auto* first = group->button(0)) first->setChecked(true);
stack->setCurrentIndex(0);
v->addWidget(header);
v->addWidget(stack, 1);
return result;
}
} // namespace geopro::app

46
src/app/PanelHeader.hpp Normal file
View File

@ -0,0 +1,46 @@
#pragma once
// 面板自绘表头(对齐原型):
// - buildPanelHeader图标 + 标题 + (可选数量徽标) + 右侧操作按钮(标准面板)。
// - buildTabbedPanel表头为若干 Tab图标+标题+可选徽标)+ 右侧操作按钮,下方堆叠对应内容。
// 由调用方放在面板内容顶部,并隐藏 ADS 自带标题栏,从而完全掌控表头外观。
// 操作按钮当前为静态占位tooltip 标注),后续接真实功能。
#include <QPair>
#include <QString>
#include <QVector>
#include "Glyphs.hpp"
class QWidget;
class QLabel;
namespace geopro::app {
// 一个表头操作项:图标 + 提示文案。
using HeaderAction = QPair<Glyph, QString>;
// 标准面板表头。标题 QLabel objectName="panelTitle"、徽标 QLabel objectName="panelBadge"
// (默认隐藏),调用方可经 container->findChild 动态更新。
QWidget* buildPanelHeader(Glyph icon, const QString& title,
const QVector<HeaderAction>& actions = {});
// 一个 Tab 规格:图标 + 标题 + 内容 + 是否带数量徽标。
struct PanelTab {
Glyph icon;
QString title;
QWidget* content;
bool hasBadge;
};
// 带 Tab 表头的面板构建结果:容器 + 各 Tab 的徽标标签(无徽标处为 nullptr供动态更新
struct TabbedPanel {
QWidget* container;
QVector<QLabel*> badges;
};
// 构建带 Tab 表头的面板(首个 Tab 默认激活;点击 Tab 切换下方堆叠内容)。
TabbedPanel buildTabbedPanel(const QVector<PanelTab>& tabs,
const QVector<HeaderAction>& actions = {});
} // namespace geopro::app

428
src/app/Theme.cpp Normal file
View File

@ -0,0 +1,428 @@
#include "Theme.hpp"
#include <QApplication>
#include <QColor>
#include <QFont>
#include <QPalette>
#include <QStyleFactory>
namespace geopro::app {
namespace {
// 全局样式表(浅色专业)。只描述外观,不含任何交互逻辑。
// 注意:刻意不重写 QCheckBox::indicator —— Fusion 一旦检测到 indicator 子控件被改写,
// 就需要自带勾选 image否则勾选态会变成空白方块。这里交给 Fusion 原生绘制,
// 它会自动采用调色板的 Highlight(#2D6CB5) 作勾选色,省去打包图片资源。
const char* kStyleSheet = R"QSS(
/* ── 基础 ───────────────────────────────────────────────── */
QWidget {
color: #1F2A3D;
}
QMainWindow, QDialog {
background: #F4F6FA;
}
QToolTip {
background: #1F2A3D;
color: #F4F6FA;
border: 1px solid #2D6CB5;
border-radius: 4px;
padding: 4px 8px;
}
/* ── 视图内工具条2D/3D、数据详情白底分段控件柔和不刺眼 ── */
QToolBar {
background: #FFFFFF;
border: none;
border-bottom: 1px solid #EAEEF4;
padding: 6px 8px;
spacing: 4px;
}
QToolBar QToolButton {
background: transparent;
color: #5A6B85;
border: none;
border-radius: 7px;
padding: 6px 14px;
font-weight: 500;
}
QToolBar QToolButton:hover {
background: #EEF3FB;
color: #1F2A3D;
}
QToolBar QToolButton:pressed {
background: #DCE9F8;
}
QToolBar QToolButton:checked {
background: #EAF1FB;
color: #2D6CB5;
font-weight: 600;
}
QToolBar QToolButton:checked:hover {
background: #DCE9F8;
}
QToolBar::separator {
background: #EAEEF4;
width: 1px;
margin: 6px 8px;
}
/* ── 树 / 列表:无边框(靠面板与留白分隔,去掉线框感)+ 充足行距 ── */
QTreeWidget, QListWidget, QTreeView, QListView {
background: #FFFFFF;
border: none;
padding: 6px;
outline: none;
}
QTreeWidget::item, QListWidget::item, QTreeView::item, QListView::item {
padding: 7px 8px;
border-radius: 6px;
margin: 1px 4px;
}
QTreeWidget::item:hover, QListWidget::item:hover,
QTreeView::item:hover, QListView::item:hover {
background: #EEF3FB;
}
QTreeWidget::item:selected, QListWidget::item:selected,
QTreeView::item:selected, QListView::item:selected {
background: #DCE9F8;
color: #1B3D67;
}
/* 注意:不要给 QTreeView::branch 设 background——一旦改写 branchQt 会停止绘制
/ indicator */
/* 表头(对象显示栏) */
QHeaderView::section {
background: #EDF1F7;
color: #3A475C;
border: none;
border-bottom: 1px solid #D5DBE5;
padding: 6px 8px;
font-weight: 600;
}
/* ── 标签页(数据 / 文件):现代下划线 tab无边框盒子 ──────── */
QTabWidget::pane {
border: none;
border-top: 1px solid #EAEEF4;
top: 0;
background: #FFFFFF;
}
QTabBar {
background: transparent;
}
QTabBar::tab {
background: transparent;
color: #5A6B85;
border: none;
border-bottom: 2px solid transparent;
padding: 8px 16px;
margin-right: 4px;
}
QTabBar::tab:selected {
color: #2D6CB5;
border-bottom: 2px solid #2D6CB5;
font-weight: 600;
}
QTabBar::tab:hover:!selected {
color: #1F2A3D;
}
/* ── 复选框(仅调间距/字色indicator 交给 Fusion 原生)──── */
QCheckBox {
spacing: 7px;
color: #1F2A3D;
}
QCheckBox:disabled {
color: #9AA6B6;
}
/* ── 通用按钮 / 输入(登录窗内部各自再覆盖)────────────────── */
QPushButton {
background: #FFFFFF;
color: #1F2A3D;
border: 1px solid #C2CCDA;
border-radius: 6px;
padding: 6px 14px;
}
QPushButton:hover {
background: #EEF3FB;
border-color: #2D6CB5;
}
QPushButton:pressed {
background: #DCE9F8;
}
QPushButton:default {
background: #2D6CB5;
color: #FFFFFF;
border-color: #2D6CB5;
}
QPushButton:default:hover {
background: #2862A6;
}
QPushButton:disabled {
background: #F0F2F6;
color: #9AA6B6;
border-color: #DCE0E7;
}
QLineEdit {
background: #FFFFFF;
color: #1F2A3D;
border: 1px solid #C7D2E0;
border-radius: 6px;
padding: 5px 8px;
selection-background-color: #2D6CB5;
selection-color: #FFFFFF;
}
QLineEdit:focus {
border: 1px solid #2D6CB5;
}
QLineEdit:disabled {
background: #F0F2F6;
color: #8A93A3;
}
/* ── 滚动条:纤细现代(无需图片资源)───────────────────────── */
QScrollBar:vertical {
background: transparent;
width: 12px;
margin: 2px;
}
QScrollBar::handle:vertical {
background: #C2CCDA;
border-radius: 5px;
min-height: 28px;
}
QScrollBar::handle:vertical:hover {
background: #A7B4C7;
}
QScrollBar:horizontal {
background: transparent;
height: 12px;
margin: 2px;
}
QScrollBar::handle:horizontal {
background: #C2CCDA;
border-radius: 5px;
min-width: 28px;
}
QScrollBar::handle:horizontal:hover {
background: #A7B4C7;
}
QScrollBar::add-line, QScrollBar::sub-line {
width: 0;
height: 0;
}
QScrollBar::add-page, QScrollBar::sub-page {
background: transparent;
}
/* ── 分隔条:默认近乎隐形,悬停时才显淡色(去掉灰硬条)──────── */
QSplitter::handle {
background: #EAEEF4;
}
QSplitter::handle:hover {
background: #C7D2E0;
}
ads--CDockSplitter::handle {
background: #EAEEF4;
}
ads--CDockSplitter::handle:hover {
background: #C7D2E0;
}
/* ── 状态栏:底部信息条(坐标系 / 状态指示,常驻可见)──────── */
QStatusBar {
background: #FFFFFF;
color: #5A6B85;
border-top: 1px solid #EAEEF4;
}
QStatusBar::item {
border: none;
}
QStatusBar QLabel {
color: #5A6B85;
padding: 0 4px;
}
/* ── 菜单栏 / 菜单(按需出现时也与主题一致)────────────────── */
QMenuBar {
background: #EDF1F7;
color: #1F2A3D;
border-bottom: 1px solid #D5DBE5;
}
QMenuBar::item {
background: transparent;
padding: 5px 12px;
border-radius: 6px;
}
QMenuBar::item:selected {
background: #DCE6F4;
}
QMenu {
background: #FFFFFF;
color: #1F2A3D;
border: 1px solid #D5DBE5;
border-radius: 8px;
padding: 5px;
}
QMenu::item {
padding: 6px 24px 6px 14px;
border-radius: 5px;
}
QMenu::item:selected {
background: #DCE9F8;
color: #1B3D67;
}
QMenu::separator {
height: 1px;
background: #E1E6EE;
margin: 5px 8px;
}
/* ── 下拉框(按需出现时也与主题一致)──────────────────────── */
QComboBox {
background: #FFFFFF;
color: #1F2A3D;
border: 1px solid #C2CCDA;
border-radius: 6px;
padding: 5px 10px;
min-height: 18px;
}
QComboBox:hover {
border-color: #2D6CB5;
}
QComboBox:focus {
border-color: #2D6CB5;
}
QComboBox::drop-down {
border: none;
width: 22px;
}
QComboBox QAbstractItemView {
background: #FFFFFF;
border: 1px solid #D5DBE5;
border-radius: 6px;
selection-background-color: #DCE9F8;
selection-color: #1B3D67;
outline: none;
}
/* ── 分组框(按需出现时也与主题一致)──────────────────────── */
QGroupBox {
border: 1px solid #D5DBE5;
border-radius: 8px;
margin-top: 10px;
padding-top: 6px;
font-weight: 600;
}
QGroupBox::title {
subcontrol-origin: margin;
left: 12px;
padding: 0 4px;
color: #3A475C;
}
/* ── 进度条(长任务反馈,遵循 Doherty 阈值)──────────────────── */
QProgressBar {
background: #E6EBF3;
border: none;
border-radius: 5px;
height: 8px;
text-align: center;
color: #5A6B85;
}
QProgressBar::chunk {
background: #2D6CB5;
border-radius: 5px;
}
/* ── ADS 停靠:标题栏做成「固定面板表头」(对齐原型)──────────────
(//) +
线 + */
ads--CDockAreaWidget {
background: #F4F6FA;
}
ads--CDockAreaTitleBar {
background: #EDF1F7;
border-bottom: 1px solid #D5DBE5;
padding: 0;
}
ads--CDockWidgetTab {
background: #EDF1F7;
border: none;
border-bottom: 2px solid transparent;
padding: 7px 12px;
min-height: 22px;
}
ads--CDockWidgetTab[activeTab="true"] {
background: #EDF1F7;
border-bottom: 2px solid #2D6CB5;
}
ads--CDockWidgetTab QLabel {
color: #5A6B85;
font-weight: 600;
}
ads--CDockWidgetTab[activeTab="true"] QLabel {
color: #1F2A3D;
font-weight: 600;
}
)QSS";
// 浅色专业调色板:让标准控件在无 QSS 覆盖处也保持一致底色/选中色。
QPalette buildPalette()
{
QPalette p;
const QColor shell("#F4F6FA");
const QColor panel("#FFFFFF");
const QColor text("#1F2A3D");
const QColor mutedText("#5A6B85");
const QColor accent("#2D6CB5");
p.setColor(QPalette::Window, shell);
p.setColor(QPalette::WindowText, text);
p.setColor(QPalette::Base, panel);
p.setColor(QPalette::AlternateBase, QColor("#F0F3F8"));
p.setColor(QPalette::Text, text);
p.setColor(QPalette::Button, QColor("#EDF1F7"));
p.setColor(QPalette::ButtonText, text);
p.setColor(QPalette::ToolTipBase, QColor("#1F2A3D"));
p.setColor(QPalette::ToolTipText, shell);
p.setColor(QPalette::Highlight, accent);
p.setColor(QPalette::HighlightedText, panel);
p.setColor(QPalette::PlaceholderText, mutedText);
p.setColor(QPalette::Link, accent);
// 关键:把 Fusion 用于绘制 3D 凹凸(斜角/凹槽/分隔条阴影)的明暗角色统一压成相近浅灰,
// 立体效果即塌成平面。ADS 分隔条用 palette(dark),这样也变成一条扁平浅灰细线(无 3D
p.setColor(QPalette::Light, QColor("#FFFFFF"));
p.setColor(QPalette::Midlight, QColor("#EEF1F5"));
p.setColor(QPalette::Mid, QColor("#E1E6EE"));
p.setColor(QPalette::Dark, QColor("#D7DEE8"));
p.setColor(QPalette::Shadow, QColor("#D7DEE8"));
// 禁用态:统一灰化,避免 Fusion 默认禁用色偏暗看不清。
p.setColor(QPalette::Disabled, QPalette::Text, QColor("#9AA6B6"));
p.setColor(QPalette::Disabled, QPalette::WindowText, QColor("#9AA6B6"));
p.setColor(QPalette::Disabled, QPalette::ButtonText, QColor("#9AA6B6"));
return p;
}
} // namespace
void applyTheme(QApplication& app)
{
// Fusion跨平台一致且对 QSS 友好Windows 原生风对部分控件会忽略样式表)。
app.setStyle(QStyleFactory::create(QStringLiteral("Fusion")));
// 基础字体:中文界面用 微软雅黑 UI 渲染最清爽;缺失时 Qt 自动回退。
// 10pt≈13px对齐主流商用客户端基准9pt 偏小显拥挤。抗锯齿优先,观感更精致。
QFont base(QStringLiteral("Microsoft YaHei UI"), 10);
base.setStyleStrategy(QFont::PreferAntialias);
app.setFont(base);
app.setPalette(buildPalette());
app.setStyleSheet(QString::fromUtf8(kStyleSheet));
}
} // namespace geopro::app

19
src/app/Theme.hpp Normal file
View File

@ -0,0 +1,19 @@
#pragma once
// 全局视觉主题浅色专业方向Fusion 风格 + 浅色 QPalette + 结构化 QSS。
// 仅外观——不改任何信号槽 / 渲染 / 数据逻辑。在 QApplication 构造后、弹登录窗前调用一次。
//
// 设计令牌(与登录窗、视图详情浮层共享,保证全项目一脉相承):
// 外壳底 #F4F6FA 面板白 #FFFFFF 抬升/表头 #EDF1F7
// 强调 #2D6CB5 悬停 #2862A6 按下 #234F87 选中行 #DCE9F8
// 文字 #1F2A3D 次要文字 #5A6B85 边框 #D5DBE5 强边框 #C2CCDA
// 危险 #C0392B
class QApplication;
namespace geopro::app {
// 应用浅色专业主题Fusion + 调色板 + 全局样式表)。幂等,启动调用一次即可。
void applyTheme(QApplication& app);
} // namespace geopro::app

238
src/app/TopBar.cpp Normal file
View File

@ -0,0 +1,238 @@
#include "TopBar.hpp"
#include <QActionGroup>
#include <QColor>
#include <QFrame>
#include <QHBoxLayout>
#include <QLabel>
#include <QMenu>
#include <QMenuBar>
#include <QSize>
#include <QStringList>
#include <QToolButton>
#include <QVBoxLayout>
#include <QWidget>
#include "Glyphs.hpp"
namespace geopro::app {
namespace {
// ── 专业图标尺寸(统一放大;菜单栏字号亦同步加大)──
constexpr int kToolIcon = 22; // 工具条右侧图标
constexpr int kWorkspaceIcon = 20; // 工作空间 / 项目图标
// 竖直分隔细线。
QFrame* makeDivider(QWidget* parent)
{
auto* line = new QFrame(parent);
line->setObjectName(QStringLiteral("topDivider"));
line->setFrameShape(QFrame::VLine);
line->setFixedWidth(1);
line->setFixedHeight(24);
return line;
}
// 右侧图标按钮(仅图标,悬停显示文本)。
QToolButton* makeIconButton(QWidget* parent, Glyph g, const QString& tip)
{
auto* btn = new QToolButton(parent);
btn->setObjectName(QStringLiteral("iconBtn"));
btn->setIcon(makeGlyph(g, QColor("#5A6B85"), kToolIcon));
btn->setIconSize(QSize(kToolIcon, kToolIcon));
btn->setToolTip(tip);
btn->setCursor(Qt::PointingHandCursor);
btn->setAutoRaise(true);
return btn;
}
// ── 四个菜单(结构对齐需求;叶子项当前为静态占位,后续接真实页面)──
QMenu* buildViewMenu(QWidget* p)
{
auto* m = new QMenu(QStringLiteral("视图"), p);
m->addAction(QStringLiteral("分析视图"));
m->addAction(QStringLiteral("大屏视图"));
return m;
}
QMenu* buildProjectMenu(QWidget* p)
{
auto* m = new QMenu(QStringLiteral("项目管理"), p);
m->addAction(QStringLiteral("数据视图"));
auto* cfg = m->addMenu(QStringLiteral("项目配置"));
cfg->addAction(QStringLiteral("基本信息"));
cfg->addAction(QStringLiteral("项目结构"));
cfg->addAction(QStringLiteral("视图配置"));
m->addAction(QStringLiteral("数据管理"));
auto* biz = m->addMenu(QStringLiteral("业务管理"));
biz->addAction(QStringLiteral("异常管理"));
biz->addAction(QStringLiteral("异常体管理"));
auto* mon = m->addMenu(QStringLiteral("在线监测"));
mon->addAction(QStringLiteral("项目设备"));
mon->addAction(QStringLiteral("在线任务管理"));
auto* doc = m->addMenu(QStringLiteral("项目资料管理"));
doc->addAction(QStringLiteral("项目资料管理"));
doc->addAction(QStringLiteral("报告列表"));
auto* tools = m->addMenu(QStringLiteral("工具组件"));
tools->addAction(QStringLiteral("装置与脚本"));
tools->addAction(QStringLiteral("色阶配置"));
tools->addAction(QStringLiteral("异常类型管理"));
tools->addAction(QStringLiteral("模型管理"));
auto* exp = m->addMenu(QStringLiteral("批量导出"));
exp->addAction(QStringLiteral("文件导出"));
exp->addAction(QStringLiteral("报告导出"));
auto* alarm = m->addMenu(QStringLiteral("告警管理"));
alarm->addAction(QStringLiteral("设备告警"));
alarm->addAction(QStringLiteral("告警查询"));
m->addAction(QStringLiteral("自动任务"));
m->addAction(QStringLiteral("模板管理"));
return m;
}
QMenu* buildToolsMenu(QWidget* p)
{
auto* m = new QMenu(QStringLiteral("业务工具"), p);
m->addAction(QStringLiteral("ERT 思维分析"));
m->addAction(QStringLiteral("电法脚本与装置"));
m->addAction(QStringLiteral("Geo 反演"));
m->addAction(QStringLiteral("三维 GPR 综合分析"));
return m;
}
QMenu* buildDeviceMenu(QWidget* p)
{
auto* m = new QMenu(QStringLiteral("设备"), p);
m->addAction(QStringLiteral("连接设备"));
m->addAction(QStringLiteral("设备管理"));
return m;
}
} // namespace
QWidget* buildMenuBar(QWidget* parent)
{
auto* mb = new QMenuBar(parent);
mb->setObjectName(QStringLiteral("appMenuBar"));
// 自带样式(覆盖全局),加大字号/内边距,专业观感。
mb->setStyleSheet(QStringLiteral(
"#appMenuBar { background:#FFFFFF; border-bottom:1px solid #EEF1F5; padding:2px 8px; }"
"#appMenuBar::item { padding:7px 14px; border-radius:6px; font-size:14px; color:#1F2A3D; }"
"#appMenuBar::item:selected { background:#EAF1FB; color:#2D6CB5; }"
"#appMenuBar::item:pressed { background:#DCE6F4; }"));
mb->addMenu(buildViewMenu(mb));
mb->addMenu(buildProjectMenu(mb));
mb->addMenu(buildToolsMenu(mb));
mb->addMenu(buildDeviceMenu(mb));
return mb;
}
QWidget* buildTopToolBar(QWidget* parent)
{
auto* bar = new QWidget(parent);
bar->setObjectName(QStringLiteral("appToolBar"));
bar->setFixedHeight(56);
bar->setStyleSheet(QStringLiteral(
"#appToolBar { background:#FFFFFF; border-bottom:1px solid #E1E6EE; }"
"#topDivider { color:#E1E6EE; }"
"#wsSwitcher { color:#1F2A3D; border:none; border-radius:8px; padding:8px 12px;"
" font-size:14px; font-weight:600; }"
"#wsSwitcher:hover { background:#EEF3FB; }"
"QToolButton#iconBtn { border:none; border-radius:8px; padding:8px; }"
"QToolButton#iconBtn:hover { background:#EEF3FB; }"
"QToolButton::menu-indicator { image:none; }"
"#avatar { background:#2D6CB5; color:#FFFFFF; border-radius:17px; font-weight:700;"
" font-size:13px; }"
"#userName { color:#1F2A3D; font-size:13px; font-weight:600; }"
"#userRole { color:#8A93A3; font-size:11px; }"));
auto* lay = new QHBoxLayout(bar);
lay->setContentsMargins(14, 0, 14, 0);
lay->setSpacing(0);
// ── 工作空间切换(最左):显示当前空间,点击下拉切换 ──
auto* wsBtn = new QToolButton(bar);
wsBtn->setObjectName(QStringLiteral("wsSwitcher"));
wsBtn->setIcon(makeGlyph(Glyph::Workspace, QColor("#2D6CB5"), kWorkspaceIcon));
wsBtn->setIconSize(QSize(kWorkspaceIcon, kWorkspaceIcon));
wsBtn->setToolButtonStyle(Qt::ToolButtonTextBesideIcon);
wsBtn->setPopupMode(QToolButton::InstantPopup);
wsBtn->setCursor(Qt::PointingHandCursor);
auto* wsMenu = new QMenu(bar);
auto* wsHeader = wsMenu->addAction(QStringLiteral("切换空间"));
wsHeader->setEnabled(false);
wsMenu->addSeparator();
auto* wsGroup = new QActionGroup(bar);
wsGroup->setExclusive(true);
const QStringList spaces = {QStringLiteral("个人工作空间"), QStringLiteral("勘探一队"),
QStringLiteral("研究院共享")};
for (const auto& s : spaces) {
auto* a = wsMenu->addAction(s);
a->setCheckable(true);
wsGroup->addAction(a);
if (s == spaces.front()) a->setChecked(true);
QObject::connect(a, &QAction::triggered, wsBtn,
[wsBtn, s]() { wsBtn->setText(s + QStringLiteral("")); });
}
wsBtn->setMenu(wsMenu);
wsBtn->setText(spaces.front() + QStringLiteral(""));
lay->addWidget(wsBtn);
lay->addSpacing(10);
lay->addWidget(makeDivider(bar));
lay->addSpacing(10);
// ── 项目选择器(与工作空间切换同款样式:无边框 + 图标 + 文本 + 下拉)──
auto* projBtn = new QToolButton(bar);
projBtn->setObjectName(QStringLiteral("wsSwitcher"));
projBtn->setIcon(makeGlyph(Glyph::Folder, QColor("#2D6CB5"), kWorkspaceIcon));
projBtn->setIconSize(QSize(kWorkspaceIcon, kWorkspaceIcon));
projBtn->setToolButtonStyle(Qt::ToolButtonTextBesideIcon);
projBtn->setPopupMode(QToolButton::InstantPopup);
projBtn->setCursor(Qt::PointingHandCursor);
auto* projMenu = new QMenu(bar);
auto* projHeader = projMenu->addAction(QStringLiteral("切换项目"));
projHeader->setEnabled(false);
projMenu->addSeparator();
auto* projCur = projMenu->addAction(QStringLiteral("青海湖北岸勘探项目"));
projCur->setCheckable(true);
projCur->setChecked(true);
projBtn->setMenu(projMenu);
projBtn->setText(QStringLiteral("青海湖北岸勘探项目 青海·海北州 ▾"));
lay->addWidget(projBtn);
lay->addStretch();
// ── 右侧:帮助 / 通知 / 设置(仅图标,悬停显示文本)──
lay->addWidget(makeIconButton(bar, Glyph::Help, QStringLiteral("帮助")));
lay->addWidget(makeIconButton(bar, Glyph::Bell, QStringLiteral("通知")));
lay->addWidget(makeIconButton(bar, Glyph::Gear, QStringLiteral("设置")));
lay->addSpacing(10);
lay->addWidget(makeDivider(bar));
lay->addSpacing(12);
// ── 用户:圆形头像 + 姓名/职务 ──
auto* avatar = new QLabel(QStringLiteral("ZL"), bar);
avatar->setObjectName(QStringLiteral("avatar"));
avatar->setFixedSize(34, 34);
avatar->setAlignment(Qt::AlignCenter);
lay->addWidget(avatar);
lay->addSpacing(8);
auto* userBox = new QWidget(bar);
auto* userLay = new QVBoxLayout(userBox);
userLay->setContentsMargins(0, 0, 0, 0);
userLay->setSpacing(0);
auto* userName = new QLabel(QStringLiteral("张磊"), userBox);
userName->setObjectName(QStringLiteral("userName"));
auto* userRole = new QLabel(QStringLiteral("高级工程师"), userBox);
userRole->setObjectName(QStringLiteral("userRole"));
userLay->addWidget(userName);
userLay->addWidget(userRole);
lay->addWidget(userBox);
return bar;
}
} // namespace geopro::app

19
src/app/TopBar.hpp Normal file
View File

@ -0,0 +1,19 @@
#pragma once
// 顶部应用区(对齐原型,静态视觉壳):
// - buildMenuBar最上方的菜单栏视图 / 项目管理 / 业务工具 / 设备,含多级子菜单)。
// - buildTopToolBar菜单栏下方的工具条工作空间切换 + 项目选择 + 帮助/通知/设置 + 用户)。
// 调用方将两者纵向堆叠后经 QMainWindow::setMenuWidget 挂到主窗口顶部。
// 菜单/按钮当前为静态占位,后续接真实页面与数据。
class QWidget;
namespace geopro::app {
// 顶部菜单栏(返回 QWidget*,内部是 QMenuBar调用方放在最上一行
QWidget* buildMenuBar(QWidget* parent = nullptr);
// 菜单栏下方的工具条(工作空间/项目/帮助/通知/设置/用户)。
QWidget* buildTopToolBar(QWidget* parent = nullptr);
} // namespace geopro::app

View File

@ -2,7 +2,6 @@
#include <QColor> #include <QColor>
#include <QFont> #include <QFont>
#include <QFormLayout>
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QLabel> #include <QLabel>
#include <QLineEdit> #include <QLineEdit>
@ -12,6 +11,7 @@
#include <QPushButton> #include <QPushButton>
#include <QRandomGenerator> #include <QRandomGenerator>
#include <QVBoxLayout> #include <QVBoxLayout>
#include <QWidget>
#include "AuthService.hpp" #include "AuthService.hpp"
@ -77,76 +77,126 @@ LoginWindow::LoginWindow(geopro::net::AuthService& auth, QWidget* parent)
: QDialog(parent), auth_(auth) : QDialog(parent), auth_(auth)
{ {
setWindowTitle(QStringLiteral("Geopro 3.0 登录")); setWindowTitle(QStringLiteral("Geopro 3.0 登录"));
setFixedSize(380, 320); setFixedSize(400, 500);
// 显式样式QLineEdit 在所有状态都白底深字+边框(否则失焦时文字色取调色板默认、与背景相近不可见)。
// 仅外观:登录窗自带样式(沿用全局主题令牌,保证一脉相承)。
// QLineEdit 在所有状态都显式白底深字 + 边框,避免失焦时取调色板默认色与背景相近不可读。
setStyleSheet(QStringLiteral( setStyleSheet(QStringLiteral(
"QDialog { background: #F5F7FD; }" "QDialog { background: #F4F6FA; }"
"QLabel { color: #2B3A55; }" "#headerBand {"
" background: qlineargradient(x1:0, y1:0, x2:1, y2:1,"
" stop:0 #2D6CB5, stop:1 #234F87); }"
"#brandTitle { color: #FFFFFF; font-size: 23px; font-weight: 700; }"
"#brandSubtitle { color: rgba(255,255,255,0.82); font-size: 12px; }"
"#fieldLabel { color: #5A6B85; font-size: 12px; font-weight: 600; }"
"QLineEdit {" "QLineEdit {"
" background: #FFFFFF; color: #1F2A3D;" " background: #FFFFFF; color: #1F2A3D;"
" border: 1px solid #C7D2E0; border-radius: 5px; padding: 6px 9px;" " border: 1px solid #C7D2E0; border-radius: 8px; padding: 0 12px;"
" selection-background-color: #3A6EA5; selection-color: #FFFFFF; }" " selection-background-color: #2D6CB5; selection-color: #FFFFFF; }"
"QLineEdit:focus { border: 1px solid #3A6EA5; }" "QLineEdit:focus { border: 1px solid #2D6CB5; }"
"QLineEdit:disabled { background: #F0F2F6; color: #8A93A3; }")); "QLineEdit:disabled { background: #F0F2F6; color: #8A93A3; }"
"#captchaImg { border: 1px solid #C7D2E0; border-radius: 8px; background: #EEF2FB; }"));
auto* root = new QVBoxLayout(this); auto* root = new QVBoxLayout(this);
root->setContentsMargins(30, 24, 30, 24); root->setContentsMargins(0, 0, 0, 0);
root->setSpacing(14); root->setSpacing(0);
auto* title = new QLabel(QStringLiteral("Geopro 3.0 登录"), this); // ── 品牌头部:强调色横幅 + 产品名 + 副标题(建立产品身份与视觉锚点)──
QFont titleFont = title->font(); auto* headerBand = new QWidget(this);
titleFont.setPointSize(15); headerBand->setObjectName(QStringLiteral("headerBand"));
titleFont.setBold(true); headerBand->setFixedHeight(108);
title->setFont(titleFont); auto* headerLayout = new QVBoxLayout(headerBand);
title->setAlignment(Qt::AlignCenter); headerLayout->setContentsMargins(32, 0, 32, 0);
title->setStyleSheet(QStringLiteral("color: #2B3A55;")); headerLayout->setSpacing(4);
root->addWidget(title); headerLayout->addStretch();
auto* brandTitle = new QLabel(QStringLiteral("Geopro 3.0"), headerBand);
brandTitle->setObjectName(QStringLiteral("brandTitle"));
auto* brandSubtitle = new QLabel(QStringLiteral("地球物理数据分析平台"), headerBand);
brandSubtitle->setObjectName(QStringLiteral("brandSubtitle"));
headerLayout->addWidget(brandTitle);
headerLayout->addWidget(brandSubtitle);
headerLayout->addStretch();
root->addWidget(headerBand);
auto* form = new QFormLayout(); // ── 表单主体:标签在上、输入在下的纵向字段(现代、留白充分)──
form->setSpacing(10); auto* body = new QWidget(this);
form->setLabelAlignment(Qt::AlignRight | Qt::AlignVCenter); auto* form = new QVBoxLayout(body);
form->setContentsMargins(32, 24, 32, 26);
form->setSpacing(6);
userEdit_ = new QLineEdit(QStringLiteral("sydk"), this); // 统一字段构造小号muted标签 + 40px 高输入框 + 字段间距。
pwdEdit_ = new QLineEdit(QStringLiteral("123456"), this); auto addField = [&](const QString& labelText, QLineEdit* edit) {
auto* lbl = new QLabel(labelText, body);
lbl->setObjectName(QStringLiteral("fieldLabel"));
edit->setMinimumHeight(40);
form->addWidget(lbl);
form->addWidget(edit);
form->addSpacing(6);
};
userEdit_ = new QLineEdit(body);
userEdit_->setPlaceholderText(QStringLiteral("请输入用户名"));
userEdit_->setClearButtonEnabled(true);
addField(QStringLiteral("用户名"), userEdit_);
pwdEdit_ = new QLineEdit(body);
pwdEdit_->setEchoMode(QLineEdit::Password); pwdEdit_->setEchoMode(QLineEdit::Password);
form->addRow(QStringLiteral("用户名"), userEdit_); pwdEdit_->setPlaceholderText(QStringLiteral("请输入密码"));
form->addRow(QStringLiteral("密码"), pwdEdit_); addField(QStringLiteral("密码"), pwdEdit_);
// 验证码:标签 + 一行(输入框占主,验证码图固定在右)。
auto* codeLbl = new QLabel(QStringLiteral("验证码"), body);
codeLbl->setObjectName(QStringLiteral("fieldLabel"));
form->addWidget(codeLbl);
// 验证码行:图 + 输入框 + 刷新
auto* captchaRow = new QHBoxLayout(); auto* captchaRow = new QHBoxLayout();
captchaLabel_ = new QLabel(this); captchaRow->setSpacing(10);
codeEdit_ = new QLineEdit(body);
codeEdit_->setMinimumHeight(40);
codeEdit_->setPlaceholderText(QStringLiteral("请输入验证码"));
captchaLabel_ = new QLabel(body);
captchaLabel_->setObjectName(QStringLiteral("captchaImg"));
captchaLabel_->setFixedSize(kCaptchaWidth, kCaptchaHeight); captchaLabel_->setFixedSize(kCaptchaWidth, kCaptchaHeight);
captchaLabel_->setFrameShape(QFrame::StyledPanel); captchaLabel_->setAlignment(Qt::AlignCenter);
codeEdit_ = new QLineEdit(this);
codeEdit_->setPlaceholderText(QStringLiteral("验证码"));
captchaRow->addWidget(captchaLabel_);
captchaRow->addWidget(codeEdit_, 1); captchaRow->addWidget(codeEdit_, 1);
form->addRow(QStringLiteral("验证码"), captchaRow); captchaRow->addWidget(captchaLabel_);
form->addLayout(captchaRow);
refreshBtn_ = new QPushButton(QStringLiteral("看不清?刷新"), this); // 刷新链接(右对齐,次要操作弱化为文字链接)。
auto* refreshRow = new QHBoxLayout();
refreshRow->addStretch();
refreshBtn_ = new QPushButton(QStringLiteral("看不清?换一张"), body);
refreshBtn_->setFlat(true); refreshBtn_->setFlat(true);
refreshBtn_->setCursor(Qt::PointingHandCursor); refreshBtn_->setCursor(Qt::PointingHandCursor);
refreshBtn_->setStyleSheet(QStringLiteral("color: #3A6EA5; border: none; text-align: right;")); refreshBtn_->setStyleSheet(QStringLiteral(
form->addRow(QString(), refreshBtn_); "QPushButton { color: #2D6CB5; border: none; background: transparent; padding: 2px 0; }"
"QPushButton:hover { color: #234F87; text-decoration: underline; }"));
refreshRow->addWidget(refreshBtn_);
form->addLayout(refreshRow);
root->addLayout(form); // 错误提示:固定占位高度,避免出现时整体布局跳动。
errorLabel_ = new QLabel(body);
errorLabel_ = new QLabel(this); errorLabel_->setStyleSheet(QStringLiteral("color: #C0392B; font-size: 12px;"));
errorLabel_->setStyleSheet(QStringLiteral("color: #C0392B;"));
errorLabel_->setWordWrap(true); errorLabel_->setWordWrap(true);
errorLabel_->setMinimumHeight(16); errorLabel_->setMinimumHeight(18);
root->addWidget(errorLabel_); form->addWidget(errorLabel_);
loginBtn_ = new QPushButton(QStringLiteral("立即登录"), this); form->addStretch();
loginBtn_->setMinimumHeight(34);
// 主操作满宽强调主按钮von Restorff唯一高强调元素引导主流程
loginBtn_ = new QPushButton(QStringLiteral("登 录"), body);
loginBtn_->setMinimumHeight(44);
loginBtn_->setCursor(Qt::PointingHandCursor); loginBtn_->setCursor(Qt::PointingHandCursor);
loginBtn_->setStyleSheet(QStringLiteral( loginBtn_->setStyleSheet(QStringLiteral(
"QPushButton { background: #3A6EA5; color: white; border: none; border-radius: 4px; " "QPushButton { background: #2D6CB5; color: #FFFFFF; border: none; border-radius: 8px; "
"font-weight: bold; }" "font-size: 15px; font-weight: 600; }"
"QPushButton:hover { background: #325E8C; }" "QPushButton:hover { background: #2862A6; }"
"QPushButton:pressed { background: #234F87; }"
"QPushButton:disabled { background: #9FB4CC; }")); "QPushButton:disabled { background: #9FB4CC; }"));
loginBtn_->setDefault(true); loginBtn_->setDefault(true);
root->addWidget(loginBtn_); form->addWidget(loginBtn_);
root->addWidget(body, 1);
connect(refreshBtn_, &QPushButton::clicked, this, &LoginWindow::refreshCaptcha); connect(refreshBtn_, &QPushButton::clicked, this, &LoginWindow::refreshCaptcha);
connect(loginBtn_, &QPushButton::clicked, this, &LoginWindow::attemptLogin); connect(loginBtn_, &QPushButton::clicked, this, &LoginWindow::attemptLogin);
@ -154,6 +204,7 @@ LoginWindow::LoginWindow(geopro::net::AuthService& auth, QWidget* parent)
connect(pwdEdit_, &QLineEdit::returnPressed, this, &LoginWindow::attemptLogin); connect(pwdEdit_, &QLineEdit::returnPressed, this, &LoginWindow::attemptLogin);
refreshCaptcha(); // 打开即拉一张验证码 refreshCaptcha(); // 打开即拉一张验证码
userEdit_->setFocus(); // 焦点落在第一个待填字段
} }
void LoginWindow::refreshCaptcha() void LoginWindow::refreshCaptcha()

View File

@ -1,11 +1,12 @@
// M1 工作台(视图重构 Task B正确产品模型。 // M1 工作台(视图重构 Task B正确产品模型。
// - 左 对象树GS→TM→DS复选框。勾选 dd_section → 在中央当前视图显示该数据集,可多条共存。 // - 左上 对象显示栏GS→TM(测线,复选框)。勾选测线 → 在中央显示其 dd_section可多条共存。
// - 左下 数据真实显示栏:单击测线 → 列其采集批次(数据集,tab 数据/文件)。单击采集批次 → 数据详情+异常+属性。
// - 中央「二维地图 / 三维视图」:两个互斥视图(内容不同,不是同一物体换相机)。 // - 中央「二维地图 / 三维视图」:两个互斥视图(内容不同,不是同一物体换相机)。
// 二维地图 = 对每个勾选数据集 buildSurveyLinelat/lon 红线俯视z=0+ applyTop2D浅底背景 // 二维地图 = 对每个勾选数据集 buildSurveyLinelat/lon 红线俯视z=0+ applyTop2D浅底背景
// 三维视图 = 对每个勾选数据集 buildCurtain竖直断面墙actor SetScale(1,1,3) 纵向夸张 + applyFree3D白底 // 三维视图 = 勾选测线的 buildCurtain竖直断面墙actor SetScale(1,1,3) 纵向夸张 + applyFree3D白底
// 切视图 / 勾选变化 → 按当前勾选集重建对应内容。 // 三维左上「视图详情」浮层(对齐原型):图层勾选 帘面 / 体素(dd_voxel,散点经 EPSG:4547 配准 IDW)
// dd_voxel 体素引擎(render::buildVoxelFromScatters)已就绪并验证,但**不**作为工具条开关 // / 切片(dd_slice,vtkImagePlaneWidget 在体素 image 上交互拖切面) / 地形(DEM 高程面 + 影像纹理)。
// (那样与二维/三维平级会令人困惑、且不在原型);待做 3D 图层控制(对齐原型「视图详情」浮层)再接 // 切视图 / 勾选变化 / 图层变化 → 重建对应内容
// - 下方「数据详情」:独立 QVTK 小视图 + 工具条「原数据 / 网格数据」切换 +「显示异常」开关(对齐原型)。 // - 下方「数据详情」:独立 QVTK 小视图 + 工具条「原数据 / 网格数据」切换 +「显示异常」开关(对齐原型)。
// 单击某 DS → 显示该数据集: // 单击某 DS → 显示该数据集:
// 网格数据 = #18 banded 等值面+等值线(两 actor SetScale(1,1.5,1) 纵向夸张)。 // 网格数据 = #18 banded 等值面+等值线(两 actor SetScale(1,1.5,1) 纵向夸张)。
@ -16,6 +17,7 @@
// 世界系:启动 loadGrid("grid1") 取一次,用其 lat/lon 中位/均值作 GeoLocalFrame全项目共享保证多视图配准 // 世界系:启动 loadGrid("grid1") 取一次,用其 lat/lon 中位/均值作 GeoLocalFrame全项目共享保证多视图配准
#include <fstream> #include <fstream>
#include <initializer_list>
#include <memory> #include <memory>
#include <sstream> #include <sstream>
#include <string> #include <string>
@ -23,12 +25,19 @@
#include <QActionGroup> #include <QActionGroup>
#include <QApplication> #include <QApplication>
#include <QCheckBox>
#include <QColor>
#include <QDialog> #include <QDialog>
#include <QFile>
#include <QFrame>
#include <QLabel> #include <QLabel>
#include <QListWidget> #include <QListWidget>
#include <QListWidgetItem> #include <QListWidgetItem>
#include <QSignalBlocker> #include <QSignalBlocker>
#include <QStringList>
#include <QTabWidget>
#include <QMainWindow> #include <QMainWindow>
#include <QStatusBar>
#include <QSurfaceFormat> #include <QSurfaceFormat>
#include <QToolBar> #include <QToolBar>
#include <QTreeWidget> #include <QTreeWidget>
@ -36,6 +45,8 @@
#include <QVBoxLayout> #include <QVBoxLayout>
#include <QWidget> #include <QWidget>
#include <DockAreaTitleBar.h>
#include <DockAreaWidget.h>
#include <DockManager.h> #include <DockManager.h>
#include <DockWidget.h> #include <DockWidget.h>
@ -45,38 +56,51 @@
#include "ApiClient.hpp" #include "ApiClient.hpp"
#include "AuthService.hpp" #include "AuthService.hpp"
#include "Glyphs.hpp"
#include "PanelHeader.hpp"
#include "Theme.hpp"
#include "TopBar.hpp"
#include "login/LoginWindow.hpp" #include "login/LoginWindow.hpp"
#include "panels/AnomalyListPanel.hpp" #include "panels/AnomalyListPanel.hpp"
#include "panels/DatasetListPanel.hpp"
#include "CameraPreset.hpp" #include "CameraPreset.hpp"
#include "ColorLutBuilder.hpp"
#include "Scene.hpp" #include "Scene.hpp"
#include "VoxelFromScatters.hpp"
#include "actors/AnomalyActor.hpp" #include "actors/AnomalyActor.hpp"
#include "actors/CurtainActor.hpp" #include "actors/CurtainActor.hpp"
#include "actors/ElectrodeActor.hpp"
#include "actors/GridContourActor.hpp" #include "actors/GridContourActor.hpp"
#include "actors/MapLineActor.hpp" #include "actors/MapLineActor.hpp"
#include "actors/ScatterActor.hpp" #include "actors/ScatterActor.hpp"
#include "actors/TerrainActor.hpp"
#include "geo/CrsTransform.hpp"
#include "geo/GeoLocalFrame.hpp" #include "geo/GeoLocalFrame.hpp"
#include <algorithm> #include <algorithm>
#include <exception>
#include <memory> #include <memory>
#include <set> #include <set>
#include <vector> #include <vector>
#include <QVTKOpenGLStereoWidget.h> #include <QVTKOpenGLStereoWidget.h>
#include <vtkGenericOpenGLRenderWindow.h> #include <vtkGenericOpenGLRenderWindow.h>
#include <vtkImagePlaneWidget.h>
#include <vtkLookupTable.h>
#include <vtkRenderWindowInteractor.h>
#include <vtkRenderer.h> #include <vtkRenderer.h>
#include <vtkSmartPointer.h> #include <vtkSmartPointer.h>
namespace { namespace {
// 角色DS 项的 dd 类型存在 UserRole+1dsId 存在 UserRole // 角色:树 TM 项存 tmId(UserRole+2);数据列表 DS 项的 dsId/ddType 由 panels/DatasetListPanel 定义。
constexpr int kRoleDsId = Qt::UserRole; constexpr int kRoleTmId = Qt::UserRole + 2;
constexpr int kRoleDdType = Qt::UserRole + 1;
// 从对象结构树构建 QTreeWidgetGS → TM → DS 三层 // 从对象结构树构建 QTreeWidgetGS → TM 两层对齐原型DS=采集批次在左下「数据列表」,不进树)
// DS 项可勾选复选框勾选驱动该测线竖直帘面在中央场景显示UserRole 存 dsId、UserRole+1 存 ddType // TM(测线) 项可勾选(复选框):勾选驱动该测线的 dd_section 在中央场景显示UserRole+2 存 tmId
// 网格剖面dd_section默认勾选启动即显示帘面 // 含 dd_section 的测线默认勾选,启动即显示
void populateTree(QTreeWidget* tree, const std::vector<geopro::data::GsNode>& gss) void populateTree(QTreeWidget* tree, const std::vector<geopro::data::GsNode>& gss)
{ {
for (const auto& gs : gss) { for (const auto& gs : gss) {
@ -85,21 +109,27 @@ void populateTree(QTreeWidget* tree, const std::vector<geopro::data::GsNode>& gs
for (const auto& tm : gs.tms) { for (const auto& tm : gs.tms) {
auto* tmItem = new QTreeWidgetItem(gsItem); auto* tmItem = new QTreeWidgetItem(gsItem);
tmItem->setText(0, QString::fromStdString(tm.name)); tmItem->setText(0, QString::fromStdString(tm.name));
for (const auto& ds : tm.dss) { tmItem->setData(0, kRoleTmId, QString::fromStdString(tm.id));
auto* dsItem = new QTreeWidgetItem(tmItem); tmItem->setFlags(tmItem->flags() | Qt::ItemIsUserCheckable);
dsItem->setText(0, QString::fromStdString(ds.name)); const bool hasSection =
dsItem->setData(0, kRoleDsId, QString::fromStdString(ds.id)); std::any_of(tm.dss.begin(), tm.dss.end(),
dsItem->setData(0, kRoleDdType, QString::fromStdString(ds.ddType)); [](const geopro::data::DsNode& d) { return d.ddType == "dd_section"; });
dsItem->setFlags(dsItem->flags() | Qt::ItemIsUserCheckable); tmItem->setCheckState(0, hasSection ? Qt::Checked : Qt::Unchecked);
// 网格剖面默认勾选 → 启动即显帘面;其余默认不勾。
dsItem->setCheckState(
0, ds.ddType == "dd_section" ? Qt::Checked : Qt::Unchecked);
}
} }
} }
tree->expandAll(); tree->expandAll();
} }
// 在结构中按 tmId 查 TM找不到返回 nullptr。
const geopro::data::TmNode* findTm(const std::vector<geopro::data::GsNode>& gss,
const std::string& tmId)
{
for (const auto& gs : gss)
for (const auto& tm : gs.tms)
if (tm.id == tmId) return &tm;
return nullptr;
}
// 读取 RSA 公钥 PEM 全文(登录时密码加密用)。读不到返回空串,登录将报错。 // 读取 RSA 公钥 PEM 全文(登录时密码加密用)。读不到返回空串,登录将报错。
std::string readPem(const std::string& path) std::string readPem(const std::string& path)
{ {
@ -132,6 +162,10 @@ constexpr float kScatterPointSize = 4.0F;
constexpr double kCurtainZScale = 3.0; constexpr double kCurtainZScale = 3.0;
constexpr double kDetailYScale = 1.5; constexpr double kDetailYScale = 1.5;
// 项目 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 工作台。 // 在给定 QMainWindow 上构建 M1 工作台。
// repo 生命周期须覆盖到事件循环结束(由调用方保证)。 // repo 生命周期须覆盖到事件循环结束(由调用方保证)。
void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& repo) void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& repo)
@ -142,6 +176,8 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
const double lat0 = median(baseGrid.lat); const double lat0 = median(baseGrid.lat);
const double lon0 = median(baseGrid.lon); const double lon0 = median(baseGrid.lon);
auto frame = std::make_shared<geopro::core::GeoLocalFrame>(lat0, lon0); auto frame = std::make_shared<geopro::core::GeoLocalFrame>(lat0, lon0);
// 测线地表高程基准(地形 z rebase 用,使地形落在测线附近而非按绝对高程浮空)。
const double refElev = baseGrid.elevation.empty() ? 0.0 : median(baseGrid.elevation);
// ── 中央 QVTK + Scene竖直帘面场景───────────────────────────────── // ── 中央 QVTK + Scene竖直帘面场景─────────────────────────────────
// Scene 非 QObject堆分配用 widget 销毁信号清理widget 随 window 销毁)。 // Scene 非 QObject堆分配用 widget 销毁信号清理widget 随 window 销毁)。
@ -158,9 +194,55 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
// 当前视图模式(全局共享,切视图/勾选时据此重建内容)。默认二维地图。 // 当前视图模式(全局共享,切视图/勾选时据此重建内容)。默认二维地图。
auto viewMode = std::make_shared<ViewMode>(ViewMode::Map2D); auto viewMode = std::make_shared<ViewMode>(ViewMode::Map2D);
// 三维图层显隐(由「视图详情」浮层控制)+ 项目 CRS→WGS84(体素配准)。
auto showCurtain = std::make_shared<bool>(true); // 帘面,默认显示
auto showVoxel = std::make_shared<bool>(false); // 体素,默认关
auto showTerrain = std::make_shared<bool>(false); // 地形(DEM+影像),默认关
auto showSlice = std::make_shared<bool>(false); // dd_slice 交互切片,默认关
// 持久的切片 widget(挂 interactor跨重建保活rebuildCentral 据条件创建/拆除)。
auto slicePlane = std::make_shared<vtkSmartPointer<vtkImagePlaneWidget>>();
std::shared_ptr<geopro::core::CrsTransform> crs; // PROJ 失败→空→体素层无效(不崩)
try {
crs = std::make_shared<geopro::core::CrsTransform>(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); auto* dockManager = new ads::CDockManager(&window);
window.setCentralWidget(dockManager); window.setCentralWidget(dockManager);
// 覆盖 ADS 自带分隔条样式:其 default.css 用 palette(dark) 把面板间分隔画成深灰粗线,
// 且设在管理器自身、选择器更具体(优先级高于全局主题)。这里在其后追加同选择器规则覆盖为极淡分隔。
dockManager->setStyleSheet(
dockManager->styleSheet() +
QStringLiteral(
"ads--CDockContainerWidget ads--CDockSplitter::handle { background: #EAEEF4; }"
"ads--CDockContainerWidget ads--CDockSplitter::handle:hover { background: #C7D2E0; }"));
// 面板包装:内容顶部加自绘表头(图标+标题+操作按钮ADS 自带标题栏随后隐藏,
// 从而让面板表头与原型完全一致。返回 [表头 + 内容] 容器供 setWidget。
auto wrapWithHeader = [](geopro::app::Glyph icon, const QString& title, QWidget* content,
const QVector<geopro::app::HeaderAction>& actions = {}) {
auto* box = new QWidget();
auto* v = new QVBoxLayout(box);
v->setContentsMargins(0, 0, 0, 0);
v->setSpacing(0);
v->addWidget(geopro::app::buildPanelHeader(icon, title, actions));
v->addWidget(content, 1);
return box;
};
// 中央容器:顶部「二维地图/三维视图」工具条 + 下方 QVTK 视图。 // 中央容器:顶部「二维地图/三维视图」工具条 + 下方 QVTK 视图。
auto* centerWidget = new QWidget(); auto* centerWidget = new QWidget();
auto* centerLayout = new QVBoxLayout(centerWidget); auto* centerLayout = new QVBoxLayout(centerWidget);
@ -181,6 +263,41 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
centerLayout->addWidget(viewToolBar); centerLayout->addWidget(viewToolBar);
centerLayout->addWidget(vtkWidget, 1); centerLayout->addWidget(vtkWidget, 1);
// ──「视图详情」图层浮层(对齐原型 3D 视图左上):浮在 QVTK 之上,控制三维图层显隐。
// 仅三维视图显示;含 帘面 / 体素 勾选(体素=两交叉测线散点配准 IDW 的派生层,正确归宿)。
auto* layerPanel = new QFrame(centerWidget);
layerPanel->setFrameShape(QFrame::StyledPanel);
layerPanel->setStyleSheet(
QStringLiteral("QFrame{background:rgba(255,255,255,0.96);border:1px solid #D5DBE5;"
"border-radius:8px;} QCheckBox{padding:2px 1px;color:#1F2A3D;}"
"QCheckBox:disabled{color:#9AA6B6;}"));
auto* layerLayout = new QVBoxLayout(layerPanel);
layerLayout->setContentsMargins(13, 10, 15, 11);
layerLayout->setSpacing(6);
auto* layerTitle = new QLabel(QStringLiteral("视图详情"));
layerTitle->setStyleSheet(QStringLiteral(
"font-weight:600;color:#2D6CB5;border:none;background:transparent;padding-bottom:3px;"));
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);
}
layerLayout->addWidget(layerTitle);
layerLayout->addWidget(chkCurtain);
layerLayout->addWidget(chkVoxel);
layerLayout->addWidget(chkSlice);
layerLayout->addWidget(chkTerrain);
layerPanel->setVisible(false); // 默认二维,不显示图层浮层
auto* vtkDock = new ads::CDockWidget(QStringLiteral("二维地图/三维视图")); auto* vtkDock = new ads::CDockWidget(QStringLiteral("二维地图/三维视图"));
vtkDock->setWidget(centerWidget); vtkDock->setWidget(centerWidget);
auto* centerDockArea = dockManager->addDockWidget(ads::CenterDockWidgetArea, vtkDock); auto* centerDockArea = dockManager->addDockWidget(ads::CenterDockWidgetArea, vtkDock);
@ -217,68 +334,132 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
auto* actShowAnomaly = detailToolBar->addAction(QStringLiteral("显示异常")); auto* actShowAnomaly = detailToolBar->addAction(QStringLiteral("显示异常"));
actShowAnomaly->setCheckable(true); actShowAnomaly->setCheckable(true);
actShowAnomaly->setChecked(true); // 默认显示异常(对齐原型 ☑显示异常) actShowAnomaly->setChecked(true); // 默认显示异常(对齐原型 ☑显示异常)
auto* actShowElectrodes = detailToolBar->addAction(QStringLiteral("显示电极"));
actShowElectrodes->setCheckable(true);
actShowElectrodes->setChecked(true); // 默认显示电极 ▼(对齐原型)
auto* actShowContour = detailToolBar->addAction(QStringLiteral("显示等值线"));
actShowContour->setCheckable(true);
actShowContour->setChecked(true); // 默认显示等值线(对齐原型)
detailLayout->addWidget(detailToolBar); detailLayout->addWidget(detailToolBar);
detailLayout->addWidget(detailWidget, 1); detailLayout->addWidget(detailWidget, 1);
auto* detailDock = new ads::CDockWidget(QStringLiteral("数据详情")); auto* detailDock = new ads::CDockWidget(QStringLiteral("数据详情"));
detailDock->setWidget(detailContainer); detailDock->setWidget(wrapWithHeader(
geopro::app::Glyph::Detail, QStringLiteral("数据详情"), detailContainer,
{{geopro::app::Glyph::Collapse, QStringLiteral("折叠")},
{geopro::app::Glyph::Download, QStringLiteral("导出")}}));
// 放在中央视图下方。 // 放在中央视图下方。
dockManager->addDockWidget(ads::BottomDockWidgetArea, detailDock, centerDockArea); dockManager->addDockWidget(ads::BottomDockWidgetArea, detailDock, centerDockArea);
// 左 dock对象树。 // 项目结构(GS→TM→DS):取一次共享,供树/中央/数据列表查 TM 的数据集。
auto* tree = new QTreeWidget(); auto structure = std::make_shared<std::vector<geopro::data::GsNode>>(repo.loadStructure());
tree->setHeaderLabel(QStringLiteral("对象"));
populateTree(tree, repo.loadStructure());
auto* leftDock = new ads::CDockWidget(QStringLiteral("对象列表"));
leftDock->setWidget(tree);
dockManager->addDockWidget(ads::LeftDockWidgetArea, leftDock);
// 右上 dock异常列表对齐原型颜色块 + 名称 + 位置/深/尺寸 + 勾选显隐,与数据详情异常联动)。 // 左上 dock对象树(GS→TM,测线复选)。表头交给自绘 PanelHeader隐藏树自带列头(避免双标题)。
auto* tree = new QTreeWidget();
tree->setHeaderHidden(true);
populateTree(tree, *structure);
// 选中行高亮不覆盖左侧缩进/折叠箭头列:给 branch 设白底(与树底一致),并用生成的箭头图片
// 保留展开/折叠图标(直接给 branch 设背景会触发 Qt 不再画默认箭头的陷阱)。
{
const QString openArrow = geopro::app::writeChevronIcon(true, QColor("#8A93A3"));
const QString closedArrow = geopro::app::writeChevronIcon(false, QColor("#8A93A3"));
tree->setStyleSheet(
QStringLiteral(
"QTreeView::branch { background: #FFFFFF; }"
"QTreeView::branch:has-children:!has-siblings:closed,"
"QTreeView::branch:closed:has-children:has-siblings { image: url(%1); }"
"QTreeView::branch:open:has-children:!has-siblings,"
"QTreeView::branch:open:has-children:has-siblings { image: url(%2); }")
.arg(closedArrow, openArrow));
}
auto* leftDock = new ads::CDockWidget(QStringLiteral("对象显示栏"));
leftDock->setWidget(wrapWithHeader(geopro::app::Glyph::Tree, QStringLiteral("对象显示栏"), tree,
{{geopro::app::Glyph::Plus, QStringLiteral("新建对象")}}));
auto* leftArea = dockManager->addDockWidget(ads::LeftDockWidgetArea, leftDock);
// 左下 dock数据真实显示栏(选中测线后列其采集批次=数据集;tab 数据/文件)。
auto* datasetTabs = new QTabWidget();
auto* datasetList = new QListWidget();
datasetList->setAlternatingRowColors(true);
datasetTabs->addTab(datasetList, QStringLiteral("数据"));
auto* fileList = new QListWidget(); // M1 文件 tab 占位
{ // 空状态引导M1 暂无文件来源,给出说明而非空白面板(识别优先于回忆)。
auto* hint = new QListWidgetItem(QStringLiteral("M1 暂无关联文件)"), fileList);
hint->setFlags(Qt::NoItemFlags);
hint->setForeground(QColor("#9AA6B6"));
hint->setTextAlignment(Qt::AlignCenter);
}
datasetTabs->addTab(fileList, QStringLiteral("文件"));
auto* datasetDock = new ads::CDockWidget(QStringLiteral("数据真实显示栏"));
auto* datasetBox = wrapWithHeader(
geopro::app::Glyph::Dataset, QStringLiteral("数据真实显示栏"), datasetTabs,
{{geopro::app::Glyph::Filter, QStringLiteral("筛选")},
{geopro::app::Glyph::Upload, QStringLiteral("上传")}});
datasetDock->setWidget(datasetBox);
// 动态标题:选中测线后改为「数据集显示栏 · ERTx」(对齐原型)。
auto* datasetTitle = datasetBox->findChild<QLabel*>(QStringLiteral("panelTitle"));
dockManager->addDockWidget(ads::BottomDockWidgetArea, datasetDock, leftArea);
// 右上 dock异常列表 / 对象属性 合并为带 Tab 表头的面板(对齐原型上半)。
auto* anomalyList = new QListWidget(); auto* anomalyList = new QListWidget();
anomalyList->setAlternatingRowColors(true); anomalyList->setAlternatingRowColors(true);
auto* anomalyDock = new ads::CDockWidget(QStringLiteral("异常列表")); auto* objAttrLabel = new QLabel(QStringLiteral("(选中对象后显示其属性)"));
anomalyDock->setWidget(anomalyList); objAttrLabel->setWordWrap(true);
auto* rightArea = dockManager->addDockWidget(ads::RightDockWidgetArea, anomalyDock); objAttrLabel->setAlignment(Qt::AlignTop | Qt::AlignLeft);
objAttrLabel->setMargin(8);
// 右下 dock属性。 auto anomalyPanel = geopro::app::buildTabbedPanel(
{{geopro::app::Glyph::Anomaly, QStringLiteral("异常列表"), anomalyList, true},
{geopro::app::Glyph::Property, QStringLiteral("对象属性"), objAttrLabel, false}},
{{geopro::app::Glyph::Filter, QStringLiteral("筛选")},
{geopro::app::Glyph::Plus, QStringLiteral("添加异常")}});
auto* anomalyBadge = anomalyPanel.badges.value(0); // 异常列表 Tab 的数量徽标
auto* rightDock = new ads::CDockWidget(QStringLiteral("异常列表/对象属性"));
rightDock->setWidget(anomalyPanel.container);
auto* rightArea = dockManager->addDockWidget(ads::RightDockWidgetArea, rightDock);
// 右下 dock属性数据集属性键值对齐原型下半独立面板
auto* propLabel = new QLabel(QStringLiteral("(单击左侧数据集查看属性与平面剖面)")); auto* propLabel = new QLabel(QStringLiteral("(单击左侧数据集查看属性与平面剖面)"));
propLabel->setWordWrap(true); propLabel->setWordWrap(true);
propLabel->setAlignment(Qt::AlignTop | Qt::AlignLeft); propLabel->setAlignment(Qt::AlignTop | Qt::AlignLeft);
propLabel->setMargin(8); propLabel->setMargin(8);
auto* propDock = new ads::CDockWidget(QStringLiteral("属性")); auto* propDock = new ads::CDockWidget(QStringLiteral("属性"));
propDock->setWidget(propLabel); propDock->setWidget(
wrapWithHeader(geopro::app::Glyph::Property, QStringLiteral("属性"), propLabel));
dockManager->addDockWidget(ads::BottomDockWidgetArea, propDock, rightArea); dockManager->addDockWidget(ads::BottomDockWidgetArea, propDock, rightArea);
// 固定全部面板(对齐原型):移除 关闭/浮动/拖动/钉住 等子窗口操作,仅保留分隔条调整边界。
// 同时隐藏 ADS 自带标题栏——表头已由各面板内的自绘 PanelHeader 承担,避免“双标题”。
// 注AlwaysShowTabs=true 时 ADS 不再自动改写标题栏可见性,手动隐藏可稳定保持。
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);
}
// ── 中央视图重建(核心)───────────────────────────────────────────── // ── 中央视图重建(核心)─────────────────────────────────────────────
// 两个互斥视图按当前勾选集整体重建scene.clear() → 对每个勾选 dd_section 加对应 actor。 // 按勾选的测线(TM)整体重建scene.clear() → 对每个勾选 TM 的 dd_section 加对应 actor。
// 二维地图 = buildSurveyLine红线俯视浅底背景+ applyTop2D。 // 二维地图 = buildSurveyLine红线俯视浅底背景+ applyTop2D。
// 三维视图 = buildCurtain断面墙SetScale(1,1,kCurtainZScale) + applyFree3D白底 // 三维视图 = buildCurtain断面墙SetScale(1,1,kCurtainZScale) + applyFree3D白底
// frame 全局共享;切视图/勾选变化都调用此函数重建当前视图。 // frame/structure 全局共享;切视图/勾选变化都调用此函数重建当前视图。
auto rebuildCentral = [scene, rendererPtr, renderWindowPtr, viewMode, &repo, frame, tree]() { auto rebuildCentral = [scene, rendererPtr, renderWindowPtr, viewMode, &repo, frame, tree,
structure, showCurtain, showVoxel, showTerrain, showSlice, slicePlane,
crs, refElev]() {
// 先拆除上次的切片 widget(独立于 scene actor须显式关闭),再按条件重建。
if (*slicePlane) { (*slicePlane)->Off(); *slicePlane = nullptr; }
scene->clear(); scene->clear();
const bool is2D = (*viewMode == ViewMode::Map2D); const bool is2D = (*viewMode == ViewMode::Map2D);
rendererPtr->SetBackground(is2D ? 0.96 : 1.0, is2D ? 0.97 : 1.0, is2D ? 0.99 : 1.0); rendererPtr->SetBackground(is2D ? 0.96 : 1.0, is2D ? 0.97 : 1.0, is2D ? 0.99 : 1.0);
// 遍历对象树收集所有勾选的 dd_section逐个加入当前视图内容。 // 渲染单个 dd_section 数据集:二维=测线线;三维=帘面(受「帘面」图层开关控制)。
QList<QTreeWidgetItem*> stack; auto renderSection = [&](const std::string& id) {
for (int i = 0; i < tree->topLevelItemCount(); ++i) stack.append(tree->topLevelItem(i));
while (!stack.isEmpty()) {
QTreeWidgetItem* cur = stack.takeFirst();
for (int i = 0; i < cur->childCount(); ++i) stack.append(cur->child(i));
const QString dsId = cur->data(0, kRoleDsId).toString();
if (dsId.isEmpty()) continue; // GS/TM 节点忽略
if (cur->checkState(0) != Qt::Checked) continue; // 仅显示勾选的
const QString ddType = cur->data(0, kRoleDdType).toString();
if (ddType != "dd_section") continue; // 当前仅支持剖面网格
const std::string id = dsId.toStdString();
const auto g = repo.loadGrid(id); const auto g = repo.loadGrid(id);
if (is2D) { if (is2D) {
auto line = geopro::render::buildSurveyLine(g, *frame); auto line = geopro::render::buildSurveyLine(g, *frame);
if (line) scene->addActor(line); if (line) scene->addActor(line);
} else { } else if (*showCurtain) {
const auto cs = repo.loadColorScale(id); const auto cs = repo.loadColorScale(id);
auto curtain = geopro::render::buildCurtain(g, cs, *frame); auto curtain = geopro::render::buildCurtain(g, cs, *frame);
if (curtain) { if (curtain) {
@ -286,6 +467,68 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
scene->addActor(curtain); scene->addActor(curtain);
} }
} }
};
// 遍历对象树收集所有勾选的测线(TM),渲染其 dd_section 数据集(可多条共存)。
QList<QTreeWidgetItem*> stack;
for (int i = 0; i < tree->topLevelItemCount(); ++i) stack.append(tree->topLevelItem(i));
while (!stack.isEmpty()) {
QTreeWidgetItem* cur = stack.takeFirst();
for (int i = 0; i < cur->childCount(); ++i) stack.append(cur->child(i));
const QString tmId = cur->data(0, kRoleTmId).toString();
if (tmId.isEmpty()) continue; // GS 节点忽略
if (cur->checkState(0) != Qt::Checked) continue; // 仅显示勾选的测线
const auto* tm = findTm(*structure, tmId.toStdString());
if (!tm) continue;
for (const auto& ds : tm->dss)
if (ds.ddType == "dd_section") renderSection(ds.id);
}
// 三维「体素 / 切片」图层:两交叉测线散点经 CRS 配准 IDW 成体素。
// 体素=GPU 体绘制(与帘面同纵向夸张);切片=vtkImagePlaneWidget 在体素 image 上交互拖切面。
// 注:切片 widget 作用于 image 原始米坐标(无 actor 夸张),与夸张后的体绘制存在纵向比例差
// (spec M-3 Z 基准统一待办);切片本身演示 dd_slice 交互正确。
if (!is2D && (*showVoxel || *showSlice) && crs) {
const auto profs = repo.loadVoxelScatters();
const auto vcs = repo.loadScatterColorScale("grid1");
// 纵向夸张烤进 image(zDisplayScale=kCurtainZScale),使体绘制/切片/帘面纵向一致。
auto vr = geopro::render::buildVoxelFromScatters(profs, vcs, *crs, *frame, 1.0, 0.5, 2.0,
4.0, kCurtainZScale);
if (vr.valid()) {
if (*showVoxel) {
rendererPtr->AddVolume(vr.volume); // 夸张已烤进 image无需 actor SetScale
}
vtkRenderWindowInteractor* interactor = renderWindowPtr->GetInteractor();
if (*showSlice && interactor) {
const std::vector<double> stops = vcs.stopValues();
const double vmn = stops.size() >= 2 ? stops.front() : 0.0;
const double vmx = stops.size() >= 2 ? stops.back() : 1.0;
auto lut = geopro::render::buildLut(vcs, vmn, vmx, 256);
int dims[3] = {1, 1, 1};
vr.image->GetDimensions(dims);
auto plane = vtkSmartPointer<vtkImagePlaneWidget>::New();
plane->SetInteractor(interactor);
plane->SetInputData(vr.image);
plane->SetPlaneOrientationToXAxes();
plane->SetSliceIndex(dims[0] / 2);
plane->SetLookupTable(lut);
plane->DisplayTextOn();
// 左键拖动=移动切面(默认左键是取值光标十字,不直观);中键仍可取值。
plane->SetLeftButtonAction(vtkImagePlaneWidget::VTK_SLICE_MOTION_ACTION);
plane->SetMiddleButtonAction(vtkImagePlaneWidget::VTK_CURSOR_ACTION);
plane->On();
*slicePlane = plane;
}
}
}
// 三维「地形」图层GDAL 读 DEM(高程)+影像(EPSG:3857)重投影到世界系warp 面 + 纹理。
if (!is2D && *showTerrain && crs) {
// zOffset=refElev 使地形落在测线地表高程附近(不按绝对高程浮空);zScale=1 真实起伏。
auto terr = geopro::render::buildTerrain(repo.demPath(), repo.imagePath(), *frame,
refElev, 1.0);
if (terr) scene->addActor(terr);
} }
if (is2D) if (is2D)
@ -296,24 +539,40 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
renderWindowPtr->Render(); renderWindowPtr->Render();
}; };
// 勾选/取消某 dd_section → 重建当前视图内容(勾的才显示;可多条共存)。 // 勾选/取消某测线(TM) → 重建当前视图内容(勾的才显示;可多条共存)。
QObject::connect(tree, &QTreeWidget::itemChanged, tree, QObject::connect(tree, &QTreeWidget::itemChanged, tree,
[rebuildCentral](QTreeWidgetItem* item, int) { [rebuildCentral](QTreeWidgetItem* item, int) {
if (item->data(0, kRoleDsId).toString().isEmpty()) return; // GS/TM 忽略 if (item->data(0, kRoleTmId).toString().isEmpty()) return; // GS 忽略
rebuildCentral(); rebuildCentral();
}); });
// 单击测线(TM) → 左下数据列表填充其采集批次(数据集) + 动态标题 + 数据 Tab 数量。
QObject::connect(tree, &QTreeWidget::itemClicked, tree,
[structure, datasetList, datasetTitle, datasetTabs](QTreeWidgetItem* item, int) {
const QString tmId = item->data(0, kRoleTmId).toString();
if (tmId.isEmpty()) return; // GS 节点无数据集
const auto* tm = findTm(*structure, tmId.toStdString());
if (!tm) return;
geopro::app::populateDatasetList(datasetList, tm->dss);
if (datasetTitle)
datasetTitle->setText(QStringLiteral("数据集显示栏 · %1").arg(item->text(0)));
datasetTabs->setTabText(
0, QStringLiteral("数据 (%1)").arg(static_cast<int>(tm->dss.size())));
});
// ── 数据详情共享状态 + 重建 ────────────────────────────────────────── // ── 数据详情共享状态 + 重建 ──────────────────────────────────────────
// 当前选中数据集 id空=未选)与详情显示模式(反演剖面/原数据);切模式或换选中都重建。 // 当前选中数据集 id空=未选)与详情显示模式(反演剖面/原数据);切模式或换选中都重建。
auto currentDsId = std::make_shared<QString>(); auto currentDsId = std::make_shared<QString>();
auto detailMode = std::make_shared<DetailMode>(DetailMode::Section18); auto detailMode = std::make_shared<DetailMode>(DetailMode::Section18);
auto showAnomalies = std::make_shared<bool>(true); // 默认显示异常(对齐原型) auto showAnomalies = std::make_shared<bool>(true); // 默认显示异常(对齐原型)
auto showElectrodes = std::make_shared<bool>(true); // 默认显示电极 ▼
auto showContour = std::make_shared<bool>(true); // 默认显示等值线
auto hiddenAnoms = std::make_shared<std::set<int>>(); // 异常列表中被取消勾选(隐藏)的异常下标 auto hiddenAnoms = std::make_shared<std::set<int>>(); // 异常列表中被取消勾选(隐藏)的异常下标
// 按当前选中 DS + 详情模式重建下方数据详情(平躺俯视正交,纵向夸张填面板)。 // 按当前选中 DS + 详情模式重建下方数据详情(平躺俯视正交,纵向夸张填面板)。
// 勾选「显示异常」时在 #18/#17 上叠加异常 dashed 折线(同纵向夸张对齐)。 // 勾选「显示异常/电极/等值线」控制对应叠加(同纵向夸张对齐)。
auto rebuildDetail = [&repo, detailRendererPtr, detailRenderWindowPtr, currentDsId, detailMode, auto rebuildDetail = [&repo, detailRendererPtr, detailRenderWindowPtr, currentDsId, detailMode,
showAnomalies, hiddenAnoms]() { showAnomalies, showElectrodes, showContour, hiddenAnoms]() {
detailRendererPtr->RemoveAllViewProps(); detailRendererPtr->RemoveAllViewProps();
if (currentDsId->isEmpty()) { // 未选数据集:清空即可 if (currentDsId->isEmpty()) { // 未选数据集:清空即可
detailRenderWindowPtr->Render(); detailRenderWindowPtr->Render();
@ -321,7 +580,7 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
} }
const std::string id = currentDsId->toStdString(); const std::string id = currentDsId->toStdString();
if (*detailMode == DetailMode::Section18) { if (*detailMode == DetailMode::Section18) {
// 网格数据:#18 banded 等值面 + 等值线,两 actor 纵向夸张 1.5x(沿 y // 网格数据:#18 banded 等值面+「显示等值线」时叠黑色等值线),纵向夸张 1.5x(沿 y
const auto g = repo.loadGrid(id); const auto g = repo.loadGrid(id);
const auto cs = repo.loadColorScale(id); const auto cs = repo.loadColorScale(id);
const auto actors = geopro::render::buildGridContour(g, cs); const auto actors = geopro::render::buildGridContour(g, cs);
@ -329,10 +588,18 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
actors.bands->SetScale(1.0, kDetailYScale, 1.0); actors.bands->SetScale(1.0, kDetailYScale, 1.0);
detailRendererPtr->AddViewProp(actors.bands); detailRendererPtr->AddViewProp(actors.bands);
} }
if (actors.edges) { if (actors.edges && *showContour) {
actors.edges->SetScale(1.0, kDetailYScale, 1.0); actors.edges->SetScale(1.0, kDetailYScale, 1.0);
detailRendererPtr->AddViewProp(actors.edges); detailRendererPtr->AddViewProp(actors.edges);
} }
// 顶部电极标记 ▼(仅网格数据;同纵向夸张对齐)。
if (*showElectrodes) {
auto elec = geopro::render::buildElectrodes(g);
if (elec) {
elec->SetScale(1.0, kDetailYScale, 1.0);
detailRendererPtr->AddViewProp(elec);
}
}
} else { } else {
// 原数据:#17 彩色散点,用散点自带色阶;纵向夸张同剖面以对齐观感。 // 原数据:#17 彩色散点,用散点自带色阶;纵向夸张同剖面以对齐观感。
const auto s = repo.loadScatter(id); const auto s = repo.loadScatter(id);
@ -359,17 +626,10 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
detailRenderWindowPtr->Render(); detailRenderWindowPtr->Render();
}; };
// ── 单击 DS → 记选中 + 重建数据详情 + 右侧属性(与勾选区分;不改帘面可见性)── // 加载某数据集到「数据详情 + 异常列表 + 属性」(数据列表单击与启动默认共用)。
QObject::connect( auto loadDataset = [&repo, propLabel, currentDsId, rebuildDetail, anomalyList, hiddenAnoms,
tree, &QTreeWidget::itemClicked, tree, anomalyBadge](const QString& dsId, const QString& name) {
[&repo, propLabel, currentDsId, rebuildDetail, anomalyList, hiddenAnoms]( if (dsId.isEmpty()) return;
QTreeWidgetItem* item, int) {
const QString dsId = item->data(0, kRoleDsId).toString();
if (dsId.isEmpty()) return; // GS/TM 节点无详情
const QString ddType = item->data(0, kRoleDdType).toString();
if (ddType != "dd_section") return;
const QString name = item->text(0);
*currentDsId = dsId; *currentDsId = dsId;
// 右上异常列表:按该数据集异常重填(默认全显);先清隐藏集再填,避免重建时阻塞信号回灌。 // 右上异常列表:按该数据集异常重填(默认全显);先清隐藏集再填,避免重建时阻塞信号回灌。
@ -379,6 +639,11 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
const QSignalBlocker block(anomalyList); // 重填触发 itemChanged先屏蔽 const QSignalBlocker block(anomalyList); // 重填触发 itemChanged先屏蔽
geopro::app::populateAnomalyList(anomalyList, anomalies); geopro::app::populateAnomalyList(anomalyList, anomalies);
} }
// 异常列表 Tab 数量徽标。
if (anomalyBadge) {
anomalyBadge->setText(QString::number(anomalies.size()));
anomalyBadge->setVisible(!anomalies.empty());
}
rebuildDetail(); rebuildDetail();
@ -389,6 +654,17 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
"vmin / vmax: %4 / %5\n异常: %6 个") "vmin / vmax: %4 / %5\n异常: %6 个")
.arg(name).arg(g.nx()).arg(g.ny()).arg(g.vmin).arg(g.vmax) .arg(name).arg(g.nx()).arg(g.ny()).arg(g.vmin).arg(g.vmax)
.arg(anomalies.size())); .arg(anomalies.size()));
};
// ── 单击左下数据列表的采集批次(DS) → 加载到数据详情/异常/属性 ──
QObject::connect(datasetList, &QListWidget::itemClicked, datasetList,
[loadDataset](QListWidgetItem* item) {
const QString dsId = item->data(geopro::app::kDsIdRole).toString();
const QString ddType = item->data(geopro::app::kDsDdTypeRole).toString();
if (ddType != "dd_section") return; // 仅剖面网格有详情图
const QString name =
item->data(Qt::DisplayRole).toString().section('\n', 0, 0);
loadDataset(dsId, name);
}); });
// ── 异常列表勾选(显隐) → 更新隐藏集 → 重建数据详情 ── // ── 异常列表勾选(显隐) → 更新隐藏集 → 重建数据详情 ──
@ -414,36 +690,152 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
rebuildDetail(); rebuildDetail();
}); });
// ──「显示异常」开关:切换异常叠加 → 重建数据详情 ── // ──「显示异常 / 显示电极 / 显示等值线」开关:切换叠加 → 重建数据详情 ──
QObject::connect(actShowAnomaly, &QAction::toggled, detailWidget, QObject::connect(actShowAnomaly, &QAction::toggled, detailWidget,
[showAnomalies, rebuildDetail](bool on) { [showAnomalies, rebuildDetail](bool on) {
*showAnomalies = on; *showAnomalies = on;
rebuildDetail(); rebuildDetail();
}); });
QObject::connect(actShowElectrodes, &QAction::toggled, detailWidget,
[showElectrodes, rebuildDetail](bool on) {
*showElectrodes = on;
rebuildDetail();
});
QObject::connect(actShowContour, &QAction::toggled, detailWidget,
[showContour, rebuildDetail](bool on) {
*showContour = on;
rebuildDetail();
});
// ── 工具条「二维地图/三维视图」:切换互斥视图 → 按当前勾选集重建对应内容 ── // 「视图详情」浮层显隐:仅三维显示,置于 QVTK 左上(工具条下方)并置顶。
QObject::connect(act2D, &QAction::triggered, vtkWidget, [viewMode, rebuildCentral]() { auto showLayerPanel = [layerPanel, viewToolBar](bool show3D) {
if (show3D) {
layerPanel->move(14, viewToolBar->height() + 12);
layerPanel->adjustSize();
layerPanel->setVisible(true);
layerPanel->raise();
} else {
layerPanel->setVisible(false);
}
};
// ── 工具条「二维地图/三维视图」:切换互斥视图 → 重建内容 + 图层浮层显隐 ──
QObject::connect(act2D, &QAction::triggered, vtkWidget,
[viewMode, rebuildCentral, showLayerPanel]() {
*viewMode = ViewMode::Map2D; *viewMode = ViewMode::Map2D;
showLayerPanel(false);
rebuildCentral(); rebuildCentral();
}); });
QObject::connect(act3D, &QAction::triggered, vtkWidget, [viewMode, rebuildCentral]() { QObject::connect(act3D, &QAction::triggered, vtkWidget,
[viewMode, rebuildCentral, showLayerPanel]() {
*viewMode = ViewMode::View3D; *viewMode = ViewMode::View3D;
showLayerPanel(true);
rebuildCentral(); rebuildCentral();
}); });
// ── 启动默认dd_section 已勾选,但 itemChanged 在 connect 之前触发故未渲染。 // ──「视图详情」图层勾选 → 更新图层显隐 → 重建中央 ──
// 这里 connect 之后主动按默认视图(二维地图)重建一次中央内容。 QObject::connect(chkCurtain, &QCheckBox::toggled, vtkWidget,
[showCurtain, rebuildCentral](bool on) {
*showCurtain = on;
rebuildCentral(); 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();
});
// ── 启动默认:测线已勾选,但 itemChanged 在 connect 之前触发故未渲染;这里重建一次中央内容。
rebuildCentral();
// 启动默认:选第一个含 dd_section 的测线 → 填充数据列表 + 加载其首个 dd_section 详情(对齐原型)。
for (const auto& gs : *structure) {
const geopro::data::TmNode* picked = nullptr;
for (const auto& tm : gs.tms) {
const bool hasSection =
std::any_of(tm.dss.begin(), tm.dss.end(),
[](const geopro::data::DsNode& d) { return d.ddType == "dd_section"; });
if (hasSection) { picked = &tm; break; }
}
if (!picked) continue;
geopro::app::populateDatasetList(datasetList, picked->dss);
if (datasetTitle)
datasetTitle->setText(
QStringLiteral("数据集显示栏 · %1").arg(QString::fromStdString(picked->name)));
datasetTabs->setTabText(
0, QStringLiteral("数据 (%1)").arg(static_cast<int>(picked->dss.size())));
for (const auto& ds : picked->dss)
if (ds.ddType == "dd_section") {
loadDataset(QString::fromStdString(ds.id), QString::fromStdString(ds.name));
break;
}
break;
}
// 顶部应用区(静态视觉壳,对齐原型):上=菜单栏(视图/项目管理/业务工具/设备),
// 下=工具条(工作空间切换 + 项目 + 帮助/通知/设置 + 用户)。纵向堆叠后挂到主窗口顶部。
{
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));
topLayout->addWidget(geopro::app::buildTopToolBar(topChrome));
window.setMenuWidget(topChrome);
}
// 底部状态栏常驻显示坐标系与世界系原点wayfinding用户随时知道当前空间基准
window.statusBar()->showMessage(
QStringLiteral("就绪 | 坐标系 %1 | 世界系原点 %2, %3")
.arg(QString::fromUtf8(kProjectCrs))
.arg(lat0, 0, 'f', 5)
.arg(lon0, 0, 'f', 5));
} }
} // namespace } // namespace
int main(int argc, char* argv[]) int main(int argc, char* argv[])
{ {
// 高 DPI 缩放采用直通策略:在 125%/150% 等分数缩放下字体/图标按真实比例渲染,更清晰。
// 必须在 QApplication 构造前设置。
QApplication::setHighDpiScaleFactorRoundingPolicy(
Qt::HighDpiScaleFactorRoundingPolicy::PassThrough);
// QVTK 默认 surface format 必须在 QApplication 之前设置(全局一次,两个 QVTK widget 共用)。 // QVTK 默认 surface format 必须在 QApplication 之前设置(全局一次,两个 QVTK widget 共用)。
QSurfaceFormat::setDefaultFormat(QVTKOpenGLStereoWidget::defaultFormat()); QSurfaceFormat::setDefaultFormat(QVTKOpenGLStereoWidget::defaultFormat());
QApplication app(argc, argv); QApplication app(argc, argv);
// 浅色专业主题Fusion + 调色板 + 全局样式表):仅外观,登录窗与工作台共用。
geopro::app::applyTheme(app);
// 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 + 登录编排 AuthServiceRSA 公钥从 resources 读取)。 // 网络层:共享会话 ApiClient + 登录编排 AuthServiceRSA 公钥从 resources 读取)。
geopro::net::ApiClient api(QStringLiteral("http://tenant.geomative.cn/pop-api")); 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"); const std::string pem = readPem("D:/Git/lanbingtech/geopro/resources/rsa_public_key.pem");
@ -462,6 +854,7 @@ int main(int argc, char* argv[])
QMainWindow window; QMainWindow window;
window.setWindowTitle(QStringLiteral("Geopro 3.0 — 项目分析视图 (M1)")); window.setWindowTitle(QStringLiteral("Geopro 3.0 — 项目分析视图 (M1)"));
window.resize(1280, 800); window.resize(1280, 800);
window.setMinimumSize(1024, 680); // 防止停靠面板被压到不可用尺寸
buildWorkbench(window, repo); buildWorkbench(window, repo);
window.show(); window.show();

View File

@ -0,0 +1,37 @@
#include "panels/DatasetListPanel.hpp"
#include <QListWidget>
#include <QListWidgetItem>
#include <QString>
namespace geopro::app {
namespace {
// dd 类型 → 中文标注。
QString ddTypeLabel(const std::string& ddType)
{
if (ddType == "dd_section") return QStringLiteral("剖面网格");
if (ddType == "dd_voxel") return QStringLiteral("体素");
return QString::fromStdString(ddType);
}
} // namespace
void populateDatasetList(QListWidget* list, const std::vector<geopro::data::DsNode>& dss)
{
if (!list) return;
list->clear();
for (const auto& ds : dss) {
const QString name = QString::fromStdString(ds.name);
const QString label = ddTypeLabel(ds.ddType);
QString text = name;
if (!label.isEmpty()) text += QStringLiteral("\n%1").arg(label);
auto* item = new QListWidgetItem(text, list);
item->setData(kDsIdRole, QString::fromStdString(ds.id));
item->setData(kDsDdTypeRole, QString::fromStdString(ds.ddType));
}
}
} // namespace geopro::app

View File

@ -0,0 +1,19 @@
#pragma once
#include <vector>
#include "repo/RepoTypes.hpp"
class QListWidget;
namespace geopro::app {
// 数据列表条目角色(与 main.cpp 树一致Qt::UserRole=dsId、+1=ddType
constexpr int kDsIdRole = 0x0100; // Qt::UserRole
constexpr int kDsDdTypeRole = 0x0101; // Qt::UserRole + 1
// 用某测线(TM)的数据集(采集批次)填充 QListWidget对齐原型左下「数据真实显示栏」
// 每条目 = 名称 +ddType 标注UserRole 存 dsId、+1 存 ddType供单击驱动数据详情
// 清空旧条目后重填。
void populateDatasetList(QListWidget* list, const std::vector<geopro::data::DsNode>& dss);
} // namespace geopro::app

View File

@ -25,6 +25,8 @@ constexpr const char* kScatterColorScaleFile = u8"剖面原数据的色阶数据
constexpr const char* kVoxelScatterFile1 = u8"剖面原数据1.txt"; constexpr const char* kVoxelScatterFile1 = u8"剖面原数据1.txt";
constexpr const char* kVoxelScatterFile2 = u8"剖面原数据2.txt"; constexpr const char* kVoxelScatterFile2 = u8"剖面原数据2.txt";
constexpr const char* kAnomalyFile = u8"剖面网格数据1——对应的异常圈定数据.txt"; constexpr const char* kAnomalyFile = u8"剖面网格数据1——对应的异常圈定数据.txt";
constexpr const char* kDemFile = u8"dem.tif";
constexpr const char* kImageFile = u8"image.tif";
// 校验 dsId未知则抛错输入边界验证 // 校验 dsId未知则抛错输入边界验证
void requireKnownDs(const std::string& dsId) { void requireKnownDs(const std::string& dsId) {
@ -99,6 +101,9 @@ ColorScale LocalSampleRepository::loadScatterColorScale(const std::string& dsId)
return parseColorScale(readFile(kScatterColorScaleFile)); return parseColorScale(readFile(kScatterColorScaleFile));
} }
std::string LocalSampleRepository::demPath() const { return dirUtf8_ + kDemFile; }
std::string LocalSampleRepository::imagePath() const { return dirUtf8_ + kImageFile; }
std::vector<ScatterField> LocalSampleRepository::loadVoxelScatters() { std::vector<ScatterField> LocalSampleRepository::loadVoxelScatters() {
std::vector<ScatterField> out; std::vector<ScatterField> out;
out.push_back(parseScatter(readFile(kVoxelScatterFile1))); out.push_back(parseScatter(readFile(kVoxelScatterFile1)));

View File

@ -21,6 +21,10 @@ public:
// 具体类专有方法(不进 IDatasetRepository 接口)散点点为不透明alpha 量纲差异无影响。 // 具体类专有方法(不进 IDatasetRepository 接口)散点点为不透明alpha 量纲差异无影响。
geopro::core::ColorScale loadScatterColorScale(const std::string& dsId); geopro::core::ColorScale loadScatterColorScale(const std::string& dsId);
// DEM/影像 GeoTIFF 的绝对路径(供 render::buildTerrain 经 GDAL 读)。
std::string demPath() const;
std::string imagePath() const;
// dd_voxel 输入读两条交叉剖面散点剖面原数据1.txt + 剖面原数据2.txt // dd_voxel 输入读两条交叉剖面散点剖面原数据1.txt + 剖面原数据2.txt
// 返回两者,供体素插值合并。具体类专有方法(不进 IDatasetRepository 接口)。 // 返回两者,供体素插值合并。具体类专有方法(不进 IDatasetRepository 接口)。
std::vector<geopro::core::ScatterField> loadVoxelScatters(); std::vector<geopro::core::ScatterField> loadVoxelScatters();

View File

@ -1,8 +1,9 @@
find_package(VTK REQUIRED COMPONENTS CommonCore CommonDataModel FiltersGeometry FiltersModeling RenderingCore RenderingOpenGL2 RenderingVolumeOpenGL2 InteractionStyle InteractionWidgets) find_package(VTK REQUIRED COMPONENTS CommonCore CommonDataModel FiltersGeometry FiltersModeling RenderingCore RenderingOpenGL2 RenderingVolumeOpenGL2 InteractionStyle InteractionWidgets IOImage)
find_package(GDAL CONFIG REQUIRED)
add_library(geopro_render STATIC add_library(geopro_render STATIC
Scene.cpp ColorLutBuilder.cpp CameraPreset.cpp VoxelFromScatters.cpp actors/GridContourActor.cpp actors/VoxelActor.cpp actors/CurtainActor.cpp actors/MapLineActor.cpp actors/ScatterActor.cpp actors/AnomalyActor.cpp) Scene.cpp ColorLutBuilder.cpp CameraPreset.cpp VoxelFromScatters.cpp actors/GridContourActor.cpp actors/VoxelActor.cpp actors/CurtainActor.cpp actors/MapLineActor.cpp actors/ScatterActor.cpp actors/AnomalyActor.cpp actors/ElectrodeActor.cpp actors/TerrainActor.cpp)
target_include_directories(geopro_render PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) target_include_directories(geopro_render PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
target_link_libraries(geopro_render PUBLIC geopro_core ${VTK_LIBRARIES}) target_link_libraries(geopro_render PUBLIC geopro_core ${VTK_LIBRARIES} GDAL::GDAL)
target_compile_features(geopro_render PUBLIC cxx_std_17) target_compile_features(geopro_render PUBLIC cxx_std_17)
set_target_properties(geopro_render PROPERTIES AUTOMOC OFF AUTOUIC OFF AUTORCC OFF) set_target_properties(geopro_render PROPERTIES AUTOMOC OFF AUTOUIC OFF AUTORCC OFF)
vtk_module_autoinit(TARGETS geopro_render MODULES ${VTK_LIBRARIES}) vtk_module_autoinit(TARGETS geopro_render MODULES ${VTK_LIBRARIES})

View File

@ -33,7 +33,8 @@ VoxelResult buildVoxelFromScatters(const std::vector<geopro::core::ScatterField>
double cellXY, double cellXY,
double cellZ, double cellZ,
double power, double power,
double maxDist) double maxDist,
double zDisplayScale)
{ {
// 1) 配准所有点到世界局部米 + 深度,组装 IDW 输入点集。 // 1) 配准所有点到世界局部米 + 深度,组装 IDW 输入点集。
geopro::core::PointSet pts; geopro::core::PointSet pts;
@ -89,10 +90,11 @@ VoxelResult buildVoxelFromScatters(const std::vector<geopro::core::ScatterField>
if (!(vmin < vmax)) { vmin = 0.0; vmax = 1.0; } if (!(vmin < vmax)) { vmin = 0.0; vmax = 1.0; }
} }
// 5) 体绘制 + 暴露 image(供切片)。 // 5) 体绘制 + 暴露 image(供切片)。纵向夸张烤进 image 的 z 原点/间距(IDW 已用真实 cellZ 采样)
// 使体绘制/切片/帘面纵向一致(切片穿过体素而非在旁)。
VoxelResult out; VoxelResult out;
out.volume = buildVoxel(vol, cs, spec.ox, spec.oy, spec.oz, spec.dx, spec.dy, spec.dz, vmin, out.volume = buildVoxel(vol, cs, spec.ox, spec.oy, spec.oz * zDisplayScale, spec.dx, spec.dy,
vmax, out.image); spec.dz * zDisplayScale, vmin, vmax, out.image);
return out; return out;
} }

View File

@ -24,6 +24,8 @@ struct VoxelResult {
// 垂向 z = -ylist(深度向下,与帘面 z 取负一致)。 // 垂向 z = -ylist(深度向下,与帘面 z 取负一致)。
// IDWmaxDist 裁剪约束插值域(两交叉测线→十字片,设计 §10)NaN 留空→体绘制透明。 // IDWmaxDist 裁剪约束插值域(两交叉测线→十字片,设计 §10)NaN 留空→体绘制透明。
// crs 须为「项目 CRS(如 EPSG:4547) → EPSG:4326」frame 与帘面/地图共用以保证空间配准。 // crs 须为「项目 CRS(如 EPSG:4547) → EPSG:4326」frame 与帘面/地图共用以保证空间配准。
// zDisplayScale把纵向夸张"烤进"输出 image 的 z 间距与原点(IDW 采样仍用真实 cellZ)
// 使体绘制 + 切片(vtkImagePlaneWidget 作用于此 image) + 帘面(actor SetScale 同倍)三者纵向一致。
// 输入不足(无点)返回 valid()==false 的空结果。 // 输入不足(无点)返回 valid()==false 的空结果。
VoxelResult buildVoxelFromScatters(const std::vector<geopro::core::ScatterField>& profiles, VoxelResult buildVoxelFromScatters(const std::vector<geopro::core::ScatterField>& profiles,
const geopro::core::ColorScale& cs, const geopro::core::ColorScale& cs,
@ -32,6 +34,7 @@ VoxelResult buildVoxelFromScatters(const std::vector<geopro::core::ScatterField>
double cellXY = 1.0, double cellXY = 1.0,
double cellZ = 0.5, double cellZ = 0.5,
double power = 2.0, double power = 2.0,
double maxDist = 4.0); double maxDist = 4.0,
double zDisplayScale = 1.0);
} // namespace geopro::render } // namespace geopro::render

View File

@ -0,0 +1,57 @@
#include "actors/ElectrodeActor.hpp"
#include <algorithm>
#include <cstddef>
#include <vtkCellArray.h>
#include <vtkNew.h>
#include <vtkPoints.h>
#include <vtkPolyData.h>
#include <vtkPolyDataMapper.h>
#include <vtkProperty.h>
#include <vtkTriangle.h>
namespace geopro::render {
vtkSmartPointer<vtkActor> buildElectrodes(const geopro::core::Grid& g, double markerW,
double markerH)
{
const int nx = g.nx();
if (nx < 1 || g.x.size() < static_cast<size_t>(nx) || g.y.empty()) {
return vtkSmartPointer<vtkActor>::New();
}
// 剖面顶边(最浅) y_screen = -min(深度)#18 用 -y 使深部在下,故最浅处 y_screen 最大。
const double minDepth = *std::min_element(g.y.begin(), g.y.end());
const double yTop = -minDepth;
vtkNew<vtkPoints> points;
vtkNew<vtkCellArray> tris;
// 每个电极一个朝下三角:顶点(apex)触剖面顶边 (x, yTop),底边在其上方 (x±w, yTop+h)。
for (int i = 0; i < nx; ++i) {
const double x = g.x[i];
const vtkIdType a = points->InsertNextPoint(x, yTop, 0.0); // apex(下)
const vtkIdType l = points->InsertNextPoint(x - markerW, yTop + markerH, 0.0);
const vtkIdType r = points->InsertNextPoint(x + markerW, yTop + markerH, 0.0);
vtkNew<vtkTriangle> t;
t->GetPointIds()->SetId(0, a);
t->GetPointIds()->SetId(1, l);
t->GetPointIds()->SetId(2, r);
tris->InsertNextCell(t);
}
vtkNew<vtkPolyData> poly;
poly->SetPoints(points);
poly->SetPolys(tris);
vtkNew<vtkPolyDataMapper> mapper;
mapper->SetInputData(poly);
mapper->ScalarVisibilityOff();
auto actor = vtkSmartPointer<vtkActor>::New();
actor->SetMapper(mapper);
actor->GetProperty()->SetColor(0.15, 0.15, 0.15); // 深灰 ▼
return actor;
}
} // namespace geopro::render

View File

@ -0,0 +1,17 @@
#pragma once
#include <vtkActor.h>
#include <vtkSmartPointer.h>
#include "model/Field.hpp"
namespace geopro::render {
// 剖面顶部电极标记(对齐原型 ▼):在剖面顶边(最浅深度)各 x 列位置画一个朝下的小三角。
// 坐标与 #18 数据详情一致x=g.x[i]、顶边 y=-min(深度)、z=0。调用方应施加与剖面相同的
// SetScale 以对齐(三角会随之略拉伸)。markerW/markerH 为数据单位下的三角半宽/高。
// 退化网格(无 x)返回空 actor。
vtkSmartPointer<vtkActor> buildElectrodes(const geopro::core::Grid& g,
double markerW = 0.5,
double markerH = 1.4);
} // namespace geopro::render

View File

@ -0,0 +1,190 @@
#include "actors/TerrainActor.hpp"
#include <algorithm>
#include <cmath>
#include <cstddef>
#include <limits>
#include <vector>
#include <gdal.h>
#include <gdal_priv.h>
#include <vtkFloatArray.h>
#include <vtkImageData.h>
#include <vtkNew.h>
#include <vtkPointData.h>
#include <vtkPoints.h>
#include <vtkPolyData.h>
#include <vtkPolyDataMapper.h>
#include <vtkStructuredGrid.h>
#include <vtkStructuredGridGeometryFilter.h>
#include <vtkTexture.h>
#include <vtkUnsignedCharArray.h>
#include "geo/CrsTransform.hpp"
namespace geopro::render {
namespace {
// 项目 CRS(DEM 无投影信息时的兜底)。
constexpr const char* kFallbackCrs = "EPSG:4547";
struct Raster {
int w = 0, h = 0;
double gt[6] = {0, 1, 0, 0, 0, 1}; // 仿射: x=gt0+col*gt1+row*gt2, y=gt3+col*gt4+row*gt5
std::string wkt;
bool ok() const { return w > 0 && h > 0; }
};
// 用 GDAL 读影像像素为 RGB 纹理(vtkTIFFReader 对压缩/分块 TIFF 不可靠,故走 GDAL)。
// 行翻转使影像在 vtkImageData 中正立(配合 tcoord v=1-row/h)。读失败返回 nullptr。
vtkSmartPointer<vtkTexture> readTexture(const std::string& path) {
auto* ds = static_cast<GDALDataset*>(GDALOpen(path.c_str(), GA_ReadOnly));
if (!ds) return nullptr;
const int w = ds->GetRasterXSize(), h = ds->GetRasterYSize();
const int nb = std::min(ds->GetRasterCount(), 3);
if (w <= 0 || h <= 0 || nb < 1) { GDALClose(ds); return nullptr; }
std::vector<unsigned char> buf(static_cast<size_t>(w) * h * nb);
// 交错读 RGBpixelSpace=nb, lineSpace=w*nb, bandSpace=1。
const CPLErr err =
ds->RasterIO(GF_Read, 0, 0, w, h, buf.data(), w, h, GDT_Byte, nb, nullptr,
nb, static_cast<GSpacing>(w) * nb, 1);
GDALClose(ds);
if (err != CE_None) return nullptr;
vtkNew<vtkImageData> img;
img->SetDimensions(w, h, 1);
vtkNew<vtkUnsignedCharArray> sc;
sc->SetNumberOfComponents(3);
sc->SetNumberOfTuples(static_cast<vtkIdType>(w) * h);
for (int r = 0; r < h; ++r)
for (int c = 0; c < w; ++c) {
const size_t s = (static_cast<size_t>(r) * w + c) * nb;
const vtkIdType d = static_cast<vtkIdType>(h - 1 - r) * w + c; // 行翻转→正立
unsigned char rgb[3];
rgb[0] = buf[s];
rgb[1] = nb > 1 ? buf[s + 1] : buf[s];
rgb[2] = nb > 2 ? buf[s + 2] : buf[s];
sc->SetTypedTuple(d, rgb);
}
img->GetPointData()->SetScalars(sc);
auto tex = vtkSmartPointer<vtkTexture>::New();
tex->SetInputData(img);
tex->InterpolateOn();
return tex;
}
// 读栅格几何信息(不读像素)。
Raster readGeo(GDALDataset* ds) {
Raster r;
if (!ds) return r;
r.w = ds->GetRasterXSize();
r.h = ds->GetRasterYSize();
ds->GetGeoTransform(r.gt);
const char* p = ds->GetProjectionRef();
if (p) r.wkt = p;
return r;
}
} // namespace
vtkSmartPointer<vtkActor> buildTerrain(const std::string& demPath, const std::string& imagePath,
const geopro::core::GeoLocalFrame& frame, double zOffset,
double zScale)
{
GDALAllRegister();
auto* dem = static_cast<GDALDataset*>(GDALOpen(demPath.c_str(), GA_ReadOnly));
if (!dem) return vtkSmartPointer<vtkActor>::New();
const Raster dg = readGeo(dem);
if (!dg.ok()) { GDALClose(dem); return vtkSmartPointer<vtkActor>::New(); }
// DEM 高程像素(float)。
std::vector<float> elev(static_cast<size_t>(dg.w) * dg.h, 0.0F);
GDALRasterBand* band = dem->GetRasterBand(1);
int hasNoData = 0;
const double noData = band->GetNoDataValue(&hasNoData);
band->RasterIO(GF_Read, 0, 0, dg.w, dg.h, elev.data(), dg.w, dg.h, GDT_Float32, 0, 0);
GDALClose(dem);
// 有效高程范围(忽略 nodata)nodata 填为最小有效高程使其平坦。
float vmin = std::numeric_limits<float>::infinity();
float vmax = -std::numeric_limits<float>::infinity();
for (float v : elev)
if (!(hasNoData && v == static_cast<float>(noData))) {
vmin = std::min(vmin, v);
vmax = std::max(vmax, v);
}
if (!(vmin <= vmax)) { vmin = vmax = 0.0F; }
// 坐标变换DEM CRS → 43264326 → 3857(纹理坐标用)。
const std::string demCrs = dg.wkt.empty() ? std::string(kFallbackCrs) : dg.wkt;
geopro::core::CrsTransform demTo4326(demCrs, "EPSG:4326");
geopro::core::CrsTransform llTo3857("EPSG:4326", "EPSG:3857");
// 影像几何(算纹理坐标);像素经 vtkTIFFReader 读为纹理。
Raster ig;
auto* img = static_cast<GDALDataset*>(GDALOpen(imagePath.c_str(), GA_ReadOnly));
if (img) { ig = readGeo(img); GDALClose(img); }
const bool hasImage = ig.ok();
// 结构化网格:点=世界局部米(E,N,elev*zScale),标量=高程;纹理坐标(若有影像)。
vtkNew<vtkStructuredGrid> sgrid;
sgrid->SetDimensions(dg.w, dg.h, 1);
vtkNew<vtkPoints> points;
points->SetNumberOfPoints(static_cast<vtkIdType>(dg.w) * dg.h);
vtkNew<vtkFloatArray> sc;
sc->SetName("elev");
sc->SetNumberOfTuples(static_cast<vtkIdType>(dg.w) * dg.h);
vtkNew<vtkFloatArray> tc;
tc->SetName("tc");
tc->SetNumberOfComponents(2);
if (hasImage) tc->SetNumberOfTuples(static_cast<vtkIdType>(dg.w) * dg.h);
for (int j = 0; j < dg.h; ++j)
for (int i = 0; i < dg.w; ++i) {
const double demX = dg.gt[0] + (i + 0.5) * dg.gt[1] + (j + 0.5) * dg.gt[2];
const double demY = dg.gt[3] + (i + 0.5) * dg.gt[4] + (j + 0.5) * dg.gt[5];
const auto ll = demTo4326.forward(demX, demY); // (lon, lat)
const auto local = frame.toLocal(ll.y, ll.x); // (E, N)
const vtkIdType id = static_cast<vtkIdType>(j) * dg.w + i;
float z = elev[static_cast<size_t>(j) * dg.w + i];
if (hasNoData && z == static_cast<float>(noData)) z = vmin;
points->SetPoint(id, local.x, local.y, (z - zOffset) * zScale); // rebase 到测线高程
sc->SetValue(id, z);
if (hasImage) {
const auto m = llTo3857.forward(ll.x, ll.y); // (mercX, mercY)
const double col = (m.x - ig.gt[0]) / ig.gt[1];
const double row = (m.y - ig.gt[3]) / ig.gt[5]; // gt5<0
const double u = col / ig.w;
const double v = 1.0 - row / ig.h; // 翻转使影像正立(按需核对)
tc->SetTuple2(id, u, v);
}
}
sgrid->SetPoints(points);
sgrid->GetPointData()->SetScalars(sc);
if (hasImage) sgrid->GetPointData()->SetTCoords(tc);
vtkNew<vtkStructuredGridGeometryFilter> geom;
geom->SetInputData(sgrid);
vtkNew<vtkPolyDataMapper> mapper;
mapper->SetInputConnection(geom->GetOutputPort());
auto actor = vtkSmartPointer<vtkActor>::New();
actor->SetMapper(mapper);
auto tex = hasImage ? readTexture(imagePath) : nullptr;
if (tex) {
actor->SetTexture(tex);
mapper->ScalarVisibilityOff(); // 用纹理,不用高程标量上色
} else {
mapper->SetScalarRange(vmin, vmax); // 退化:影像读失败/缺失 → 按高程上色
}
return actor;
}
} // namespace geopro::render

View File

@ -0,0 +1,25 @@
#pragma once
#include <string>
#include <vtkActor.h>
#include <vtkSmartPointer.h>
#include "geo/GeoLocalFrame.hpp"
namespace geopro::render {
// DEM 地形 + 影像贴图(spec ④)。GDAL 读 dem.tif(高程栅格)与 image.tif(影像,EPSG:3857)
// 各自按其 CRS → EPSG:4326 → GeoLocalFrame 局部米配准到世界系(与帘面/体素同系)
// 高程作 z 起伏(vtkStructuredGrid 面)影像按经纬→3857→像素 算纹理坐标贴面。
// 影像加载失败则退化为按高程上色(无纹理)。读不到 DEM 返回空 actor。
//
// 依赖 GDAL/PROJ调用方运行时须有 PROJ_DATA。
// zOffset从高程减去的基准(米)——传测线地表高程使地形落在测线附近(否则按绝对高程浮空,
// spec M-3 Z 基准)。世界 z = (elev - zOffset) * zScale。zScale 为纵向夸张。
vtkSmartPointer<vtkActor> buildTerrain(const std::string& demPath,
const std::string& imagePath,
const geopro::core::GeoLocalFrame& frame,
double zOffset = 0.0,
double zScale = 1.0);
} // namespace geopro::render

View File

@ -74,6 +74,9 @@ vtkSmartPointer<vtkVolume> buildVoxel(const geopro::core::ScalarVolume& vol,
// SmartVolumeMapper有 GPU 走 GPU ray cast否则自动回退 CPU避免无 GPU 时卡死/失败。 // SmartVolumeMapper有 GPU 走 GPU ray cast否则自动回退 CPU避免无 GPU 时卡死/失败。
vtkNew<vtkSmartVolumeMapper> mapper; vtkNew<vtkSmartVolumeMapper> mapper;
mapper->SetInputData(img); mapper->SetInputData(img);
// 全程统一全质量(GPU 足够快, 实测 ~7ms/帧):关掉交互降采样, 避免"停手补高清"那一帧突跳停顿。
mapper->SetAutoAdjustSampleDistances(0);
mapper->SetInteractiveAdjustSampleDistances(0);
vtkNew<vtkVolumeProperty> prop; vtkNew<vtkVolumeProperty> prop;
prop->SetColor(color); prop->SetColor(color);

View File

@ -70,6 +70,10 @@ target_sources(geopro_tests PRIVATE render/test_curtain.cpp)
target_sources(geopro_tests PRIVATE render/test_scatter.cpp) target_sources(geopro_tests PRIVATE render/test_scatter.cpp)
# AnomalybuildAnomalies(markType 点/线/面 -> vtkActor) ///y/ # AnomalybuildAnomalies(markType 点/线/面 -> vtkActor) ///y/
target_sources(geopro_tests PRIVATE render/test_anomaly.cpp) target_sources(geopro_tests PRIVATE render/test_anomaly.cpp)
# ElectrodebuildElectrodes(剖面顶边朝下三角 ) //
target_sources(geopro_tests PRIVATE render/test_electrode.cpp)
# TerrainbuildTerrain(GDAL dem/image + 重投影 warp 面+纹理) /缺文件安全( PROJ_DATA)
target_sources(geopro_tests PRIVATE render/test_terrain.cpp)
target_link_libraries(geopro_tests PRIVATE geopro_render ${VTK_LIBRARIES}) target_link_libraries(geopro_tests PRIVATE geopro_render ${VTK_LIBRARIES})
vtk_module_autoinit(TARGETS geopro_tests MODULES ${VTK_LIBRARIES}) vtk_module_autoinit(TARGETS geopro_tests MODULES ${VTK_LIBRARIES})

View File

@ -0,0 +1,42 @@
#include <gtest/gtest.h>
#include <vtkPolyData.h>
#include <vtkPolyDataMapper.h>
#include "actors/ElectrodeActor.hpp"
#include "model/Field.hpp"
using namespace geopro::core;
// buildElectrodes: nx 列 → nx 个朝下三角(各 3 点);顶点 y = -min(深度)(剖面顶边)。
TEST(Electrode, BuildsDownTrianglesAtSectionTop) {
Grid g(3, 2);
g.x = {0.0, 10.0, 20.0};
g.y = {2.0, 8.0}; // 深度min=2 → 顶边 y_screen = -2
auto actor = geopro::render::buildElectrodes(g, /*markerW*/ 0.5, /*markerH*/ 1.0);
ASSERT_NE(actor.GetPointer(), nullptr);
auto* mapper = vtkPolyDataMapper::SafeDownCast(actor->GetMapper());
ASSERT_NE(mapper, nullptr);
auto* poly = vtkPolyData::SafeDownCast(mapper->GetInput());
ASSERT_NE(poly, nullptr);
EXPECT_EQ(poly->GetNumberOfCells(), 3); // 3 电极 → 3 三角
EXPECT_EQ(poly->GetNumberOfPoints(), 3 * 3); // 每三角 3 点
// 第一个三角的 apex(点 0) 在 (x=0, y=-2)(顶边),底边两点在其上方 y=-1。
double apex[3];
poly->GetPoint(0, apex);
EXPECT_DOUBLE_EQ(apex[0], 0.0);
EXPECT_DOUBLE_EQ(apex[1], -2.0);
double base[3];
poly->GetPoint(1, base);
EXPECT_DOUBLE_EQ(base[1], -1.0); // -2 + markerH(1)
}
// 退化网格(无 x)返回空 actor。
TEST(Electrode, EmptyGridYieldsSafeActor) {
Grid g(0, 0);
auto actor = geopro::render::buildElectrodes(g);
ASSERT_NE(actor.GetPointer(), nullptr);
EXPECT_EQ(actor->GetMapper(), nullptr);
}

View File

@ -0,0 +1,32 @@
#include <gtest/gtest.h>
#include <string>
#include <vtkActor.h>
#include <vtkMapper.h>
#include "actors/TerrainActor.hpp"
#include "geo/GeoLocalFrame.hpp"
using namespace geopro::core;
// 样本 DEM/影像目录(UTF-8 中文路径;源文件以 UTF-8 保存 + MSVC /utf-8 编译)。
static const std::string kDir =
u8"D:/Git/lanbingtech/geopro/docs/剖面网格数据的色阶数据2等文件/";
// buildTerrain: 真实 dem.tif + image.tif 经 GDAL 读 + 重投影 → 非空 actor 且有 mapper。
// (需 PROJ_DATA + GDAL 运行时; tests CMake 注入 PROJ_DATA。)
TEST(Terrain, BuildsTexturedSurfaceFromSampleDemImage) {
GeoLocalFrame frame(22.546, 114.164); // 测区附近(香港)
auto actor = geopro::render::buildTerrain(kDir + "dem.tif", kDir + "image.tif", frame);
ASSERT_NE(actor.GetPointer(), nullptr);
ASSERT_NE(actor->GetMapper(), nullptr); // 成功读 DEM → 有 mapper(空 actor 无 mapper)
}
// 不存在的 DEM → 安全返回空 actor(无 mapper),不崩。
TEST(Terrain, MissingDemYieldsSafeActor) {
GeoLocalFrame frame(22.546, 114.164);
auto actor = geopro::render::buildTerrain("D:/no/such/dem.tif", "D:/no/such/img.tif", frame);
ASSERT_NE(actor.GetPointer(), nullptr);
EXPECT_EQ(actor->GetMapper(), nullptr);
}

View File

@ -10,9 +10,11 @@
#include <vtkActor.h> #include <vtkActor.h>
#include <vtkNew.h> #include <vtkNew.h>
#include <vtkOpenGLRenderWindow.h>
#include <vtkPNGWriter.h> #include <vtkPNGWriter.h>
#include <vtkRenderWindow.h> #include <vtkRenderWindow.h>
#include <vtkRenderer.h> #include <vtkRenderer.h>
#include <vtkSmartVolumeMapper.h>
#include <vtkWindowToImageFilter.h> #include <vtkWindowToImageFilter.h>
#include "CameraPreset.hpp" #include "CameraPreset.hpp"
@ -20,9 +22,11 @@
#include "VoxelFromScatters.hpp" #include "VoxelFromScatters.hpp"
#include "actors/AnomalyActor.hpp" #include "actors/AnomalyActor.hpp"
#include "actors/CurtainActor.hpp" #include "actors/CurtainActor.hpp"
#include "actors/ElectrodeActor.hpp"
#include "actors/GridContourActor.hpp" #include "actors/GridContourActor.hpp"
#include "actors/MapLineActor.hpp" #include "actors/MapLineActor.hpp"
#include "actors/ScatterActor.hpp" #include "actors/ScatterActor.hpp"
#include "actors/TerrainActor.hpp"
#include "geo/CrsTransform.hpp" #include "geo/CrsTransform.hpp"
#include "geo/GeoLocalFrame.hpp" #include "geo/GeoLocalFrame.hpp"
#include "parse/SampleParsers.hpp" #include "parse/SampleParsers.hpp"
@ -110,6 +114,9 @@ int main() {
act->SetScale(1.0, exag, 1.0); act->SetScale(1.0, exag, 1.0);
ren->AddActor(act); ren->AddActor(act);
} }
// 顶部电极标记 ▼(同纵向夸张对齐)。
auto elec = render::buildElectrodes(g);
if (elec) { elec->SetScale(1.0, exag, 1.0); ren->AddActor(elec); }
render::applyTop2D(ren); render::applyTop2D(ren);
renderToPng(ren, (dir + "verify_section_anomaly.png").c_str(), 1100, 360); renderToPng(ren, (dir + "verify_section_anomaly.png").c_str(), 1100, 360);
std::printf("ANOMALY n=%zu\n", anomalies.size()); std::printf("ANOMALY n=%zu\n", anomalies.size());
@ -143,6 +150,28 @@ int main() {
if (vr.valid()) vr.image->GetDimensions(dims); if (vr.valid()) vr.image->GetDimensions(dims);
std::printf("VOXEL valid=%d dims=%dx%dx%d pts=%zu\n", vr.valid() ? 1 : 0, std::printf("VOXEL valid=%d dims=%dx%dx%d pts=%zu\n", vr.valid() ? 1 : 0,
dims[0], dims[1], dims[2], profs[0].v.size() + profs[1].v.size()); dims[0], dims[1], dims[2], profs[0].v.size() + profs[1].v.size());
// [实测诊断] 体绘制实际渲染模式 + OpenGL 渲染器(查是否软件渲染/CPU 回退)。
if (vr.valid()) {
vtkNew<vtkRenderer> rd; rd->AddVolume(vr.volume);
vtkNew<vtkRenderWindow> rwd; rwd->SetOffScreenRendering(1);
rwd->AddRenderer(rd); rwd->SetSize(400, 400); rwd->Render();
auto* svm = vtkSmartVolumeMapper::SafeDownCast(vr.volume->GetMapper());
const int mode = svm ? svm->GetLastUsedRenderMode() : -99;
std::printf("VOXEL_RENDER_MODE=%d (0=Default,1=RayCast/CPU,2=GPU,3=OSPRay)\n", mode);
auto* gl = vtkOpenGLRenderWindow::SafeDownCast(rwd.Get());
if (gl) std::printf("GL_CAPS:\n%s\n", gl->ReportCapabilities());
}
}
// 7) DEM 地形 + 影像贴图 — GDAL 读 + 重投影到世界系 + warp 面 + 纹理
{
auto terr = render::buildTerrain(dir + "dem.tif", dir + "image.tif", frame, 0.0, 1.0);
vtkNew<vtkRenderer> ren; ren->SetBackground(0.50, 0.60, 0.72);
if (terr) ren->AddActor(terr);
render::applyFree3D(ren);
renderToPng(ren, (dir + "verify_terrain_3d.png").c_str(), 800, 600);
std::printf("TERRAIN actor=%d\n", terr ? 1 : 0);
} }
std::printf("RENDER_VERIFY_DONE grid=%dx%d lat0=%.5f lon0=%.5f\n", g.nx(), g.ny(), lat0, lon0); std::printf("RENDER_VERIFY_DONE grid=%dx%d lat0=%.5f lon0=%.5f\n", g.nx(), g.ny(), lat0, lon0);

View File

@ -4,6 +4,7 @@
"description": "Geopro 3.0 desktop client (Qt6 + VTK9) - M1. 方案②-修订: Qt/VTK/ADS/QtKeychain 对接官方 MSVC Qt(不走 vcpkg); 仅非 Qt 依赖走 vcpkg, 按层递增。", "description": "Geopro 3.0 desktop client (Qt6 + VTK9) - M1. 方案②-修订: Qt/VTK/ADS/QtKeychain 对接官方 MSVC Qt(不走 vcpkg); 仅非 Qt 依赖走 vcpkg, 按层递增。",
"dependencies": [ "dependencies": [
"eigen3", "eigen3",
"gdal",
"gtest", "gtest",
"nlohmann-json", "nlohmann-json",
"openssl", "openssl",