Compare commits
88 Commits
2c204a134a
...
a2e16e18e8
| Author | SHA1 | Date |
|---|---|---|
|
|
a2e16e18e8 | |
|
|
3635f295b2 | |
|
|
223b8ecf70 | |
|
|
484992a434 | |
|
|
c5577ce071 | |
|
|
052fdc1168 | |
|
|
97dfd54445 | |
|
|
876d88c251 | |
|
|
e718336385 | |
|
|
c3f72fdc8d | |
|
|
692ee057ab | |
|
|
e15930d8fb | |
|
|
fb43237830 | |
|
|
fd43051d8d | |
|
|
69a81b2eac | |
|
|
67eaade7bd | |
|
|
0ac2765fd7 | |
|
|
ca847f5a77 | |
|
|
63e7874175 | |
|
|
b5bab42825 | |
|
|
52f7a7d5e8 | |
|
|
a4866de68c | |
|
|
5f27e59685 | |
|
|
23ed390faf | |
|
|
d27ef37a24 | |
|
|
ad3310b5bb | |
|
|
c03dc35469 | |
|
|
d99e5c61f4 | |
|
|
33e9949623 | |
|
|
67f767d787 | |
|
|
ef8a9da254 | |
|
|
29ea44560d | |
|
|
aaf150ca2e | |
|
|
11349e533c | |
|
|
d68fc31ae7 | |
|
|
8fceb6c1f3 | |
|
|
a588b651a6 | |
|
|
c06f9ea0f8 | |
|
|
8684e52939 | |
|
|
f407c0adbc | |
|
|
cc53a74b88 | |
|
|
86764b0cd9 | |
|
|
fe04bb1266 | |
|
|
b2740898f6 | |
|
|
37b433208e | |
|
|
5d1cf07882 | |
|
|
5fe1c298d2 | |
|
|
e34abd271f | |
|
|
2934bacd34 | |
|
|
2d155c864c | |
|
|
744b55c1b6 | |
|
|
77f1b5543e | |
|
|
624cdcbb2e | |
|
|
575529e5a0 | |
|
|
5e15941cd2 | |
|
|
24d88530af | |
|
|
2179f149b7 | |
|
|
5e57d462c8 | |
|
|
d81494fd5e | |
|
|
5f19a0c0db | |
|
|
b6143a0cb6 | |
|
|
efc09a5877 | |
|
|
45662ff897 | |
|
|
97d1e70099 | |
|
|
3f24ad81e3 | |
|
|
529ffc023c | |
|
|
01a8c0ae03 | |
|
|
540fb1cde5 | |
|
|
c058c851ee | |
|
|
07f2f25b58 | |
|
|
5809b88a44 | |
|
|
8d94247dd9 | |
|
|
2e5cc4e6db | |
|
|
a7edfa5c78 | |
|
|
f3a1ba9f99 | |
|
|
87c5cc910e | |
|
|
43f8228e49 | |
|
|
eb8cb9e7ee | |
|
|
8a06014e0b | |
|
|
ff3ce27978 | |
|
|
29710a8484 | |
|
|
a5e4f04bd9 | |
|
|
87b90a2022 | |
|
|
85d4ff57df | |
|
|
c44203d6ca | |
|
|
86e07722e5 | |
|
|
73deb2b159 | |
|
|
3dea339ddc |
21
build.bat
21
build.bat
|
|
@ -2,11 +2,13 @@
|
||||||
REM ============================================================
|
REM ============================================================
|
||||||
REM geopro build helper (Windows / MSVC + Ninja, CMake presets)
|
REM geopro build helper (Windows / MSVC + Ninja, CMake presets)
|
||||||
REM
|
REM
|
||||||
REM Usage: build [app | all | test | run | configure]
|
REM Usage: build [app | all | test | run | rebuild | configure]
|
||||||
REM app (default) build target geopro_desktop
|
REM app (default) build target geopro_desktop (incremental)
|
||||||
REM all build all targets
|
REM all build all targets (incremental)
|
||||||
REM test build + run unit tests via ctest
|
REM test build + run unit tests via ctest
|
||||||
REM run build + launch geopro_desktop
|
REM run incremental build + launch geopro_desktop
|
||||||
|
REM rebuild FORCE clean rebuild (--clean-first) + launch - use when
|
||||||
|
REM incremental seems stale / changes not showing up
|
||||||
REM configure force re-run CMake configure (after CMakeLists changes)
|
REM configure force re-run CMake configure (after CMakeLists changes)
|
||||||
REM
|
REM
|
||||||
REM Requires: Visual Studio 2022/2026 (Desktop C++ workload, ships
|
REM Requires: Visual Studio 2022/2026 (Desktop C++ workload, ships
|
||||||
|
|
@ -48,7 +50,8 @@ if /i "%CMD%"=="app" goto :app
|
||||||
if /i "%CMD%"=="all" goto :all
|
if /i "%CMD%"=="all" goto :all
|
||||||
if /i "%CMD%"=="test" goto :test
|
if /i "%CMD%"=="test" goto :test
|
||||||
if /i "%CMD%"=="run" goto :run
|
if /i "%CMD%"=="run" goto :run
|
||||||
echo [build] unknown command "%CMD%". Use: app ^| all ^| test ^| run ^| configure
|
if /i "%CMD%"=="rebuild" goto :rebuild
|
||||||
|
echo [build] unknown command "%CMD%". Use: app ^| all ^| test ^| run ^| rebuild ^| configure
|
||||||
exit /b 1
|
exit /b 1
|
||||||
|
|
||||||
:ensure
|
:ensure
|
||||||
|
|
@ -80,3 +83,11 @@ call :ensure
|
||||||
"%CMAKE%" --build "%BUILDDIR%" --target geopro_desktop || exit /b 1
|
"%CMAKE%" --build "%BUILDDIR%" --target geopro_desktop || exit /b 1
|
||||||
"%BUILDDIR%\src\app\geopro_desktop.exe"
|
"%BUILDDIR%\src\app\geopro_desktop.exe"
|
||||||
exit /b %errorlevel%
|
exit /b %errorlevel%
|
||||||
|
|
||||||
|
:rebuild
|
||||||
|
REM Force full clean rebuild (--clean-first) then launch; avoids flaky ninja incremental.
|
||||||
|
REM If geopro_desktop is already running, link fails (LNK1104, exe locked) - close it first.
|
||||||
|
call :ensure
|
||||||
|
"%CMAKE%" --build "%BUILDDIR%" --target geopro_desktop --clean-first || exit /b 1
|
||||||
|
"%BUILDDIR%\src\app\geopro_desktop.exe"
|
||||||
|
exit /b %errorlevel%
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
# 反演剖面(dd_inversion_data)竖向字段 y / z / elevation 语义待业务确认
|
||||||
|
|
||||||
|
- 日期:2026-06-16
|
||||||
|
- 背景:桌面客户端在 3D 视图里把 ERT 反演剖面(`dd_inversion_data`)渲染成**竖直帘面**。水平方向已用 `lat/lon` 摆到真实测线位置(弯曲测线渲染为曲面,已验证)。**竖直方向用哪个字段、如何定位,目前不确定**,需业务/数据方确认。
|
||||||
|
- 数据来源:线上 `GET /business/dd/ert/inversion/rows/{dsObjectId}`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 接口返回的竖向相关字段
|
||||||
|
|
||||||
|
`dd/ert/inversion/rows` 的 `data` 含:
|
||||||
|
- `x`:长度 `nx`,水平轴(距离?)。
|
||||||
|
- `y`:长度 `ny`,竖直轴(含义不明,见下)。
|
||||||
|
- `v`:`[ny][nx]` 电阻率值矩阵(不规则区大量 `null`)。
|
||||||
|
- `z`:`[ny][nx]`,逐格一个数(含义不明)。
|
||||||
|
- `elevation`:长度 `nx`,疑似每列地表高程。
|
||||||
|
- `lat` / `lon`:长度 `nx`,每列经纬度(已用于水平定位)。
|
||||||
|
- `vmin` / `vmax`、`sectionType` 等。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 核心问题:y / z / elevation 的范围与关系**跨数据集不一致**
|
||||||
|
|
||||||
|
抽样 4 条真实 `dd_inversion_data`(**全部 `sectionType=1`**,即不是"剖面类型不同"导致):
|
||||||
|
|
||||||
|
| 数据集 | 项目 | x 范围 | **y 范围** | **z 范围** | **elevation 范围** | z/v 空值 |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| T251230002-M-2 | 地大华睿演示 | [200.0, 437.7] | **[-35.1, -1.1]** | [-101.5, -0.09] | [-35.0, -34.1] | 0 |
|
||||||
|
| T120526003-3 | 香港威立雅 | [2.9, 74.6] | **[13.1, 26.2]** | [2.1, 15.4] | [41.6, 51.0] | 1618/1900 |
|
||||||
|
| ERT1-WS | 演示(高密度+瞬变) | [0.2, 75.7] | **[9.8, 26.7]** | [0.1, 15.4] | [36.1, 37.9] | 14911/90000 |
|
||||||
|
| ert2-ws | 射洪垃圾填埋场 | [0.04, 235.4] | **[287.6, 353.4]** | [-10.1, -0.03] | [287.8, 292.8] | 0 |
|
||||||
|
|
||||||
|
**观察到的矛盾:**
|
||||||
|
1. `y` 的范围毫无统一规律:有负(-35~-1)、有小正(9~27)、有大正(287~353)。无法判断它是"深度(向下为正)"、"相对层号"、还是"绝对高程"。
|
||||||
|
2. `z` 同样无规律:有深负(-101.5)、有小正(0~15)、有小负(-10)。
|
||||||
|
3. `elevation` 看起来最像"地表高程"(随项目所在地不同:-34 / 41~51 / 36 / 288~293),但与 `y`、`z` 的换算关系**对不上**(例:射洪 `y`≈`elevation`≈287~353,而地大 `y`=-35~-1 远小于 `elevation`=-34)。
|
||||||
|
4. `z` 的空值数恰好等于 `v` 的空值数 → `z` 随"有无数据"分布(像是逐格的某个量)。
|
||||||
|
|
||||||
|
> 结论:仅凭数据无法可靠推断竖向模型,且**不同数据集疑似采用了不同的竖向约定/基准**(可能与上传来源/格式有关)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 客户端当前做法(供确认/纠正)
|
||||||
|
|
||||||
|
- 竖直坐标用 **`y` 作深度**:每个格子 Z = `-y[j]`(深度向下取负),**未使用 `z` 和 `elevation`**。
|
||||||
|
- 这与**二维"数据详情"反演图一致**(详情图 `ContourPlotItem` 也只用 `x`、`y` 画"距离 × 深度"矩形,不用 `z`/`elevation`)。
|
||||||
|
- 即:3D 帘面 = 二维"距离×深度"剖面**立起来 + 沿真实测线(lat/lon)弯曲**,**平顶**、不随地表起伏。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 请业务/数据方确认的问题
|
||||||
|
|
||||||
|
1. **`y` 是什么?** 深度(向下为正/为负?)、相对层号、还是绝对高程?为何不同数据集范围差异巨大(-35~-1 vs 287~353)?是否存在多套竖向基准?
|
||||||
|
2. **`z`(`[ny][nx]`)是什么?** 逐格的真实高程?深度?还是别的量(如反演网格的实际竖向坐标/褶皱面)?
|
||||||
|
3. **`elevation`(`[nx]`)是什么?** 每列地表高程吗?单位、基准(海拔/相对)?
|
||||||
|
4. **3D 剖面竖向应如何定位?**
|
||||||
|
- (A) 维持现状:平顶"距离×深度"面(与 2D 详情一致);或
|
||||||
|
- (B) 跟随地表起伏:顶面按 `elevation`/`z` 摆到真实高程、随地形上下。
|
||||||
|
若选 (B),请给出**用哪个字段、如何换算**(Z = ?(y, z, elevation))。
|
||||||
|
5. **垂向单位与方向**:米?向上为正还是向下为正?
|
||||||
|
6. **不同数据集的竖向差异**是数据本身的真实差异,还是上传/解析造成的不一致(需后端修正)?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 影响
|
||||||
|
|
||||||
|
- 现状 (A) 已能正确渲染(与 2D 详情一致),可继续使用。
|
||||||
|
- 若业务要 (B) 地形跟随,需上面第 2/3/4 项的明确定义后,客户端按真实竖向模型实现(避免凭猜测导致渲染错误)。
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
# 3D 地球改造:当前实现方式、目标、问题/约束评估
|
||||||
|
|
||||||
|
- 日期:2026-06-17
|
||||||
|
- 目的:评估桌面客户端 VTK 三维视图从「平面局部坐标场景」改造为「3D 地球(对齐原版 web)」的可行性、影响范围与约束,供决策是否立项。
|
||||||
|
- 说明:**纯文字描述,不含代码**。结论待 subagent 基于代码实测评审。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 原版(web)目标形态(已实地分析)
|
||||||
|
- 原版 dataView 用 **Three.js 3D 地球**(容器 `threeMap`,单 WebGL canvas;`__THREE__` 在,Cesium 加载但 `cesium-widget` 未用)。
|
||||||
|
- 底图瓦片:**Mapbox 卫星** XYZ(`api.mapbox.com/v4/mapbox.satellite/{z}/{x}/{y}.webp`)贴在球面。
|
||||||
|
- 反演剖面等数据按**真实经纬**贴在弯曲球面上(竖直帘面),可从太空旋转/缩放飞到地面。
|
||||||
|
- 需求表要求底图为「天地图」(与原版实际用 Mapbox 不一致);天地图同样有卫星 WMTS(Web Mercator z/x/y),瓦片源可换、不影响"球 vs 平面"这一架构问题。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 当前桌面实现方式(文字描述)
|
||||||
|
|
||||||
|
**渲染技术**:桌面用 **VTK**(非 Three.js / 非 Cesium),是与 web 完全不同的另一套实现。
|
||||||
|
|
||||||
|
**坐标系:局部切平面(不是地球球面)**
|
||||||
|
- 用 `GeoLocalFrame`:等距圆柱(equirectangular)近似,把经纬度投影成以某原点为中心的**局部米平面**(x=东、y=北,单位米)。这是一个**小范围测区的平面近似**,不是地心 ECEF 球面坐标。
|
||||||
|
- 竖直方向 Z = 深度(向下为负),与经纬无关。
|
||||||
|
- 原点最近改为"按首个真实剖面 lat/lon 中心就地重锚",使局部坐标从 0 附近起。
|
||||||
|
|
||||||
|
**各组件都建立在这个局部平面坐标系上**:
|
||||||
|
- **帘面(剖面)**:每列经纬经 `GeoLocalFrame` 投到局部米平面,Z 取深度;整体是局部平面里的一面"墙"。
|
||||||
|
- **切片交互**:在体素/剖面上沿轴向/任意角度切片,基于局部直角坐标的平面/重采样。
|
||||||
|
- **坐标轴**:取场景局部包围盒造立方体坐标轴,刻度可反算回经纬度显示。
|
||||||
|
- **相机预设**:前/后/左/右/上/下 6 向 + Zoom/Fit,都假设局部直角坐标、Z 向上。
|
||||||
|
- **拾取/选中**:在局部坐标场景里按包围盒/距离判定。
|
||||||
|
- **底图(我刚做的)**:天地图瓦片平铺成**一块平面地面**(z=0),在局部坐标系里,不是球。
|
||||||
|
|
||||||
|
**结论**:当前是"**以测区为中心的局部平面 3D 场景**",适合近距离审视单个剖面/切片;不是地球。spec 当初有意如此选择(VTK ≠ web 球,且三栏/切片在局部直角系才好实现)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 目标:3D 地球(对齐原版)
|
||||||
|
- 整个地球为球面(地心 ECEF / 椭球坐标),可从太空旋转、缩放飞到地面。
|
||||||
|
- 卫星瓦片(天地图卫星)贴满球面,随缩放 LOD 加细。
|
||||||
|
- 数据(帘面/切片/异常)按真实经纬贴在弯曲球面对应位置。
|
||||||
|
- 相机为地球导航(轨道环绕 + 飞向目标),而非 6 向局部预设。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 从"局部平面"到"3D 地球"的问题 / 约束(核心)
|
||||||
|
|
||||||
|
1. **坐标系根本改变**:局部切平面米 → 地心 ECEF(经纬高→三维笛卡尔球面坐标)。这不是"加个底图",而是**整个 3D 视图的坐标基准更换**。
|
||||||
|
|
||||||
|
2. **所有依赖局部坐标的组件都要重做**:
|
||||||
|
- 帘面定位(局部米 → 球面 ECEF 的竖直面);
|
||||||
|
- 切片几何与重采样(轴向/任意切片在球面坐标下的平面定义变复杂);
|
||||||
|
- 立方体坐标轴(球面上"立方体轴"语义不再适用,需换成经纬网/比例尺);
|
||||||
|
- 6 向相机预设(前/后/左/右/上/下在球面无固定意义,需改地球导航);
|
||||||
|
- 拾取/选中(球面坐标下判定);
|
||||||
|
- 纵向比例(垂向夸张)在球面上的语义与实现。
|
||||||
|
|
||||||
|
3. **VTK 无现成"瓦片地球"**:Cesium/Three.js 有内建的 LOD 瓦片地球;VTK 没有。要自建:球面几何 + 多级瓦片调度 + 投影贴图 + (大气/光照),工作量大。
|
||||||
|
|
||||||
|
4. **数值精度**:ECEF 坐标量级约 6.4×10⁶ 米,GPU 渲染管线的浮点矩阵在该量级有抖动(jitter)(数据数组本身已用双精度)。标准规避法是"相机相对原点偏移"——**而当前的 `GeoLocalFrame.reanchor`(一切相对数据中心原点渲染)正是这种规避**;即局部平面方案天然避开了球面会引入的精度问题(佐证务实方案)。
|
||||||
|
|
||||||
|
4b. **【评审补充,最硬的拦路虎】体素/切片基于 `vtkImageData`(轴对齐规则栅格)**:三维体是 `vtkImageData`(`SetOrigin`/`SetSpacing` 轴对齐),切片工具对它重采样。`vtkImageData` 本质是轴对齐规则网格,**无法弯曲贴到球面**。这是真 3D 地球最根本的不兼容点,比"切片重采样变复杂"更强。
|
||||||
|
|
||||||
|
4c. **【评审补充】地形 actor(TerrainActor)也全程局部系**:DEM 顶点经 `frame.toLocal` 摆放 + 独立 EPSG 重投影/墨卡托纹理坐标,球面化都要重做。
|
||||||
|
|
||||||
|
4d. **【评审补充】纵向夸张(VE)散布三处**:帘面 `SetScale(1,1,ve)`、体素烤进 origin/spacing、地形 zScale。球面上"缩放 Z"无单一含义(Z 是径向、方向逐点变),三处都要重定义。
|
||||||
|
|
||||||
|
4e. **【评审补充】2D 俯视测线模式(Map2D)**:`applyTop2D + addSurveyLine` 也建在 z=0 局部平面,球面基准下要么单独保留平面投影、要么重新推导。
|
||||||
|
|
||||||
|
5. **小尺度上收益有限**:数据都在几百米的小测区;该尺度下地球曲率不可见——"飞到地面的地球"与"局部卫星平面"看到的卫星图基本一致。3D 地球真正多出的是"从太空看全球/转地球"的整体观感与"和原版一致"。
|
||||||
|
|
||||||
|
6. **与既有功能的冲突**:本轮已完成的三栏/帘面/切片/坐标轴/相机预设全部基于局部平面;改球面等于把这些重写或重新适配,回归风险高、且 GUI 不可由我自测。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 影响范围 / 工作量(定性)
|
||||||
|
- **底图本身**(瓦片源换天地图):小。
|
||||||
|
- **平面底图 → 局部卫星平面**:小(现成 TileBasemap 换源)。
|
||||||
|
- **平面场景 → 3D 地球**:**大重构**,触及坐标系 + 帘面/切片/轴/相机/拾取/底图全链,属重新立项分期,不宜并入当前轮次。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 决策建议
|
||||||
|
- 3D 地球是**根本性重构**(非底图增量);本地剖面尺度(几百米)下与"局部卫星平面"视觉收益差异有限;务实方案是"局部天地图卫星平面",3D 地球如确需"地球观感"则单独立项分期。
|
||||||
|
|
||||||
|
## 7. 评审结论(opus 子代理 · 基于代码实测)
|
||||||
|
- §2 当前实现的 7 项描述(局部等距圆柱坐标、帘面/切片/坐标轴/相机/拾取/底图均依赖局部平面)**逐条经代码核实,全部准确**(与源码逐字吻合)。
|
||||||
|
- §4 "球=根本性重构"的判断**成立且偏保守**——文档**低估**了耦合面:另需重做 **地形 actor、体素 `vtkImageData` 轴对齐(最硬拦路虎,规则栅格无法贴球)、三处纵向夸张、2D 俯视模式**(已补入 §4b–4e)。无任何"夸大耦合"之处。
|
||||||
|
- 总体结论 **SOUND**:3D 地球触及整条 3D 管线根基,非底图增量;局部卫星平面是务实折中;站点尺度曲率视觉可忽略。其中**体素/切片基于 vtkImageData 无法弯曲贴球**是最强佐证。
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
# 交接:VTK 三维视图「补充需求」(feat/vtk-3d-view)
|
||||||
|
|
||||||
|
> 给下一个会话无缝接手用。日期 2026-06-16。分支 `feat/vtk-3d-view`,HEAD `07f2f25`,工作树干净(仅根目录 grid-*.png/grid-snap.yml 是既有未跟踪文件,非本任务产物,勿动)。
|
||||||
|
|
||||||
|
## 1. 背景
|
||||||
|
- 项目:geopro 桌面客户端(Qt6 + VTK9 + ADS dock),Windows/MSVC+Ninja。
|
||||||
|
- 任务:实现需求表「补充需求」页签 = **VTK 三维视图的整套交互/结构**。需求源:`D:\Projects\GEOPRO\Geopro3.0 需求表.xlsx`「补充需求」(用 openpyxl 读,控制台中文乱码须导 UTF-8 文件再读;A1–C92)。
|
||||||
|
- 历史:做本需求前已有"基于本地样本数据的原型渲染"(帘面/体素/切片/地形/散点),但在 commit `6241eb3`"CentralScene 数据驱动重构"时**装配代码被摘除**——render 层 actor 完整且有测试,只是没接上。本任务从复活它起步。
|
||||||
|
- 原版 web「数据视图」(`tenant.geomative.cn/#/projectSpace/dataView`) 已 Playwright 实地分析:3D = **ThreeTile(Three.js)地球**(非 Cesium)+多瓦片源;3D 结果=2D 反演剖面成竖直帘面。**三栏/切片是客户端新需求(web 无三栏)**。详见记忆 [[web-3d-view-threetile]]。
|
||||||
|
|
||||||
|
## 2. 关键约束(用户拍板)
|
||||||
|
- **后端未就绪** → 本轮全部用 `LocalSampleRepository` 静态样本数据驱动;**但仓储接口必须按真实后端形态设计好**(`I3dSceneRepository` 异步),将来换 `Api3dRepository` 不动上层。
|
||||||
|
- **严格按需求,禁止砍功能/改需求**(教训:曾把 F25 砍掉、把双击正视改按钮,被用户纠正)。复刻不确定处必须实地学习(Playwright),禁猜测。
|
||||||
|
- 全部回复中文。
|
||||||
|
|
||||||
|
## 3. 权威文档
|
||||||
|
- spec:`docs/superpowers/specs/2026-06-15-vtk-3d-supplementary-design.md`(v2,已纳入架构评审 + web 实地分析;§4 是补充需求逐行映射表;§6 接口设计;§14 分期)。
|
||||||
|
- 计划:`plans/2026-06-15-vtk-3d-p1-revive-rendering.md`、`plans/2026-06-15-vtk-3d-p2-dataset3d-bar.md`、`plans/2026-06-16-vtk-3d-p3-slice-interaction.md`。
|
||||||
|
|
||||||
|
## 4. 已完成并经用户验收(commit 范围 faee28c..07f2f25)
|
||||||
|
- **P1 复活渲染**(`0f521c5`+`53ccdc0`):`VtkSceneController`(编排,异步)+`I3dSceneRepository`/`LocalSample3dRepository`+`I3dSceneView`/`VtkSceneView`+`Scene::addViewProp`(体绘制 vtkVolume 入场)。勾选对象→样本数据→渲染帘面/体素/地形。
|
||||||
|
- **P2 三维数据集栏**(`3dea339`+样式 `73deb2b`/`86e0772`):坐标轴(标准/三维立体/不显示 vtkCubeAxesActor)、刻度(无/米/英尺/经纬度,GeoLocalFrame::toLatLon)、水平/垂直比例滑块、快捷视图6向、Zoom(In/Out/Fit)。右上工具条浮层(仅三维显示)。
|
||||||
|
- **P3 切片交互**(`85d4ff5`..`07f2f25`):`src/render/interact/`(SlicePlaneMath/SliceTool/PickInteractorStyle/InteractionManager)。**已验收**:
|
||||||
|
- 上下/前后/左右切片=固定角度可移动(G22-24);任意切片=拖边缘旋转(F25)+拖中间移动。
|
||||||
|
- 触碰切片→选中+**亮青边框**高亮(未选暗灰)。
|
||||||
|
- **双击切片→正视**(D40,靠 widget StartInteractionEvent 350ms 双击判定)。
|
||||||
|
- 滚轮→推进**选中**切片(D46);关闭→移除选中;翻转(E55)。
|
||||||
|
- **D39 以选中切片为中心旋转视图**:`PickInteractorStyle::Rotate()` 自定义——按下不动相机、拖动时绕选中切片中心(getRotateCenter)增量旋转整个相机(T(c)·R(up)·R(right)·T(-c))→不跳。
|
||||||
|
- **构建基建修复**(重要,见 §7):`build.bat` vswhere/ASCII/加 `rebuild`;`vcpkg.json` 加 `builtin-baseline`。
|
||||||
|
- ctest 全绿 **221/221**。
|
||||||
|
|
||||||
|
## 5. 当前 UI 是"过渡态",**不是 spec A1 的三栏**(已与用户讲明,属"功能先行、结构后做"的有意分期)
|
||||||
|
现状 = 旧「二维地图/三维视图」切换 + 三个浮层/工具条:
|
||||||
|
- 左上「视图详情」(layerPanel):帘面/体素/地形 复选框;
|
||||||
|
- 右上「三维数据集栏」(axisBar):坐标轴/刻度/比例/快捷视图/Zoom;
|
||||||
|
- 左下「切片」(sliceBar):上下/前后/左右/任意/翻转/关闭。
|
||||||
|
渲染由 LocalSample 样本驱动:对象树勾选任意 TM → 映射成样本 ds `"grid1"`(main.cpp checkedTmsChanged 处)。
|
||||||
|
|
||||||
|
## 6. 下一步(用户即将定方向;我已建议先做①)
|
||||||
|
**① 三栏结构重构(建议先做,A1 顶层框架)**:把界面重组为 三维数据集 / 二维数据集 / 三维分析 三栏,各栏含:
|
||||||
|
- 数据集列表(按 ds 维度 3D/2D 过滤勾选对象的 ds,C9/C15;维度映射见 `I3dSceneRepository::dimensionOf`);
|
||||||
|
- 三维分析栏的树(按 对象/三维体模型/切片 结构,C19);
|
||||||
|
- **右键菜单创建切片**(D21/F22-25:右键三维体→上下/前后/左右/任意切片;现在是用左下浮层按钮代替,须改成右键)。
|
||||||
|
**② P4 功能**(结构之后或并行):
|
||||||
|
- 切片 CRUD:保存/保存为/导出图片/导出dat/删除为数据集(F30-33/D47/E51-53)——`I3dSceneRepository` 已留 SliceSpec/createSlice 等接口位(spec §6.3),本轮内存态。
|
||||||
|
- 创建异常+异常体管理(D48/E49-50/A69-C88):异常=切片面上的 2D 多边形(复用 core::Anomaly);异常体树/删除/属性/VTK↔列表联动/显示过滤/截图属性。
|
||||||
|
- 三维体/切片详情(A58-C67):源数据/切片/异常/插值模型(IDW·克里金)/参数/色阶/测量(点数·体积);切片详情参照 dd_section。
|
||||||
|
- 任务管理(A90-C92):任务记录 + 可使用任务列表(按 ds 类型过滤 model/list)。
|
||||||
|
**③ P5 二维数据集栏**:底图(天地图/Google/隐藏)+2D视图位置(关闭/Z=0/顶部/底部/自定义Z)。VTK 瓦片层+EPSG:3857→GeoLocalFrame 配准(复用 TerrainActor 流程)。
|
||||||
|
- 待精修(需 Geopro **1.0** 实地参考,目前无):F26 色阶"参考1.0"、F50 异常保存框"参考1.0"。
|
||||||
|
|
||||||
|
## 7. ⚠️ 构建/验证铁律(务必遵守,否则重蹈本会话覆辙)
|
||||||
|
- 构建用 `build.bat`(已修好)。从 Git Bash 调:`cmd.exe /c "chcp 65001 >nul && cd /d D:\Git\lanbingtech\geopro && .\build.bat test"`。命令:`app`/`run`/`test`/`rebuild`(--clean-first 强制全量重编+启动)/`configure`。
|
||||||
|
- **ninja 增量偶发漏编** → 改了代码却"看不到效果"时,用 `build.bat rebuild` 或 `touch` 改过的源再编;验 exe 新鲜:`stat -c '%y' build/release/src/app/geopro_desktop.exe`。
|
||||||
|
- **切勿 `rm -rf build/release`**(vcpkg.json 虽已加 baseline,但重配会从源码重编 openssl/gdal/proj,慢;增量链接错用 `--clean-first`,别删目录)。
|
||||||
|
- **.bat 必须纯 ASCII**(中文 Windows cmd 按 GBK 解析 .bat,UTF-8 中文注释会让解析崩)。
|
||||||
|
- **Claude 工具跑 build 会间歇被一个 `Start-Process 'C:\Users\corey\...'` 钩子劫持**(环境问题、非项目;只影响 Claude 工具,不影响用户终端)→ 我的构建验证有时静默没跑,会误判"已更新";**交互类改动必须让用户在其终端 `build.bat rebuild` 实测**。
|
||||||
|
- **Claude 无法 GUI 测试**:VTK 交互(切片/旋转/拾取)的正确性只能靠用户实测——纯逻辑(几何/相机数学)抽成单测,widget 行为靠目视。
|
||||||
|
- app 启动需登录(真实 API,tenant.geomative.cn);勾"记住登录(30天)"可免登。
|
||||||
|
- 详见记忆 [[build-vs2026-vcpkg-gotchas]]、[[build-run-verify-gotchas]]、[[build-ninja-stale-shared-header]]。
|
||||||
|
|
||||||
|
## 8. 代码地图
|
||||||
|
- `src/render/actors/`:Scatter/GridContour/Voxel(GPU体绘制)/Anomaly(2D)/Terrain/Curtain/MapLine/Electrode/**AxesActor**(P2)。
|
||||||
|
- `src/render/`:Scene(addActor/addViewProp/clear=RemoveAllViewProps)、CameraPreset(Top2D/Free3D/applyView6向/zoomBy/fitView)、VoxelFromScatters、ColorLutBuilder、ContourBands。
|
||||||
|
- `src/render/interact/`(P3):SlicePlaneMath(纯几何,有单测)、SliceTool(封 vtkImagePlaneWidget;轴向 SetPlaneOrientationTo*+MarginSize0 禁旋转;任意 Origin/Pt1/Pt2 45°可旋转;SetLeftButtonAction(SLICE_MOTION);onInteract 选中回调)、PickInteractorStyle(自定义 Rotate 绕支点+滚轮+手动双击)、InteractionManager(切片增删/选中/滚轮/翻转/双击正视/getRotateCenter)。
|
||||||
|
- `src/controller/`:VtkSceneController(QObject 编排,异步回调 QPointer+generation 守护)、I3dSceneView(抽象,解耦 VTK)。
|
||||||
|
- `src/data/repo/`:I3dSceneRepository(异步接口:dimensionOf/loadVolume(VolumeGrid)/loadTerrainPaths,切片/异常/任务签名留位)、LocalSample3dRepository、IDatasetRepository、LocalSampleRepository。
|
||||||
|
- `src/app/`:VtkSceneView(I3dSceneView 实现)、main.cpp(buildWorkbench 内全部接线 + 三个浮层/工具条 + InteractionManager 创建于 ~309)。
|
||||||
|
- 测试:`tests/render/`(test_scene/test_camera_preset/test_axes/test_slice_plane_math)、`tests/data/`(test_3d_repo)、`tests/controller/`(test_vtk_scene_controller)。
|
||||||
|
|
||||||
|
## 9. 工作方式(用户偏好)
|
||||||
|
- 派 opus subagent 实现 → 完成后**我亲自**独立验证构建/测试 + 派 cpp-reviewer 审查 + 查 code 与 spec 符合度 → 修 CRITICAL/HIGH。
|
||||||
|
- 不要嘴上保证"编进去了"——用证据(ctest 输出、exe mtime、读改动文件)说话。
|
||||||
|
- 抠准每条需求(本会话因没抠准 G22-24/F25/D40 反复返工,务必先逐条读「补充需求」对应行)。
|
||||||
|
|
@ -0,0 +1,495 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>三栏结构重构 · 高保真原型对比</title>
|
||||||
|
<style>
|
||||||
|
/* ===== 真实 app 深色令牌(src/app/Theme.cpp dark 列) ===== */
|
||||||
|
:root{
|
||||||
|
--bg-app:#0E1116; --bg-panel:#161A20; --bg-panel-subtle:#161B22; --bg-header:#12161C;
|
||||||
|
--bg-hover:#1B2129; --bg-selected:#16243F;
|
||||||
|
--border:#262C35; --border-strong:#333B45;
|
||||||
|
--text:#E6E9EF; --text-2:#A4ADBB; --text-3:#7A8494; --text-dis:#5A626F;
|
||||||
|
--accent:#5E8DF5; --accent-h:#93B4FA;
|
||||||
|
--canvas-bg:#0B1320; --canvas-soft:#111B2D; --canvas-grid:#1E2A3D;
|
||||||
|
--canvas-text:#E6ECF5; --canvas-dim:#8A97AC;
|
||||||
|
--danger:#FF6166; --warn:#F5A623; --ok:#46C07A;
|
||||||
|
--r:6px;
|
||||||
|
}
|
||||||
|
*{box-sizing:border-box;margin:0;padding:0;}
|
||||||
|
html,body{height:100%;}
|
||||||
|
body{
|
||||||
|
font-family:"Microsoft YaHei UI","Segoe UI",system-ui,sans-serif;
|
||||||
|
background:#05080d;color:var(--text);font-size:13px;
|
||||||
|
display:flex;flex-direction:column;height:100vh;overflow:hidden;
|
||||||
|
}
|
||||||
|
/* ===== 顶部方案切换条(原型控制,非 app 一部分) ===== */
|
||||||
|
.meta{
|
||||||
|
display:flex;align-items:center;gap:14px;padding:10px 16px;
|
||||||
|
background:#11151c;border-bottom:1px solid var(--border);flex:0 0 auto;
|
||||||
|
}
|
||||||
|
.meta .lbl{color:var(--text-3);font-size:12px;}
|
||||||
|
.meta .opts{display:flex;gap:8px;}
|
||||||
|
.meta button{
|
||||||
|
font:inherit;font-size:12.5px;color:var(--text-2);background:var(--bg-panel);
|
||||||
|
border:1px solid var(--border);border-radius:20px;padding:6px 16px;cursor:pointer;
|
||||||
|
transition:.12s;
|
||||||
|
}
|
||||||
|
.meta button:hover{border-color:var(--accent);color:var(--text);}
|
||||||
|
.meta button.on{background:var(--accent);border-color:var(--accent);color:#fff;font-weight:600;}
|
||||||
|
.meta .tag{margin-left:auto;font-size:11.5px;color:var(--text-3);}
|
||||||
|
.meta .tag b{color:var(--ok);}
|
||||||
|
|
||||||
|
/* ===== app 外壳 ===== */
|
||||||
|
.app{flex:1 1 auto;display:flex;flex-direction:column;min-height:0;background:var(--bg-app);}
|
||||||
|
/* TopBar */
|
||||||
|
.topbar{
|
||||||
|
height:46px;flex:0 0 auto;display:flex;align-items:center;gap:14px;padding:0 14px;
|
||||||
|
background:var(--bg-header);border-bottom:1px solid var(--border);
|
||||||
|
}
|
||||||
|
.topbar .logo{width:24px;height:24px;border-radius:6px;background:linear-gradient(135deg,var(--accent),#3B73EC);}
|
||||||
|
.topbar .ws{font-weight:600;color:var(--text);}
|
||||||
|
.topbar .ws small{color:var(--text-3);font-weight:400;margin-left:6px;}
|
||||||
|
.topbar .spacer{flex:1;}
|
||||||
|
.topbar .ico{width:30px;height:30px;border-radius:6px;display:grid;place-items:center;color:var(--text-2);}
|
||||||
|
.topbar .ico:hover{background:var(--bg-hover);}
|
||||||
|
.topbar .avatar{width:30px;height:30px;border-radius:50%;background:var(--accent);color:#fff;display:grid;place-items:center;font-size:12px;font-weight:700;}
|
||||||
|
|
||||||
|
/* dock 网格 */
|
||||||
|
.dockgrid{flex:1 1 auto;display:grid;gap:6px;padding:6px;min-height:0;}
|
||||||
|
/* 默认(A/B):左 / 中 / 右 三列 */
|
||||||
|
.dockgrid.cols{grid-template-columns:288px 1fr 248px;grid-template-rows:1fr;}
|
||||||
|
.col{display:flex;flex-direction:column;gap:6px;min-height:0;min-width:0;}
|
||||||
|
|
||||||
|
/* dock 面板 */
|
||||||
|
.dock{
|
||||||
|
background:var(--bg-panel);border:1px solid var(--border);border-radius:var(--r);
|
||||||
|
display:flex;flex-direction:column;min-height:0;overflow:hidden;
|
||||||
|
}
|
||||||
|
.dock-hd{
|
||||||
|
height:30px;flex:0 0 auto;display:flex;align-items:center;gap:6px;padding:0 10px;
|
||||||
|
background:var(--bg-header);border-bottom:1px solid var(--border);
|
||||||
|
font-size:12.5px;font-weight:600;color:var(--text-2);
|
||||||
|
}
|
||||||
|
.dock-hd .badge{margin-left:auto;font-size:10.5px;font-weight:600;color:var(--text-3);
|
||||||
|
background:var(--bg-hover);border-radius:9px;padding:1px 7px;}
|
||||||
|
.dock-bd{flex:1 1 auto;overflow:auto;padding:8px 10px;min-height:0;}
|
||||||
|
.dock.flex1{flex:1 1 auto;}
|
||||||
|
.dock.flexN{flex:0 0 auto;}
|
||||||
|
|
||||||
|
/* 对象树 / 列表项 */
|
||||||
|
.tree{list-style:none;}
|
||||||
|
.tree li{padding:4px 2px;color:var(--text);white-space:nowrap;border-radius:4px;}
|
||||||
|
.tree li:hover{background:var(--bg-hover);}
|
||||||
|
.tree li.sel{background:var(--bg-selected);}
|
||||||
|
.tree .ind1{padding-left:18px;}
|
||||||
|
.tree .ind2{padding-left:34px;}
|
||||||
|
.tree .ind3{padding-left:50px;}
|
||||||
|
.ck{display:inline-block;width:13px;height:13px;border:1px solid var(--border-strong);border-radius:3px;
|
||||||
|
vertical-align:-2px;margin-right:7px;position:relative;background:var(--canvas-bg);}
|
||||||
|
.ck.on{background:var(--accent);border-color:var(--accent);}
|
||||||
|
.ck.on::after{content:"";position:absolute;left:4px;top:1px;width:3px;height:7px;border:solid #fff;border-width:0 2px 2px 0;transform:rotate(45deg);}
|
||||||
|
.tw{color:var(--text-3);margin-right:4px;font-size:10px;}
|
||||||
|
.muted{color:var(--text-3);}
|
||||||
|
.pill{font-size:10px;color:var(--accent-h);border:1px solid var(--canvas-grid);border-radius:8px;padding:0 6px;margin-left:6px;}
|
||||||
|
|
||||||
|
/* 工具条 */
|
||||||
|
.toolbar{display:flex;flex-wrap:wrap;gap:6px;align-items:center;padding:8px 10px;
|
||||||
|
border-bottom:1px solid var(--border);background:var(--bg-panel-subtle);}
|
||||||
|
.toolbar.canvas{background:var(--canvas-soft);border-color:var(--canvas-grid);}
|
||||||
|
select.mini,.btnm{
|
||||||
|
font:inherit;font-size:11.5px;color:var(--text);background:var(--canvas-bg);
|
||||||
|
border:1px solid var(--canvas-grid);border-radius:4px;padding:3px 7px;cursor:pointer;
|
||||||
|
}
|
||||||
|
.btnm:hover{background:var(--bg-hover);border-color:var(--accent);}
|
||||||
|
.grp{display:flex;gap:3px;flex-wrap:wrap;}
|
||||||
|
.grp .btnm{padding:3px 8px;}
|
||||||
|
/* 工具条分组栏位 */
|
||||||
|
.toolbar.col{flex-direction:column;align-items:stretch;gap:0;}
|
||||||
|
.tgrp{display:flex;flex-wrap:wrap;align-items:center;gap:5px;padding:6px 0;border-bottom:1px dashed var(--canvas-grid);}
|
||||||
|
.tgrp:last-child{border-bottom:none;}
|
||||||
|
.tgrp .glbl{width:100%;font-size:11px;font-weight:600;color:var(--canvas-dim);margin-bottom:2px;}
|
||||||
|
.flbl{font-size:11px;color:var(--canvas-dim);display:inline-flex;align-items:center;gap:2px;}
|
||||||
|
.zin{font:inherit;font-size:11px;width:52px;color:var(--text);background:var(--canvas-bg);
|
||||||
|
border:1px solid var(--canvas-grid);border-radius:4px;padding:2px 5px;}
|
||||||
|
/* 表单行:标签固定宽 + 控件填满,不再流式折行 */
|
||||||
|
.frow{display:flex;align-items:center;gap:8px;width:100%;}
|
||||||
|
.frow .flbl{width:58px;flex:0 0 auto;}
|
||||||
|
.frow select.mini{width:132px;flex:0 0 auto;}
|
||||||
|
.frow .track{flex:1;}
|
||||||
|
.sval{font-size:11px;color:var(--canvas-text);width:36px;text-align:right;flex:0 0 auto;}
|
||||||
|
/* 滑块:独立类,不再依赖 .slider 父级(之前那样导致 track 0 高度看不见) */
|
||||||
|
.track{height:5px;background:var(--canvas-grid);border-radius:3px;position:relative;min-width:80px;}
|
||||||
|
.track .knob{position:absolute;top:-4px;width:13px;height:13px;border-radius:50%;
|
||||||
|
background:var(--accent);box-shadow:0 0 0 3px rgba(94,141,245,.22);cursor:pointer;}
|
||||||
|
.track .fill{position:absolute;left:0;top:0;bottom:0;background:var(--accent);border-radius:3px;opacity:.55;}
|
||||||
|
|
||||||
|
/* VTK 视图区 */
|
||||||
|
.vtk{
|
||||||
|
flex:1 1 auto;position:relative;min-height:0;border-radius:var(--r);overflow:hidden;
|
||||||
|
background:radial-gradient(120% 120% at 50% 18%,#16243f 0%,var(--canvas-bg) 60%);
|
||||||
|
border:1px solid var(--border);
|
||||||
|
}
|
||||||
|
.vtk .hd{position:absolute;top:0;left:0;right:0;height:30px;display:flex;align-items:center;padding:0 10px;
|
||||||
|
background:rgba(18,22,28,.72);border-bottom:1px solid var(--canvas-grid);font-size:12px;color:var(--text-2);z-index:4;backdrop-filter:blur(2px);}
|
||||||
|
/* 全屏按钮(VTK视图 + 数据详情 标题栏右侧) */
|
||||||
|
.fsbtn{margin-left:auto;width:22px;height:20px;display:grid;place-items:center;cursor:pointer;
|
||||||
|
border-radius:4px;color:var(--text-3);font-size:13px;}
|
||||||
|
.fsbtn:hover{background:var(--bg-hover);color:var(--accent);}
|
||||||
|
.fs-on{position:fixed !important;inset:0;z-index:90;border-radius:0;width:auto !important;height:auto !important;}
|
||||||
|
/* 假三维:两片帘面 + 体素盒 + 切片 */
|
||||||
|
.scene{position:absolute;inset:30px 0 0 0;perspective:900px;display:grid;place-items:center;}
|
||||||
|
.stage{transform-style:preserve-3d;transform:rotateX(60deg) rotateZ(-28deg);}
|
||||||
|
.curtain{position:absolute;width:230px;height:120px;left:-115px;top:-60px;
|
||||||
|
background:linear-gradient(90deg,#2a4a8f,#3aa0c0 35%,#7ec96f 60%,#e8c84a 78%,#d9603a);
|
||||||
|
opacity:.92;border:1px solid rgba(255,255,255,.18);box-shadow:0 0 24px rgba(94,141,245,.25);}
|
||||||
|
.curtain.b{transform:rotateZ(90deg) translateZ(0);opacity:.78;}
|
||||||
|
.axes{position:absolute;left:-130px;top:70px;width:0;height:0;}
|
||||||
|
.axes i{position:absolute;height:2px;transform-origin:left center;}
|
||||||
|
.ax-x{width:150px;background:#e5605f;}
|
||||||
|
.ax-y{width:120px;background:#46c07a;transform:rotate(-90deg);}
|
||||||
|
.grid-floor{position:absolute;width:300px;height:300px;left:-150px;top:-150px;
|
||||||
|
background-image:linear-gradient(var(--canvas-grid) 1px,transparent 1px),linear-gradient(90deg,var(--canvas-grid) 1px,transparent 1px);
|
||||||
|
background-size:30px 30px;opacity:.35;transform:translateZ(-2px);}
|
||||||
|
.slice3d{position:absolute;width:160px;height:90px;left:-80px;top:-45px;
|
||||||
|
background:repeating-linear-gradient(45deg,rgba(94,141,245,.35) 0 8px,rgba(94,141,245,.12) 8px 16px);
|
||||||
|
border:2px solid var(--accent);transform:rotateY(0deg) rotateZ(28deg) translateZ(40px);box-shadow:0 0 18px rgba(94,141,245,.4);}
|
||||||
|
.legend{position:absolute;right:12px;bottom:12px;width:14px;height:96px;border-radius:3px;
|
||||||
|
background:linear-gradient(#d9603a,#e8c84a,#7ec96f,#3aa0c0,#2a4a8f);border:1px solid var(--canvas-grid);z-index:3;}
|
||||||
|
.legend::after{content:"Ω·m";position:absolute;left:-24px;top:40px;font-size:10px;color:var(--canvas-dim);}
|
||||||
|
|
||||||
|
/* tabs(方案 A) */
|
||||||
|
.tabbar{display:flex;gap:2px;border-bottom:1px solid var(--border);background:var(--bg-header);flex:0 0 auto;}
|
||||||
|
.tabbar .tab{padding:7px 14px;font-size:12.5px;color:var(--text-3);cursor:pointer;border-bottom:2px solid transparent;}
|
||||||
|
.tabbar .tab:hover{color:var(--text);}
|
||||||
|
.tabbar .tab.on{color:var(--accent);border-bottom-color:var(--accent);font-weight:600;}
|
||||||
|
.tabpane{display:none;flex-direction:column;min-height:0;flex:1 1 auto;}
|
||||||
|
.tabpane.on{display:flex;}
|
||||||
|
|
||||||
|
/* 折叠分段(方案 B) */
|
||||||
|
.section .sec-hd{display:flex;align-items:center;gap:6px;padding:7px 10px;cursor:pointer;
|
||||||
|
background:var(--bg-header);border-top:1px solid var(--border);font-weight:600;color:var(--text-2);font-size:12.5px;}
|
||||||
|
.section:first-child .sec-hd{border-top:none;}
|
||||||
|
.section .sec-hd .tw{font-size:11px;}
|
||||||
|
.section .sec-bd{padding:6px 10px 10px;}
|
||||||
|
|
||||||
|
/* 视图内嵌侧栏(方案 C 修正版):抽屉式,画布在右、不遮挡 */
|
||||||
|
.vtk.with-drawer .scene{left:var(--drawer-w,300px);transition:left .18s;}
|
||||||
|
.view-drawer{position:absolute;top:30px;left:0;bottom:0;width:300px;z-index:6;
|
||||||
|
background:rgba(17,27,45,.94);border-right:1px solid var(--canvas-grid);
|
||||||
|
display:flex;flex-direction:column;backdrop-filter:blur(3px);transition:width .18s;overflow:hidden;}
|
||||||
|
.view-drawer .tabbar{background:rgba(18,22,28,.55);}
|
||||||
|
.drawer-toggle{position:absolute;top:38px;z-index:7;left:300px;width:18px;height:46px;
|
||||||
|
background:rgba(17,27,45,.94);border:1px solid var(--canvas-grid);border-left:none;
|
||||||
|
border-radius:0 6px 6px 0;display:grid;place-items:center;color:var(--canvas-dim);cursor:pointer;
|
||||||
|
font-size:11px;transition:left .18s;}
|
||||||
|
.drawer-toggle:hover{color:var(--accent);}
|
||||||
|
.vtk.drawer-collapsed .view-drawer{width:0;border-right:none;}
|
||||||
|
.vtk.drawer-collapsed .scene{left:0;}
|
||||||
|
.vtk.drawer-collapsed .drawer-toggle{left:0;}
|
||||||
|
|
||||||
|
/* 右键菜单 */
|
||||||
|
.ctx{position:absolute;z-index:30;min-width:150px;background:var(--bg-panel);border:1px solid var(--border-strong);
|
||||||
|
border-radius:6px;box-shadow:0 10px 30px rgba(0,0,0,.6);padding:4px;display:none;}
|
||||||
|
.ctx.show{display:block;}
|
||||||
|
.ctx .it{padding:6px 12px;border-radius:4px;font-size:12.5px;color:var(--text);cursor:pointer;white-space:nowrap;}
|
||||||
|
.ctx .it:hover{background:var(--accent);color:#fff;}
|
||||||
|
.ctx .sep{height:1px;background:var(--border);margin:4px 6px;}
|
||||||
|
.ctx .it.sub::after{content:"▸";float:right;color:var(--text-3);margin-left:18px;}
|
||||||
|
|
||||||
|
.hint{font-size:11px;color:var(--text-3);padding:6px 10px;border-top:1px dashed var(--border);}
|
||||||
|
.note{font-size:11.5px;color:var(--warn);background:rgba(245,166,35,.08);border:1px solid rgba(245,166,35,.25);
|
||||||
|
border-radius:5px;padding:6px 9px;margin:8px 10px;}
|
||||||
|
kbd{background:var(--bg-hover);border:1px solid var(--border-strong);border-radius:3px;padding:0 5px;font-size:11px;}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="meta">
|
||||||
|
<span class="lbl">三栏结构重构 · 布局方案</span>
|
||||||
|
<div class="opts">
|
||||||
|
<button data-opt="C" class="on">方案 C · 视图内嵌侧栏(修正·推荐)</button>
|
||||||
|
<button data-opt="A">方案 A · 左侧独立 dock</button>
|
||||||
|
<button data-opt="B">方案 B · 竖向分段 dock</button>
|
||||||
|
</div>
|
||||||
|
<span class="tag">配色取自 <b>Theme.cpp</b> 深色令牌 · 右键「三维分析」树里的三维体试试创建切片</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============ APP 外壳 ============ -->
|
||||||
|
<div class="app">
|
||||||
|
<div class="topbar">
|
||||||
|
<div class="logo"></div>
|
||||||
|
<div class="ws">地大演示项目 <small>· 工作区</small></div>
|
||||||
|
<div class="spacer" style="flex:1"></div>
|
||||||
|
<div class="ico">⌗</div><div class="ico">⚙</div>
|
||||||
|
<div class="avatar">GZ</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dockgrid cols" id="grid">
|
||||||
|
<!-- 左列:内容随方案变 -->
|
||||||
|
<div class="col" id="leftcol"></div>
|
||||||
|
|
||||||
|
<!-- 中列:VTK + 详情 -->
|
||||||
|
<div class="col" style="min-width:0">
|
||||||
|
<div class="dock flex1" style="padding:0;border:none;background:transparent">
|
||||||
|
<div class="vtk" id="vtk">
|
||||||
|
<div class="hd">VTK视图 · 地大演示项目<span class="fsbtn" data-fs title="全屏 / 还原">⛶</span></div>
|
||||||
|
<div class="scene"><div class="stage">
|
||||||
|
<div class="grid-floor"></div>
|
||||||
|
<div class="curtain"></div>
|
||||||
|
<div class="curtain b"></div>
|
||||||
|
<div class="slice3d"></div>
|
||||||
|
<div class="axes"><i class="ax-x"></i><i class="ax-y"></i></div>
|
||||||
|
</div></div>
|
||||||
|
<div class="legend"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="dock flexN" style="height:120px">
|
||||||
|
<div class="dock-hd">数据详情<span class="fsbtn" data-fs title="全屏 / 还原">⛶</span></div>
|
||||||
|
<div class="dock-bd muted" style="font-size:12px">选中数据集查看详情(源数据 / 切片 / 异常 / 插值模型 / 色阶 / 测量)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 右列:异常/属性 -->
|
||||||
|
<div class="col">
|
||||||
|
<div class="dock flex1">
|
||||||
|
<div class="dock-hd">异常 / 对象属性</div>
|
||||||
|
<div class="dock-bd">
|
||||||
|
<ul class="tree">
|
||||||
|
<li><span class="tw">▾</span>异常体 A <span class="pill">随GS</span></li>
|
||||||
|
<li class="ind1"><span class="tw">▾</span>分组-1</li>
|
||||||
|
<li class="ind2 muted">异常 #1</li>
|
||||||
|
<li class="ind2 muted">异常 #2</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="dock flexN" style="height:150px">
|
||||||
|
<div class="dock-hd">数据集属性</div>
|
||||||
|
<div class="dock-bd muted" style="font-size:12px">名称 / 类型 / 维度 / 创建时间…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 三维体数据集 右键菜单(原文行21-27:切片/色阶/显隐/详情,无删除) -->
|
||||||
|
<div class="ctx" id="ctx">
|
||||||
|
<div class="it sub" data-act="slice">切片</div>
|
||||||
|
<div class="it">色阶</div>
|
||||||
|
<div class="it">显示 / 隐藏</div>
|
||||||
|
<div class="it">数据详情</div>
|
||||||
|
</div>
|
||||||
|
<div class="ctx" id="ctxSub">
|
||||||
|
<div class="it">上下</div>
|
||||||
|
<div class="it">前后</div>
|
||||||
|
<div class="it">左右</div>
|
||||||
|
<div class="it">任意</div>
|
||||||
|
</div>
|
||||||
|
<!-- 切片数据集 右键菜单(原文行29-35:保存/保存为/导出/删除/色阶/显隐/详情) -->
|
||||||
|
<div class="ctx" id="ctxSlice">
|
||||||
|
<div class="it">保存</div>
|
||||||
|
<div class="it">保存为</div>
|
||||||
|
<div class="it">导出</div>
|
||||||
|
<div class="it" style="color:var(--danger)">删除</div>
|
||||||
|
<div class="sep"></div>
|
||||||
|
<div class="it">色阶</div>
|
||||||
|
<div class="it">显示 / 隐藏</div>
|
||||||
|
<div class="it">数据详情</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ===== 三栏内容片段 =====
|
||||||
|
function dataset3DList(){return `
|
||||||
|
<ul class="tree">
|
||||||
|
<li><span class="ck on"></span>反演剖面-L1 <span class="pill">帘面</span></li>
|
||||||
|
<li><span class="ck on"></span>反演剖面-L2 <span class="pill">帘面</span></li>
|
||||||
|
<li><span class="ck"></span>体素模型-V1 <span class="pill">dd_voxel</span></li>
|
||||||
|
<li><span class="ck"></span>地形 DEM+影像</li>
|
||||||
|
</ul>`;}
|
||||||
|
function dataset2DList(){return `
|
||||||
|
<ul class="tree">
|
||||||
|
<li><span class="ck on"></span>测线-T1 <span class="pill">俯视</span></li>
|
||||||
|
<li><span class="ck"></span>轨迹-Tr1 <span class="pill">trajectory</span></li>
|
||||||
|
</ul>`;}
|
||||||
|
function analysisTree(){return `
|
||||||
|
<ul class="tree" id="anaTree">
|
||||||
|
<li><span class="ck on"></span><span class="tw">▾</span>GS-地大演示</li>
|
||||||
|
<li class="ind1" data-vol="1"><span class="ck on"></span><span class="tw">▾</span>三维体模型-V1 <span class="pill">右键</span></li>
|
||||||
|
<li class="ind2" data-slice="1"><span class="ck on"></span><span class="tw">▸</span>切片·上下-01 <span class="pill">右键</span></li>
|
||||||
|
<li class="ind2" data-slice="1"><span class="ck"></span><span class="tw">▸</span>切片·任意-02</li>
|
||||||
|
<li class="ind1" data-vol="1"><span class="ck"></span><span class="tw">▸</span>三维体模型-V2</li>
|
||||||
|
</ul>
|
||||||
|
<div class="hint">右键<b>三维体</b>→切片▸(上下/前后/左右/任意)·色阶·显隐·详情;右键<b>切片</b>→保存/保存为/导出/删除·色阶·显隐·详情。</div>`;}
|
||||||
|
|
||||||
|
function toolbar3D(canvas){return `
|
||||||
|
<div class="toolbar col ${canvas?'canvas':''}">
|
||||||
|
<div class="tgrp"><span class="glbl">坐标轴设置</span>
|
||||||
|
<div class="frow"><span class="flbl">显示方式</span><select class="mini"><option>标准</option><option>三维立体</option><option>不显示</option></select></div>
|
||||||
|
<div class="frow"><span class="flbl">O点位置</span><span class="btnm">设置…</span></div>
|
||||||
|
<div class="frow"><span class="flbl">刻度</span><select class="mini"><option>无刻度</option><option selected>米</option><option>英尺</option><option>经纬度</option></select></div>
|
||||||
|
<div class="frow"><span class="flbl">字体</span><span class="btnm">设置…</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="tgrp"><span class="glbl">水平/垂直比例</span>
|
||||||
|
<div class="frow"><span class="track"><span class="fill" style="width:24%"></span><span class="knob" style="left:24%"></span></span><span class="sval">2.0×</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="tgrp"><span class="glbl">快捷视图</span>
|
||||||
|
<span class="grp"><span class="btnm">前</span><span class="btnm">后</span><span class="btnm">左</span><span class="btnm">右</span><span class="btnm">上</span><span class="btnm">下</span></span>
|
||||||
|
</div>
|
||||||
|
<div class="tgrp"><span class="glbl">缩放 (Zoom)</span>
|
||||||
|
<span class="grp"><span class="btnm">放大</span><span class="btnm">缩小</span><span class="btnm">适配</span></span>
|
||||||
|
</div>
|
||||||
|
</div>`;}
|
||||||
|
function toolbar2D(canvas){return `
|
||||||
|
<div class="toolbar col ${canvas?'canvas':''}">
|
||||||
|
<div class="tgrp"><span class="glbl">地图</span>
|
||||||
|
<div class="frow"><span class="flbl">底图源</span><select class="mini"><option>天地图</option><option>Google Map</option><option>隐藏</option></select></div>
|
||||||
|
</div>
|
||||||
|
<div class="tgrp"><span class="glbl">2D视图</span>
|
||||||
|
<div class="frow"><span class="flbl">位置</span><select class="mini" onchange="document.getElementById('zwrap').style.display=this.value==='自定义'?'inline-flex':'none'">
|
||||||
|
<option>关闭</option><option selected>Z=0</option><option>顶部</option><option>底部</option><option>自定义</option></select></div>
|
||||||
|
<div class="frow" id="zwrap" style="display:none"><span class="flbl">Z 值</span><input class="zin" type="number" value="0"><span class="flbl">m</span></div>
|
||||||
|
</div>
|
||||||
|
</div>`;}
|
||||||
|
|
||||||
|
// 对象树 dock(A/B/C 都有,作为勾选源)
|
||||||
|
function objectDock(){return `
|
||||||
|
<div class="dock flexN" style="height:170px">
|
||||||
|
<div class="dock-hd">对象 <span class="badge">勾选源</span></div>
|
||||||
|
<div class="dock-bd">
|
||||||
|
<ul class="tree">
|
||||||
|
<li class="sel"><span class="ck on"></span><span class="tw">▾</span>GS-地大演示</li>
|
||||||
|
<li class="ind1"><span class="ck on"></span>TM-反演成果</li>
|
||||||
|
<li class="ind1"><span class="ck"></span>TM-轨迹</li>
|
||||||
|
<li><span class="ck"></span><span class="tw">▸</span>GS-威立雅</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>`;}
|
||||||
|
|
||||||
|
// ===== 三种方案的左列 =====
|
||||||
|
function buildA(){ // Tab 切换
|
||||||
|
return objectDock() + `
|
||||||
|
<div class="dock flex1">
|
||||||
|
<div class="tabbar" id="tabbarA">
|
||||||
|
<div class="tab on" data-t="0">三维数据集</div>
|
||||||
|
<div class="tab" data-t="1">二维数据集</div>
|
||||||
|
<div class="tab" data-t="2">三维分析</div>
|
||||||
|
</div>
|
||||||
|
<div class="tabpane on">${toolbar3D(false)}<div class="dock-bd">${dataset3DList()}</div></div>
|
||||||
|
<div class="tabpane">${toolbar2D()}<div class="dock-bd">${dataset2DList()}</div></div>
|
||||||
|
<div class="tabpane"><div class="dock-bd">${analysisTree()}</div></div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
function buildB(){ // 竖向分段
|
||||||
|
return objectDock() + `
|
||||||
|
<div class="dock flex1">
|
||||||
|
<div class="dock-hd">数据集 / 分析 <span class="badge">全可见</span></div>
|
||||||
|
<div class="dock-bd" style="padding:0">
|
||||||
|
<div class="section">
|
||||||
|
<div class="sec-hd"><span class="tw">▾</span>三维数据集</div>
|
||||||
|
<div class="sec-bd">${toolbar3D(false)}${dataset3DList()}</div>
|
||||||
|
</div>
|
||||||
|
<div class="section">
|
||||||
|
<div class="sec-hd"><span class="tw">▾</span>二维数据集</div>
|
||||||
|
<div class="sec-bd">${toolbar2D()}${dataset2DList()}</div>
|
||||||
|
</div>
|
||||||
|
<div class="section">
|
||||||
|
<div class="sec-hd"><span class="tw">▾</span>三维分析</div>
|
||||||
|
<div class="sec-bd">${analysisTree()}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
function buildC(){ // 视图内嵌侧栏(修正):左列保留对象列表(三栏的筛选来源)+ 详情
|
||||||
|
return objectDock() + `
|
||||||
|
<div class="dock flex1">
|
||||||
|
<div class="dock-hd">数据集(详情查看)</div>
|
||||||
|
<div class="dock-bd">
|
||||||
|
<ul class="tree">
|
||||||
|
<li class="muted">反演剖面-L1</li><li class="muted">体素模型-V1</li><li class="muted">测线-T1</li>
|
||||||
|
</ul>
|
||||||
|
<div class="note" style="color:var(--accent-h);background:rgba(94,141,245,.08);border-color:rgba(94,141,245,.3)">方案 C(修正):三个「子列表栏」内嵌在 VTK 视图左侧(抽屉式侧栏),三 tab 切换。画布在其右侧、不被遮挡;点侧栏右缘 <b>◀</b> 可折叠让画布全宽。这正是需求「VTK视图上提供三个子列表栏」的形态。左侧「对象」列表是三栏的筛选来源(需求:筛勾选对象中的 ds)。</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
function viewDrawerHTML(){return `
|
||||||
|
<div class="view-drawer">
|
||||||
|
<div class="tabbar">
|
||||||
|
<div class="tab on" data-t="0">三维数据集</div>
|
||||||
|
<div class="tab" data-t="1">二维数据集</div>
|
||||||
|
<div class="tab" data-t="2">三维分析</div>
|
||||||
|
</div>
|
||||||
|
<div class="tabpane on">${toolbar3D(true)}<div class="dock-bd">${dataset3DList()}</div></div>
|
||||||
|
<div class="tabpane">${toolbar2D(true)}<div class="dock-bd">${dataset2DList()}</div></div>
|
||||||
|
<div class="tabpane"><div class="dock-bd">${analysisTree()}</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="drawer-toggle" id="drawerToggle">◀</div>`;}
|
||||||
|
|
||||||
|
// ===== 渲染 =====
|
||||||
|
const grid=document.getElementById('grid');
|
||||||
|
const vtk=document.getElementById('vtk');
|
||||||
|
function render(opt){
|
||||||
|
document.querySelectorAll('.meta button').forEach(b=>b.classList.toggle('on',b.dataset.opt===opt));
|
||||||
|
const left=document.getElementById('leftcol');
|
||||||
|
// 清掉方案 C 的内嵌侧栏
|
||||||
|
vtk.classList.remove('with-drawer','drawer-collapsed');
|
||||||
|
vtk.querySelectorAll('.view-drawer,.drawer-toggle').forEach(e=>e.remove());
|
||||||
|
|
||||||
|
if(opt==='A'){ left.innerHTML=buildA(); wireTabs(); }
|
||||||
|
if(opt==='B'){ left.innerHTML=buildB(); wireSections(); }
|
||||||
|
if(opt==='C'){
|
||||||
|
left.innerHTML=buildC();
|
||||||
|
vtk.classList.add('with-drawer');
|
||||||
|
vtk.insertAdjacentHTML('beforeend', viewDrawerHTML());
|
||||||
|
wireTabs(vtk.querySelector('.view-drawer'));
|
||||||
|
const tg=document.getElementById('drawerToggle');
|
||||||
|
tg.onclick=()=>{const c=vtk.classList.toggle('drawer-collapsed');tg.textContent=c?'▶':'◀';};
|
||||||
|
}
|
||||||
|
wireCtx();
|
||||||
|
}
|
||||||
|
function wireTabs(scope){
|
||||||
|
(scope||document).querySelectorAll('.tabbar .tab').forEach(t=>{
|
||||||
|
t.onclick=()=>{
|
||||||
|
const bar=t.parentElement, panes=bar.parentElement.querySelectorAll(':scope > .tabpane');
|
||||||
|
bar.querySelectorAll('.tab').forEach(x=>x.classList.remove('on'));
|
||||||
|
t.classList.add('on');
|
||||||
|
panes.forEach((p,i)=>p.classList.toggle('on',i===+t.dataset.t));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function wireSections(){
|
||||||
|
document.querySelectorAll('.section .sec-hd').forEach(h=>{
|
||||||
|
h.onclick=()=>{const bd=h.nextElementSibling, tw=h.querySelector('.tw');
|
||||||
|
const open=bd.style.display!=='none'; bd.style.display=open?'none':''; tw.textContent=open?'▸':'▾';};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 右键菜单:三维体(ctx+ctxSub子菜单) / 切片(ctxSlice)
|
||||||
|
const ctx=document.getElementById('ctx'), ctxSub=document.getElementById('ctxSub'),
|
||||||
|
ctxSlice=document.getElementById('ctxSlice');
|
||||||
|
function popAt(menu,e){e.preventDefault();hideCtx();
|
||||||
|
menu.style.left=e.pageX+'px';menu.style.top=e.pageY+'px';menu.classList.add('show');}
|
||||||
|
function wireCtx(){
|
||||||
|
document.querySelectorAll('[data-vol]').forEach(li=>li.oncontextmenu=(e)=>popAt(ctx,e));
|
||||||
|
document.querySelectorAll('[data-slice]').forEach(li=>li.oncontextmenu=(e)=>popAt(ctxSlice,e));
|
||||||
|
ctx.querySelector('[data-act=slice]').onmouseenter=()=>{
|
||||||
|
const r=ctx.getBoundingClientRect();
|
||||||
|
ctxSub.style.left=r.right+'px';ctxSub.style.top=r.top+'px';ctxSub.classList.add('show');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function hideCtx(){[ctx,ctxSub,ctxSlice].forEach(m=>m.classList.remove('show'));}
|
||||||
|
document.addEventListener('click',hideCtx);
|
||||||
|
document.addEventListener('scroll',hideCtx,true);
|
||||||
|
|
||||||
|
// 全屏切换:VTK视图 / 数据详情
|
||||||
|
document.querySelectorAll('[data-fs]').forEach(b=>b.onclick=(e)=>{
|
||||||
|
e.stopPropagation();
|
||||||
|
const panel=b.closest('.vtk')||b.closest('.dock');
|
||||||
|
const on=panel.classList.toggle('fs-on');
|
||||||
|
b.textContent=on?'🗗':'⛶';
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.meta button').forEach(b=>b.onclick=()=>render(b.dataset.opt));
|
||||||
|
render('C');
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
# P3:三维分析·切片交互(核心)
|
||||||
|
|
||||||
|
- 日期:2026-06-16
|
||||||
|
- 分支:`feat/vtk-3d-view`
|
||||||
|
- 上游:spec `2026-06-15-vtk-3d-supplementary-design.md` §9(交互层)、§4 行 F22–F25/C38–D46/E54–E56;接 P1/P2(VtkSceneController/VtkSceneView/Scene/体素管线已就位)
|
||||||
|
- 目标:补充需求最重模块。在 P1 的 LocalSample 体素上实现**切片交互**:轴向/任意切片、滚轮推进、拾取选中、双击正视。让用户能在三维体上切出带色阶的剖面并交互调整。
|
||||||
|
|
||||||
|
## 范围
|
||||||
|
**范围内(P3 核心)**:
|
||||||
|
- 新建 `src/render/interact/` 交互层(README 早有规划但目录不存在)。
|
||||||
|
- **切片工具**:轴向(上下=水平面/前后/左右,角度固定,F22–F24)+ 任意切片(初始 45°、可旋转,F25),对体素 `vtkImageData` 重采样出**带色阶剖面**。
|
||||||
|
- **滚轮切片**(D46):选中切片,滚轮沿切面法向推进/后退。
|
||||||
|
- **拾取选中 + 联动**(C38/D39/D40/E54/E55/E56):拾取三维体/切片 → 以其中心为相机焦点(拖动绕其旋转);双击切片 → 相机正视切面法向;视图翻转(水平 180°);关闭切片。
|
||||||
|
- 切片剖面随体素纵向夸张一致(复用 P2 VE)。
|
||||||
|
- UI 入口:在三维视图加一组**切片按钮**(上下/前后/左右/任意/关闭),最小可用即可(完整右键菜单 + 三维分析树留 P4)。
|
||||||
|
|
||||||
|
**范围外(留 P4)**:切片**保存/保存为/导出图片/导出dat/删除**为数据集(§6.3 CRUD)、三维分析栏树列表、右键上下文菜单、异常圈定 + 异常体管理、三维体/切片详情。
|
||||||
|
|
||||||
|
## 关键设计
|
||||||
|
- **体素 image 暴露**:`VtkSceneView::addVolume` 改用 `buildVoxel(...,outImage)` 重载,保留 `currentVolumeImage_`(含 VE 烤入的 origin/spacing),供切片工具附着。无体素时切片按钮禁用/无效。
|
||||||
|
- **切片工具**(`src/render/interact/SliceTool.{hpp,cpp}`):
|
||||||
|
- 方案优选 **`vtkImagePlaneWidget`** 同时覆盖轴向与任意:`SetPlaneOrientationToXAxes/Y/Z` 给轴向(关闭旋转交互=角度固定);任意 = 设初始法向 45° 并允许旋转。它内部 reslice + 纹理显示剖面,`SetLookupTable` 套我们的色阶 LUT(`ColorLutBuilder`)。
|
||||||
|
- 若 `vtkImagePlaneWidget` 旋转/滚轮交互不满足,再退 `vtkImplicitPlaneWidget2 + vtkImageReslice + vtkImageActor`(spec §9.1 钉死 reslice,**不用 vtkCutter**)。
|
||||||
|
- 持 `vtkPlane`(origin/normal);产出当前切面(供 P4 保存时转 Grid)。
|
||||||
|
- **滚轮推进**:自定义 interactor 观察者截 `MouseWheelForward/BackwardEvent`,对选中切片沿法向平移 origin → 更新 widget。
|
||||||
|
- **拾取 + 自定义 InteractorStyle**(`src/render/interact/PickInteractorStyle.{hpp,cpp}`,继承 `vtkInteractorStyleTrackballCamera`):
|
||||||
|
- `vtkPropPicker` 拾取 → 选中 prop(高亮)→ 相机 focalPoint=prop 包围盒中心(绕其旋转)。
|
||||||
|
- 双击切片 → 相机 position=center+normal·dist、viewUp 取法向正交(法向竖直时兜底备用 up)→ 正视(E54)。视图翻转=Azimuth(180)(E55)。
|
||||||
|
- **InteractionManager**(`src/render/interact/InteractionManager.{hpp,cpp}`):持 interactor + 活动切片工具列表 + 选中态;管理创建/关闭切片、滚轮分发、拾取联动。app 层 VtkSceneView 持有它并接 UI 按钮。
|
||||||
|
|
||||||
|
## 步骤(TDD;交互件靠 build+目视,纯逻辑单测)
|
||||||
|
0. 基线 `build.bat test` 全绿。
|
||||||
|
1. **切面几何/法向数学**(TDD 纯逻辑):轴向法向(上下=(0,0,1)/前后=(0,1,0)/左右=(1,0,0))、任意初始 45°;滚轮平移 origin=origin+normal·step;双击正视的相机 position/viewUp 计算(含法向竖直兜底)。抽成可测纯函数(如 `SlicePlaneMath`)。
|
||||||
|
2. **VtkSceneView 暴露体素 image**:addVolume 保留 currentVolumeImage_;clear 置空。测试/目视体素仍正常。
|
||||||
|
3. **SliceTool**(vtkImagePlaneWidget 封装):构建/附着 image/设色阶 LUT/轴向 vs 任意配置/关闭。可测部分:plane origin/normal 设置、LUT 套用;widget 交互目视。
|
||||||
|
4. **PickInteractorStyle + InteractionManager**:拾取→focal、双击→正视、滚轮→推进、翻转、关闭。纯逻辑(focal/相机计算)单测;拾取/事件目视。
|
||||||
|
5. **UI 接入 main.cpp**:三维视图切片按钮组(上下/前后/左右/任意/关闭),连 InteractionManager;仅三维 + 有体素时可用。深色主题(复用 P2 工具条样式)。
|
||||||
|
6. `build.bat test` 全绿 + `build.bat app` 链接;目视清单交用户。
|
||||||
|
7. cpp-reviewer 审查 + 提交。
|
||||||
|
|
||||||
|
## 风险/注意
|
||||||
|
- **vtkImagePlaneWidget 生命周期**:须 `SetInteractor`(QVTK 的 `renderWindow->GetInteractor()`) + `On()`;vtkSmartPointer 持有防析构;场景 clear/切视图时正确 Off()+释放,避免悬挂观察者/崩溃。
|
||||||
|
- **纵向夸张一致**:切片附着的 image 已烤入 VE(P1 体素管线),切面几何与体绘制对齐;VE 变化触发体素重建 → 切片须重附着或关闭重建。
|
||||||
|
- **2D 模式无切片**:切片仅三维视图;切到二维须 Off 所有切片工具。
|
||||||
|
- **自定义 InteractorStyle 与 QVTK 默认**:替换 style 后须保留相机拖动等基本交互(继承 TrackballCamera)。
|
||||||
|
- **构建**:增量链接错用 `cmake --build build\release --clean-first`,**勿删 build 目录**([[build-vs2026-vcpkg-gotchas]]);app 在运行致 LNK1104 则以 ctest 为准、提示关 app 重链。
|
||||||
|
- **交互件难单测**:VTK widget 需 render window/interactor,CI 无显示环境多半跳过;故纯逻辑(plane/相机数学)尽量抽出单测,widget 行为靠目视。报告标清目视项。
|
||||||
|
- 切片"标准/任意"手感、色阶、正视/翻转细节若需对齐 Geopro 1.0,先合理实现,待 1.0 实地学习再精修([[study-original-via-playwright]])。
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
# P5 二维数据集栏渲染 + 底图 Implementation Plan
|
||||||
|
|
||||||
|
> 接续:三栏结构重构(已完成) + 真实 ERT 帘面(已完成)。本计划做「二维数据集」栏的真实渲染 + 天地图底图。
|
||||||
|
> GUI 渲染正确性须用户实测(Claude 不能 GUI 测;见构建铁律)。
|
||||||
|
|
||||||
|
**Goal:** 「二维数据集」栏勾选 → 在 VTK 视图的 2D 俯视面渲染:轨迹线(dd_trajectory_data)、网格面(dd_grid),并叠加天地图底图(可切换 天地图/Google/隐藏)。
|
||||||
|
|
||||||
|
**Architecture:** 复用现有 `VtkSceneController`(已有 ViewMode::Map2D + addSurveyLine) + `GeoLocalFrame`(已重锚真实数据) + 天地图 token/WMTS(已存在于 trajectory_map.html)。新增:2D 数据异步加载(轨迹/网格) + 2D actor + 底图瓦片层(VTK)。
|
||||||
|
|
||||||
|
**已确认事实(真实 API/代码):**
|
||||||
|
- 天地图 token:`TK=aca91d8c9f59a4f779f39061b8a07737`,WMTS XYZ:`http://t{0-7}.tianditu.gov.cn/{layer}_w/wmts?...&tileMatrixSet=w&TileMatrix={z}&TileRow={y}&TileCol={x}&style=default&format=tiles&tk=TK`(图层 `vec`街道/`img`卫星 + `cva`/`cia`注记,EPSG:3857,原生 z18)。见 `src/app/resources/map/trajectory_map.html`。
|
||||||
|
- 轨迹数据:`GET /business/dd/ert/trajectory/line?dsObjectId={id}&frontCrsCode=EPSG:4326` → `data.electrodelList`[],每项 `{electrodeNo, electrodeCoordinate}`(经纬度)。
|
||||||
|
- 网格数据:`dd/ert/grid/rows/{id}`(GET) 实测 404 → **本计划 Task 0 先确认正确端点**(疑似 query 参数或 POST;参考 `src/app/panels/chart/GridStrategy.hpp` + `ApiDatasetRepository` 的 `gridRowsBatch`/`makeGridRows`)。
|
||||||
|
- `dimensionOf`:`dd_trajectory_data`→Dim2D(已);`dd_grid`→当前 Other(**需加 Dim2D**)。
|
||||||
|
- col2D(`Column2DDataset`) 信号 `basemapChanged/view2DModeChanged/customZChanged/checkedDatasetsChanged` 在 main.cpp **当前未接线**(T7 留待本期)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 0:确认 dd_grid 端点 ✅(已做,结论纠正本计划)
|
||||||
|
- `dd_grid` = **「白化数据」分页坐标点表**:`GET /business/dd/ert/grid/rows?dsObjectId=&pageNo=&pageSize=` → `data={rowList[{x,y,id}], gridHeaderDisplay[x,y], total}`(见 `ApiDatasetRepository::gridRowsBatch` + `GridStrategy.hpp`)。
|
||||||
|
- **结论:dd_grid 是表格数据、不是 2D 地图面**,不作渲染层 → 维持 `Other`(仅在「数据详情」看表)。
|
||||||
|
- **2D 地图可渲染类型只剩 `dd_trajectory_data`(轨迹线)+ 底图**。Task 1 取消(不把 dd_grid 归 2D);Task 2/3/4 只做轨迹。
|
||||||
|
|
||||||
|
## Task 1:(取消)dd_grid 非地图渲染类型,维持 Other。
|
||||||
|
|
||||||
|
## Task 2:2D 数据异步加载(轨迹 + 网格)
|
||||||
|
在 `Api3dRepository`(真实) + `LocalSample3dRepository`(样本 stub) 加异步方法(照 `loadSection` 范式):
|
||||||
|
- `loadTrajectory(dsId, onOk(vector<LatLon>), onErr)`:真实走 `trajectory/line`,解析 `electrodelList[].electrodeCoordinate` → 经纬序列。
|
||||||
|
- `load2dGrid(dsId, onOk(SectionData/Grid), onErr)`:按 Task 0 的端点解析网格面。
|
||||||
|
- `I3dSceneRepository` 加这两个虚方法(接口扩展,LocalSample stub 返回样本/空)。
|
||||||
|
- 验证:编译绿;FakeSceneRepo 加 override。
|
||||||
|
|
||||||
|
## Task 3:2D actor(render 层)
|
||||||
|
- 轨迹线:新增 `render/actors/TrajectoryActor`(或复用 `MapLineActor`):经 `GeoLocalFrame.toLocal` 把经纬序列 → 局部米折线,摆在 Z=0 平面(或 2D视图 Z)。橙色(对齐轨迹详情 `#ff8c00`)。
|
||||||
|
- 网格面:复用 `MapLineActor`(俯视红线) 或 `GridContourActor`(着色面,需 frame)。
|
||||||
|
- 单测:纯几何(经纬→局部米折线点数/坐标)抽出可测。
|
||||||
|
|
||||||
|
## Task 4:VtkSceneView + Controller 接 2D 渲染
|
||||||
|
- `I3dSceneView`/`VtkSceneView` 加 `addTrajectory(...)`、`add2dGrid(...)`。
|
||||||
|
- `VtkSceneController`:Map2D 分支里,对勾选的 2D ds 按维度调 `loadTrajectory`/`load2dGrid` → addTrajectory/add2dGrid(异步 + QPointer/gen 守护,照帘面范式)。
|
||||||
|
- `setViewMode`:col2D 的「2D视图位置」(关闭/Z=0/顶部/底部/自定义Z) 控制 2D 面的 Z(自定义Z=世界绝对米,见三栏 spec)。
|
||||||
|
|
||||||
|
## Task 5:main.cpp 接 col2D 信号
|
||||||
|
- `drawer->col2D()` 的 `checkedDatasetsChanged` → 渲染 2D ds(同 col3D 模式,按维度过滤后的真实 dsId)。
|
||||||
|
- `view2DModeChanged/customZChanged` → setViewMode + 2D 面 Z。
|
||||||
|
- `basemapChanged` → Task 6 底图开关。
|
||||||
|
- 验证:编译绿 + 用户实测(勾选轨迹 ds → 俯视面出橙色测线;网格 ds → 面)。
|
||||||
|
|
||||||
|
## Task 6:天地图底图瓦片层(最复杂,可独立验证)
|
||||||
|
新增 `render/ground/TileGroundLayer`:
|
||||||
|
- 输入:当前数据地理范围(从已渲染 actor 的 lat/lon 包围盒,或 GeoLocalFrame 原点 + 视域)→ 选合适 zoom(数据跨度→z)。
|
||||||
|
- 瓦片数学:EPSG:3857 经纬→TileRow/Col(标准 Web Mercator 瓦片公式),取覆盖范围的瓦片集。
|
||||||
|
- 异步拉取:`QNetworkAccessManager` GET 天地图 WMTS URL(token/子域见上)→ QImage → vtkTexture。
|
||||||
|
- 摆放:每块瓦片的地理 bbox → 四角经纬 `GeoLocalFrame.toLocal` → 局部米 → `vtkPlaneSource` + texture,置于 Z=底图平面。
|
||||||
|
- 切换:`basemapChanged`(0天地图/1Google/2隐藏);Google 可后置(国内可用性),先天地图 + 隐藏。
|
||||||
|
- LOD/随相机更新:本期可固定一档 zoom(覆盖数据范围),相机驱动 LOD 后置。
|
||||||
|
- **配准是精度敏感点**:3857 瓦片范围反算到 GeoLocalFrame 必须与帘面/轨迹同一 frame,否则底图与数据错位。**须用户实测对齐**(Claude 不能 GUI 测)。
|
||||||
|
- 单测:瓦片数学(经纬→z/x/y、瓦片 bbox→经纬)纯函数抽出可测。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ④ 三维分析栏交互(本期受限,附记)
|
||||||
|
- 树/右键菜单已是 UI。`detailRequested`→`detailCtrl.openDataset` 已接(T7)。`显示/隐藏` 可接 actor 可见性。
|
||||||
|
- **真切片**需 3D 体模型(dd_voxel/dd_Structual3D),后端缺 → 受限,待后端。
|
||||||
|
- 切片 CRUD/色阶/异常 = P4,接口已留位(②a),待后端 + 1.0 参考。
|
||||||
|
|
||||||
|
## 风险/验证
|
||||||
|
- 全部渲染须用户 `build.bat rebuild` 实测(Claude 不能 GUI 测)。
|
||||||
|
- 底图配准、2D 面 Z、轨迹/网格定位都是精度敏感点 → 小步验证。
|
||||||
|
- Task 0(grid 端点) 不明会卡 Task 2 的网格分支 → 先确认。
|
||||||
|
|
@ -0,0 +1,869 @@
|
||||||
|
# VTK 三栏结构重构 Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** 把 VTK 工作台的"旧二维/三维切换 + 三浮层"过渡态,重构成需求 A1 的「三个子列表栏」(三维数据集 / 二维数据集 / 三维分析),内嵌在唯一的中央「VTK视图」左侧,并接通已有渲染/切片能力;同时给 VTK视图 + 数据详情 加全屏按钮。
|
||||||
|
|
||||||
|
**Architecture:** 三栏抽成独立 widget(`src/app/panels/columns/`),各自只发信号、不依赖控制器;`ColumnDrawer` 作为 `vtkWidget` 在 HBox 中的**左侧兄弟控件**(非 GL 浮层,规避原生 GL 浮层 z 序/圆角伪影),可折叠。`main.cpp::buildWorkbench` 删三浮层+分段切换,改挂三栏并把信号接到既有 `VtkSceneController`/`InteractionManager`/`DatasetDetailController`。数据集列表由 `WorkbenchNavController` 取 `DsRow`、按 `I3dSceneRepository::dimensionOf` 过滤后分发到三栏。
|
||||||
|
|
||||||
|
**Tech Stack:** C++17, Qt6 Widgets, VTK9, Qt-ADS。构建 `build.bat`(见"构建/验证铁律"),测试 GoogleTest/CTest。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ 构建/验证铁律(每个 Task 都遵守)
|
||||||
|
|
||||||
|
- 构建:从 Git Bash 调 `cmd.exe /c "chcp 65001 >nul && cd /d D:\Git\lanbingtech\geopro && .\build.bat test"`。命令:`app`/`test`/`rebuild`(全量)/`configure`。
|
||||||
|
- **ninja 偶发漏编** → 改头/布局后用 `build.bat rebuild`;验 exe 新鲜:`stat -c '%y' build/release/src/app/geopro_desktop.exe`。
|
||||||
|
- **切勿 `rm -rf build/release`**(vcpkg 重编依赖极慢)。
|
||||||
|
- **Claude 工具跑 build 偶被 Start-Process 钩子劫持静默不跑** → **所有"用户实测"步骤必须由用户在其终端 `build.bat rebuild` 跑并目视**。Claude 不能 GUI 测 VTK 交互。
|
||||||
|
- 纯逻辑(如维度过滤)抽函数 + GoogleTest 单测;UI/交互靠 build 绿 + 用户实测清单。
|
||||||
|
|
||||||
|
## 测试方式约定(本计划特例,覆盖默认 TDD-everywhere)
|
||||||
|
|
||||||
|
- **逻辑步骤**(标 `[逻辑]`):先写失败测试 → 跑红 → 实现 → 跑绿 → 提交。
|
||||||
|
- **UI 步骤**(标 `[UI]`):实现 → `build.bat rebuild` 编绿 → **用户实测清单** → 提交。Claude 不声称"已验证"交互,只验证编译通过 + 代码读校。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
**新建:**
|
||||||
|
- `src/app/panels/columns/ColumnDrawer.hpp/.cpp` — 抽屉容器(QTabWidget 三 tab + 折叠开关)。
|
||||||
|
- `src/app/panels/columns/Column3DDataset.hpp/.cpp` — 三维数据集栏(4 工具条栏位 + 3D 数据集树)。
|
||||||
|
- `src/app/panels/columns/Column2DDataset.hpp/.cpp` — 二维数据集栏(地图/2D视图控件 + 2D 数据集树)。
|
||||||
|
- `src/app/panels/columns/Column3DAnalysis.hpp/.cpp` — 三维分析栏(对象→三维体→切片 树 + 两个右键菜单)。
|
||||||
|
- `src/app/DatasetDimension.hpp/.cpp` — 纯函数 `splitByDimension(...)`(可单测)。
|
||||||
|
- `tests/app/test_dataset_dimension.cpp` — 维度过滤单测。
|
||||||
|
|
||||||
|
**修改:**
|
||||||
|
- `src/app/main.cpp` — `buildWorkbench`:删三浮层(393-556)/分段切换(380-389,832-843)/showLayerPanel(804-827)/相关 connect;改挂 ColumnDrawer;接信号;rename vtkDock;bump dockState 版本;接维度过滤;全屏按钮。
|
||||||
|
- `src/app/Glyphs.hpp/.cpp` — 加 `Glyph::Fullscreen` + SVG。
|
||||||
|
- `src/app/CMakeLists.txt` — 加新源文件。
|
||||||
|
- `tests/CMakeLists.txt`(或对应)— 加 test_dataset_dimension。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: 加 Fullscreen 图标 [UI]
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/app/Glyphs.hpp:15-35`(Glyph 枚举)
|
||||||
|
- Modify: `src/app/Glyphs.cpp`(SVG path 映射,参照现有 case)
|
||||||
|
|
||||||
|
- [ ] **Step 1: 在 Glyph 枚举加 Fullscreen**
|
||||||
|
|
||||||
|
`src/app/Glyphs.hpp`,在 `Collapse,` 之后加:
|
||||||
|
```cpp
|
||||||
|
Collapse, // 折叠(双箭头)
|
||||||
|
Fullscreen, // 全屏 / 最大化
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 在 Glyphs.cpp 的 svg 映射加 case**
|
||||||
|
|
||||||
|
找到 `makeGlyph`/svg path 的 `switch`(参照 `case Glyph::Collapse:`),加:
|
||||||
|
```cpp
|
||||||
|
case Glyph::Fullscreen:
|
||||||
|
return QStringLiteral("<path d='M8 3H5a2 2 0 0 0-2 2v3m13-5h3a2 2 0 0 1 2 2v3"
|
||||||
|
"M21 16v3a2 2 0 0 1-2 2h-3M8 21H5a2 2 0 0 1-2-2v-3'/>");
|
||||||
|
```
|
||||||
|
(若已有 restore/缩小语义图标可复用,但需一个独立 Fullscreen 项。)
|
||||||
|
|
||||||
|
- [ ] **Step 3: 编译**
|
||||||
|
|
||||||
|
Run: `cmd.exe /c "chcp 65001 >nul && cd /d D:\Git\lanbingtech\geopro && .\build.bat app"`
|
||||||
|
Expected: 编译通过(exe 重新生成)。
|
||||||
|
|
||||||
|
- [ ] **Step 4: 提交**
|
||||||
|
```bash
|
||||||
|
git add src/app/Glyphs.hpp src/app/Glyphs.cpp
|
||||||
|
git commit -m "feat(vtk): 加 Glyph::Fullscreen 图标(三栏重构全屏按钮用)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: 维度过滤纯函数 [逻辑]
|
||||||
|
|
||||||
|
把"DsRow 列表 → 按维度分三组"抽成可单测纯函数。`dimensionOf` 已在 `I3dSceneRepository`(接口),这里做的是**列表分流**。
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/app/DatasetDimension.hpp`
|
||||||
|
- Create: `src/app/DatasetDimension.cpp`
|
||||||
|
- Test: `tests/app/test_dataset_dimension.cpp`
|
||||||
|
- Modify: `src/app/CMakeLists.txt`、`tests/CMakeLists.txt`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 写失败测试**
|
||||||
|
|
||||||
|
`tests/app/test_dataset_dimension.cpp`:
|
||||||
|
```cpp
|
||||||
|
#include <gtest/gtest.h>
|
||||||
|
#include "app/DatasetDimension.hpp"
|
||||||
|
#include "data/repo/RepoTypes.hpp"
|
||||||
|
|
||||||
|
using geopro::data::DsRow;
|
||||||
|
using geopro::app::splitByDimension;
|
||||||
|
using geopro::app::DimBuckets;
|
||||||
|
|
||||||
|
static DsRow row(const char* id, const char* ddCode) {
|
||||||
|
DsRow r; r.id = id; r.ddCode = ddCode; return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(DatasetDimension, SplitsByDdCode) {
|
||||||
|
std::vector<DsRow> in{
|
||||||
|
row("a", "dd_section"), // 3D
|
||||||
|
row("b", "dd_voxel"), // 3D
|
||||||
|
row("c", "dd_trajectory_data"), // 2D
|
||||||
|
row("d", "dd_slice"), // Analysis
|
||||||
|
row("e", "dd_unknownxyz"), // Other → 不入任何栏
|
||||||
|
};
|
||||||
|
DimBuckets b = splitByDimension(in);
|
||||||
|
ASSERT_EQ(b.dim3D.size(), 2u);
|
||||||
|
EXPECT_EQ(b.dim3D[0].id, "a");
|
||||||
|
EXPECT_EQ(b.dim3D[1].id, "b");
|
||||||
|
ASSERT_EQ(b.dim2D.size(), 1u);
|
||||||
|
EXPECT_EQ(b.dim2D[0].id, "c");
|
||||||
|
ASSERT_EQ(b.analysis.size(), 1u);
|
||||||
|
EXPECT_EQ(b.analysis[0].id, "d");
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(DatasetDimension, EmptyInput) {
|
||||||
|
DimBuckets b = splitByDimension({});
|
||||||
|
EXPECT_TRUE(b.dim3D.empty());
|
||||||
|
EXPECT_TRUE(b.dim2D.empty());
|
||||||
|
EXPECT_TRUE(b.analysis.empty());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 跑测试确认失败**
|
||||||
|
|
||||||
|
Run: `cmd.exe /c "chcp 65001 >nul && cd /d D:\Git\lanbingtech\geopro && .\build.bat test"`
|
||||||
|
Expected: FAIL(`DatasetDimension.hpp` 不存在 / 链接失败)。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 写实现**
|
||||||
|
|
||||||
|
`src/app/DatasetDimension.hpp`:
|
||||||
|
```cpp
|
||||||
|
#pragma once
|
||||||
|
#include <vector>
|
||||||
|
#include "data/repo/RepoTypes.hpp"
|
||||||
|
|
||||||
|
namespace geopro::app {
|
||||||
|
|
||||||
|
struct DimBuckets {
|
||||||
|
std::vector<geopro::data::DsRow> dim3D;
|
||||||
|
std::vector<geopro::data::DsRow> dim2D;
|
||||||
|
std::vector<geopro::data::DsRow> analysis;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 按 ddCode 把 ds 分流到 三维数据集 / 二维数据集 / 三维分析 三栏。
|
||||||
|
// Other 维度不入任何栏(保留 parentId 顺序,调用方可直接喂 populateDatasetList)。
|
||||||
|
DimBuckets splitByDimension(const std::vector<geopro::data::DsRow>& rows);
|
||||||
|
|
||||||
|
} // namespace geopro::app
|
||||||
|
```
|
||||||
|
|
||||||
|
`src/app/DatasetDimension.cpp`:
|
||||||
|
```cpp
|
||||||
|
#include "app/DatasetDimension.hpp"
|
||||||
|
|
||||||
|
namespace geopro::app {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
// 与 LocalSample3dRepository::dimensionOf 同一映射(spec §6.1)。
|
||||||
|
// 抽到此处以便纯函数单测;将来后端返 dimension 字段时此函数改读字段即可。
|
||||||
|
enum class Dim { D3, D2, Analysis, Other };
|
||||||
|
Dim dimOf(const std::string& c) {
|
||||||
|
if (c == "dd_voxel" || c == "dd_Structual3D" || c == "dd_Property3D" ||
|
||||||
|
c == "dd_section" || c == "dd_inversion_data")
|
||||||
|
return Dim::D3;
|
||||||
|
if (c == "dd_slice") return Dim::Analysis;
|
||||||
|
if (c == "dd_trajectory_data") return Dim::D2;
|
||||||
|
return Dim::Other;
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
DimBuckets splitByDimension(const std::vector<geopro::data::DsRow>& rows) {
|
||||||
|
DimBuckets b;
|
||||||
|
for (const auto& r : rows) {
|
||||||
|
switch (dimOf(r.ddCode)) {
|
||||||
|
case Dim::D3: b.dim3D.push_back(r); break;
|
||||||
|
case Dim::D2: b.dim2D.push_back(r); break;
|
||||||
|
case Dim::Analysis: b.analysis.push_back(r); break;
|
||||||
|
case Dim::Other: break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace geopro::app
|
||||||
|
```
|
||||||
|
|
||||||
|
> 注:`dimensionOf` 同时存在于 `LocalSample3dRepository`(渲染编排用)。此处复制映射是**有意**——纯函数便于单测、且与"将来后端返 dimension 字段"解耦。后续若收敛为单一真源,再让本函数调用注入的 repo。落地时若 reviewer 要求单一真源,可改签名 `splitByDimension(rows, const I3dSceneRepository&)`,本期按纯函数。
|
||||||
|
|
||||||
|
- [ ] **Step 4: 注册到 CMake**
|
||||||
|
|
||||||
|
`src/app/CMakeLists.txt`:把 `DatasetDimension.cpp` 加入 app 目标源列表(仿照同目录 .cpp 的加法)。
|
||||||
|
`tests/CMakeLists.txt`(或 tests/app):把 `test_dataset_dimension.cpp` 加入测试目标(仿照 `test_3d_repo` 的注册)。
|
||||||
|
|
||||||
|
- [ ] **Step 5: 跑测试确认通过**
|
||||||
|
|
||||||
|
Run: `cmd.exe /c "chcp 65001 >nul && cd /d D:\Git\lanbingtech\geopro && .\build.bat test"`
|
||||||
|
Expected: `DatasetDimension.*` 2 项 PASS;总数 ≥ 223/223。
|
||||||
|
|
||||||
|
- [ ] **Step 6: 提交**
|
||||||
|
```bash
|
||||||
|
git add src/app/DatasetDimension.hpp src/app/DatasetDimension.cpp tests/app/test_dataset_dimension.cpp src/app/CMakeLists.txt tests/CMakeLists.txt
|
||||||
|
git commit -m "feat(vtk): 维度过滤纯函数 splitByDimension + 单测"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: 三维数据集栏 widget [UI]
|
||||||
|
|
||||||
|
独立 widget:4 工具条栏位(坐标轴设置 / 水平垂直比例 / 快捷视图 / 缩放)+ 数据集树。只发信号。控件创建可**搬运** `main.cpp:433-516`(axisBar)的 combo/slider/button 构造与样式,重排成 4 分组(参照原型 `docs/superpowers/mockups/2026-06-16-three-column-layout.html` 的 `toolbar3D`)。
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/app/panels/columns/Column3DDataset.hpp/.cpp`
|
||||||
|
- Modify: `src/app/CMakeLists.txt`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 写头文件(信号 API)**
|
||||||
|
|
||||||
|
`src/app/panels/columns/Column3DDataset.hpp`:
|
||||||
|
```cpp
|
||||||
|
#pragma once
|
||||||
|
#include <QWidget>
|
||||||
|
#include <QStringList>
|
||||||
|
#include <vector>
|
||||||
|
#include "controller/I3dSceneView.hpp" // AxesMode/AxesUnit/ViewDir
|
||||||
|
#include "data/repo/RepoTypes.hpp"
|
||||||
|
|
||||||
|
class QTreeWidget;
|
||||||
|
|
||||||
|
namespace geopro::app {
|
||||||
|
|
||||||
|
// 三维数据集栏:坐标轴设置 + 水平/垂直比例 + 快捷视图 + 缩放 + 3D 数据集列表。
|
||||||
|
class Column3DDataset : public QWidget {
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
explicit Column3DDataset(QWidget* parent = nullptr);
|
||||||
|
// 用 3D 维度的 ds 填充列表(调用 populateDatasetList)。
|
||||||
|
void setDatasets(const std::vector<geopro::data::DsRow>& rows);
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void axesModeChanged(geopro::controller::AxesMode mode);
|
||||||
|
void axesUnitChanged(geopro::controller::AxesUnit unit);
|
||||||
|
void verticalExaggerationChanged(double ve);
|
||||||
|
void viewRequested(geopro::controller::ViewDir dir);
|
||||||
|
void zoomInRequested();
|
||||||
|
void zoomOutRequested();
|
||||||
|
void fitRequested();
|
||||||
|
void oPointClicked(); // O点位置按钮(本期弹框留 stub)
|
||||||
|
void fontClicked(); // 字体按钮(本期 stub)
|
||||||
|
void checkedDatasetsChanged(const QStringList& dsIds); // 列表勾选变化
|
||||||
|
|
||||||
|
private:
|
||||||
|
QTreeWidget* list_ = nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace geopro::app
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 写实现(构造 4 分组 + 列表)**
|
||||||
|
|
||||||
|
`src/app/panels/columns/Column3DDataset.cpp`:用 `QVBoxLayout` 堆 4 个分组 `QGroupBox`/`QFrame`(标题 + 表单行)+ `QTreeWidget` 列表。控件构造照搬 `main.cpp:464-500`(axesModeCombo/axesUnitCombo/veSlider/btnFront..btnFit),样式用 `applyTokenizedStyleSheet` 照搬 `main.cpp:437-457`。各控件 `connect` 到本类 `emit ...`。要点(完整骨架):
|
||||||
|
```cpp
|
||||||
|
#include "app/panels/columns/Column3DDataset.hpp"
|
||||||
|
#include <QVBoxLayout>
|
||||||
|
#include <QHBoxLayout>
|
||||||
|
#include <QFormLayout>
|
||||||
|
#include <QComboBox>
|
||||||
|
#include <QSlider>
|
||||||
|
#include <QPushButton>
|
||||||
|
#include <QLabel>
|
||||||
|
#include <QTreeWidget>
|
||||||
|
#include "app/Theme.hpp"
|
||||||
|
#include "app/panels/DatasetListPanel.hpp" // populateDatasetList
|
||||||
|
|
||||||
|
using geopro::controller::AxesMode;
|
||||||
|
using geopro::controller::AxesUnit;
|
||||||
|
using geopro::controller::ViewDir;
|
||||||
|
|
||||||
|
namespace geopro::app {
|
||||||
|
|
||||||
|
Column3DDataset::Column3DDataset(QWidget* parent) : QWidget(parent) {
|
||||||
|
auto* root = new QVBoxLayout(this);
|
||||||
|
root->setContentsMargins(space::kMd, space::kMd, space::kMd, space::kMd);
|
||||||
|
root->setSpacing(space::kMd);
|
||||||
|
|
||||||
|
// —— 坐标轴设置 组:显示方式▾ / O点位置(按钮) / 刻度▾ / 字体(按钮) ——
|
||||||
|
{
|
||||||
|
auto* form = new QFormLayout();
|
||||||
|
auto* mode = new QComboBox();
|
||||||
|
mode->addItem(QStringLiteral("标准"), static_cast<int>(AxesMode::Standard));
|
||||||
|
mode->addItem(QStringLiteral("三维立体"), static_cast<int>(AxesMode::Stereo));
|
||||||
|
mode->addItem(QStringLiteral("不显示"), static_cast<int>(AxesMode::None));
|
||||||
|
connect(mode, qOverload<int>(&QComboBox::currentIndexChanged), this,
|
||||||
|
[this, mode](int){ emit axesModeChanged(static_cast<AxesMode>(mode->currentData().toInt())); });
|
||||||
|
auto* oPoint = new QPushButton(QStringLiteral("设置…"));
|
||||||
|
connect(oPoint, &QPushButton::clicked, this, &Column3DDataset::oPointClicked);
|
||||||
|
auto* unit = new QComboBox();
|
||||||
|
unit->addItem(QStringLiteral("无刻度"), static_cast<int>(AxesUnit::None));
|
||||||
|
unit->addItem(QStringLiteral("米"), static_cast<int>(AxesUnit::Meter));
|
||||||
|
unit->addItem(QStringLiteral("英尺"), static_cast<int>(AxesUnit::Feet));
|
||||||
|
unit->addItem(QStringLiteral("经纬度"), static_cast<int>(AxesUnit::LatLon));
|
||||||
|
unit->setCurrentIndex(1);
|
||||||
|
connect(unit, qOverload<int>(&QComboBox::currentIndexChanged), this,
|
||||||
|
[this, unit](int){ emit axesUnitChanged(static_cast<AxesUnit>(unit->currentData().toInt())); });
|
||||||
|
auto* font = new QPushButton(QStringLiteral("设置…"));
|
||||||
|
connect(font, &QPushButton::clicked, this, &Column3DDataset::fontClicked);
|
||||||
|
form->addRow(QStringLiteral("显示方式"), mode);
|
||||||
|
form->addRow(QStringLiteral("O点位置"), oPoint);
|
||||||
|
form->addRow(QStringLiteral("刻度"), unit);
|
||||||
|
form->addRow(QStringLiteral("字体"), font);
|
||||||
|
root->addWidget(new QLabel(QStringLiteral("坐标轴设置")));
|
||||||
|
root->addLayout(form);
|
||||||
|
}
|
||||||
|
// —— 水平/垂直比例 组:单个滑块 + 数值 ——
|
||||||
|
{
|
||||||
|
auto* row = new QHBoxLayout();
|
||||||
|
auto* slider = new QSlider(Qt::Horizontal);
|
||||||
|
slider->setMinimum(1); slider->setMaximum(10); slider->setValue(2);
|
||||||
|
auto* val = new QLabel(QStringLiteral("2.0×"));
|
||||||
|
connect(slider, &QSlider::valueChanged, this, [this, val](int v){
|
||||||
|
val->setText(QStringLiteral("%1.0×").arg(v));
|
||||||
|
emit verticalExaggerationChanged(static_cast<double>(v));
|
||||||
|
});
|
||||||
|
row->addWidget(slider, 1); row->addWidget(val);
|
||||||
|
root->addWidget(new QLabel(QStringLiteral("水平/垂直比例")));
|
||||||
|
root->addLayout(row);
|
||||||
|
}
|
||||||
|
// —— 快捷视图 组:前/后/左/右/上/下 ——
|
||||||
|
{
|
||||||
|
auto* row = new QHBoxLayout();
|
||||||
|
struct V { const char* t; ViewDir d; };
|
||||||
|
for (V v : { V{"前",ViewDir::Front}, V{"后",ViewDir::Back}, V{"左",ViewDir::Left},
|
||||||
|
V{"右",ViewDir::Right}, V{"上",ViewDir::Top}, V{"下",ViewDir::Bottom} }) {
|
||||||
|
auto* b = new QPushButton(QString::fromUtf8(v.t));
|
||||||
|
ViewDir d = v.d;
|
||||||
|
connect(b, &QPushButton::clicked, this, [this, d]{ emit viewRequested(d); });
|
||||||
|
row->addWidget(b);
|
||||||
|
}
|
||||||
|
root->addWidget(new QLabel(QStringLiteral("快捷视图")));
|
||||||
|
root->addLayout(row);
|
||||||
|
}
|
||||||
|
// —— 缩放 组:放大/缩小/适配 ——
|
||||||
|
{
|
||||||
|
auto* row = new QHBoxLayout();
|
||||||
|
auto* in = new QPushButton(QStringLiteral("放大"));
|
||||||
|
auto* out = new QPushButton(QStringLiteral("缩小"));
|
||||||
|
auto* fit = new QPushButton(QStringLiteral("适配"));
|
||||||
|
connect(in, &QPushButton::clicked, this, &Column3DDataset::zoomInRequested);
|
||||||
|
connect(out, &QPushButton::clicked, this, &Column3DDataset::zoomOutRequested);
|
||||||
|
connect(fit, &QPushButton::clicked, this, &Column3DDataset::fitRequested);
|
||||||
|
row->addWidget(in); row->addWidget(out); row->addWidget(fit);
|
||||||
|
root->addWidget(new QLabel(QStringLiteral("缩放")));
|
||||||
|
root->addLayout(row);
|
||||||
|
}
|
||||||
|
// —— 数据集列表(3D 维度)——
|
||||||
|
list_ = new QTreeWidget();
|
||||||
|
list_->setHeaderHidden(true);
|
||||||
|
list_->setRootIsDecorated(true);
|
||||||
|
applyDatasetCardDelegate(list_);
|
||||||
|
connect(list_, &QTreeWidget::itemChanged, this, [this](QTreeWidgetItem*, int){
|
||||||
|
QStringList ids;
|
||||||
|
for (QTreeWidgetItemIterator it(list_); *it; ++it)
|
||||||
|
if ((*it)->checkState(0) == Qt::Checked)
|
||||||
|
ids << (*it)->data(0, /*kDsIdRole*/ Qt::UserRole + 1).toString();
|
||||||
|
emit checkedDatasetsChanged(ids);
|
||||||
|
});
|
||||||
|
root->addWidget(list_, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Column3DDataset::setDatasets(const std::vector<geopro::data::DsRow>& rows) {
|
||||||
|
populateDatasetList(list_, rows, /*append=*/false);
|
||||||
|
// 列表项需可勾选:populateDatasetList 后给每项加 Qt::ItemIsUserCheckable + Unchecked。
|
||||||
|
for (QTreeWidgetItemIterator it(list_); *it; ++it) {
|
||||||
|
(*it)->setFlags((*it)->flags() | Qt::ItemIsUserCheckable);
|
||||||
|
if ((*it)->checkState(0) == Qt::Unchecked || (*it)->checkState(0) == Qt::Checked) {}
|
||||||
|
else (*it)->setCheckState(0, Qt::Unchecked);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace geopro::app
|
||||||
|
```
|
||||||
|
> 注 1:`kDsIdRole` 的真实值见 `src/app/panels/DatasetListPanel.cpp`(`Qt::UserRole+? `)。落地时 include 该常量或用其公开定义,勿硬编码 `UserRole+1`——读 DatasetListPanel.hpp/.cpp 取真实 role 常量。
|
||||||
|
> 注 2:`populateDatasetList` 生成的项默认不可勾选;本栏需勾选渲染,故 setDatasets 后补 `ItemIsUserCheckable` + `Unchecked`(见上)。
|
||||||
|
> 注 3:分组标题/表单样式照原型;可用 `applyTokenizedStyleSheet` 套深色令牌(搬 `main.cpp:437-457`)。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 注册 CMake + 编译**
|
||||||
|
|
||||||
|
`src/app/CMakeLists.txt` 加 `panels/columns/Column3DDataset.cpp`。
|
||||||
|
Run: `cmd.exe /c "chcp 65001 >nul && cd /d D:\Git\lanbingtech\geopro && .\build.bat app"`
|
||||||
|
Expected: 编译通过。
|
||||||
|
|
||||||
|
- [ ] **Step 4: 提交**
|
||||||
|
```bash
|
||||||
|
git add src/app/panels/columns/Column3DDataset.hpp src/app/panels/columns/Column3DDataset.cpp src/app/CMakeLists.txt
|
||||||
|
git commit -m "feat(vtk): 三维数据集栏 widget(4工具条栏位+3D数据集列表,只发信号)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: 二维数据集栏 widget [UI]
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/app/panels/columns/Column2DDataset.hpp/.cpp`
|
||||||
|
- Modify: `src/app/CMakeLists.txt`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 写头文件**
|
||||||
|
|
||||||
|
`Column2DDataset.hpp`:
|
||||||
|
```cpp
|
||||||
|
#pragma once
|
||||||
|
#include <QWidget>
|
||||||
|
#include <QStringList>
|
||||||
|
#include <vector>
|
||||||
|
#include "data/repo/RepoTypes.hpp"
|
||||||
|
class QTreeWidget;
|
||||||
|
namespace geopro::app {
|
||||||
|
class Column2DDataset : public QWidget {
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
explicit Column2DDataset(QWidget* parent = nullptr);
|
||||||
|
void setDatasets(const std::vector<geopro::data::DsRow>& rows);
|
||||||
|
signals:
|
||||||
|
void basemapChanged(int index); // 0 天地图 / 1 Google / 2 隐藏
|
||||||
|
void view2DModeChanged(int index); // 0 关闭 /1 Z=0 /2 顶部 /3 底部 /4 自定义
|
||||||
|
void customZChanged(double z); // 世界绝对高程(米),向上为正
|
||||||
|
void checkedDatasetsChanged(const QStringList& dsIds);
|
||||||
|
private:
|
||||||
|
QTreeWidget* list_ = nullptr;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 写实现**
|
||||||
|
|
||||||
|
`Column2DDataset.cpp`:地图 combo(天地图/Google Map/隐藏)→ `basemapChanged`;2D视图 combo(关闭/Z=0/顶部/底部/自定义)→ `view2DModeChanged`,选"自定义"时显一个 `QDoubleSpinBox`(范围 ±1e6,后缀 " m")→ `customZChanged`;`QTreeWidget` 列表同 Task3(可勾选 + setDatasets 用 populateDatasetList)。骨架同 Column3DDataset 模式(QFormLayout 两组 + 列表),此处不赘述控件 connect(与 Task3 同形)。自定义 Z 输入框默认 `setVisible(false)`,在 `view2DModeChanged` 槽里 `zEdit->setVisible(index==4)`。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 注册 CMake + 编译**
|
||||||
|
|
||||||
|
`src/app/CMakeLists.txt` 加 `panels/columns/Column2DDataset.cpp`。
|
||||||
|
Run: `cmd.exe /c "chcp 65001 >nul && cd /d D:\Git\lanbingtech\geopro && .\build.bat app"`
|
||||||
|
Expected: 编译通过。
|
||||||
|
|
||||||
|
- [ ] **Step 4: 提交**
|
||||||
|
```bash
|
||||||
|
git add src/app/panels/columns/Column2DDataset.hpp src/app/panels/columns/Column2DDataset.cpp src/app/CMakeLists.txt
|
||||||
|
git commit -m "feat(vtk): 二维数据集栏 widget(地图/2D视图+自定义Z输入+2D列表)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: 三维分析栏 widget + 两个右键菜单 [UI]
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/app/panels/columns/Column3DAnalysis.hpp/.cpp`
|
||||||
|
- Modify: `src/app/CMakeLists.txt`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 写头文件**
|
||||||
|
|
||||||
|
`Column3DAnalysis.hpp`:
|
||||||
|
```cpp
|
||||||
|
#pragma once
|
||||||
|
#include <QWidget>
|
||||||
|
#include <QStringList>
|
||||||
|
#include <vector>
|
||||||
|
#include "data/repo/RepoTypes.hpp"
|
||||||
|
#include "render/interact/SlicePlaneMath.hpp" // SliceAxis
|
||||||
|
class QTreeWidget;
|
||||||
|
class QTreeWidgetItem;
|
||||||
|
namespace geopro::app {
|
||||||
|
// 三维分析栏:对象→三维体模型→切片 树 + 三维体/切片两套右键菜单。
|
||||||
|
class Column3DAnalysis : public QWidget {
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
explicit Column3DAnalysis(QWidget* parent = nullptr);
|
||||||
|
void setDatasets(const std::vector<geopro::data::DsRow>& rows); // Analysis 维度(三维体/切片)
|
||||||
|
signals:
|
||||||
|
// 三维体右键:切片▸(上下/前后/左右/任意)
|
||||||
|
void sliceRequested(geopro::render::interact::SliceAxis axis);
|
||||||
|
void colorScaleRequested(const QString& dsId); // 三维体&切片(本期 stub)
|
||||||
|
void visibilityToggled(const QString& dsId); // 显示/隐藏
|
||||||
|
void detailRequested(const QString& dsId, const QString& ddCode, const QString& name);
|
||||||
|
// 切片右键(本期 stub,菜单可见但发信号给上层提示"待实现")
|
||||||
|
void sliceSaveRequested(const QString& dsId);
|
||||||
|
void sliceSaveAsRequested(const QString& dsId);
|
||||||
|
void sliceExportRequested(const QString& dsId);
|
||||||
|
void sliceDeleteRequested(const QString& dsId);
|
||||||
|
void checkedItemsChanged(const QStringList& dsIds);
|
||||||
|
private:
|
||||||
|
void onContextMenu(const QPoint& pos);
|
||||||
|
QTreeWidget* tree_ = nullptr;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 写实现(树 + 右键分派)**
|
||||||
|
|
||||||
|
`Column3DAnalysis.cpp`:`tree_` 设 `setContextMenuPolicy(Qt::CustomContextMenu)`,connect `customContextMenuRequested` → `onContextMenu`。节点类型用 `item->data(0, role)` 区分"三维体" vs "切片"(建树时按 ddCode:`dd_voxel/dd_Structual3D/dd_Property3D/dd_section` 为三维体;`dd_slice` 为切片)。右键分派(核心):
|
||||||
|
```cpp
|
||||||
|
void Column3DAnalysis::onContextMenu(const QPoint& pos) {
|
||||||
|
QTreeWidgetItem* it = tree_->itemAt(pos);
|
||||||
|
if (!it) return;
|
||||||
|
const QString dsId = it->data(0, kDsIdRole).toString();
|
||||||
|
const QString ddCode = it->data(0, kDsDdCodeRole).toString();
|
||||||
|
const QString name = it->data(0, kDsNameRole).toString();
|
||||||
|
const bool isSlice = (ddCode == QStringLiteral("dd_slice"));
|
||||||
|
QMenu menu(this);
|
||||||
|
if (!isSlice) {
|
||||||
|
// 三维体数据集:切片▸(上下/前后/左右/任意) / 色阶 / 显示·隐藏 / 数据详情
|
||||||
|
QMenu* sub = menu.addMenu(QStringLiteral("切片"));
|
||||||
|
using SA = geopro::render::interact::SliceAxis;
|
||||||
|
sub->addAction(QStringLiteral("上下"), this, [this]{ emit sliceRequested(SA::UpDown); });
|
||||||
|
sub->addAction(QStringLiteral("前后"), this, [this]{ emit sliceRequested(SA::FrontBack); });
|
||||||
|
sub->addAction(QStringLiteral("左右"), this, [this]{ emit sliceRequested(SA::LeftRight); });
|
||||||
|
sub->addAction(QStringLiteral("任意"), this, [this]{ emit sliceRequested(SA::Oblique); });
|
||||||
|
menu.addAction(QStringLiteral("色阶"), this, [this, dsId]{ emit colorScaleRequested(dsId); });
|
||||||
|
menu.addAction(QStringLiteral("显示 / 隐藏"), this, [this, dsId]{ emit visibilityToggled(dsId); });
|
||||||
|
menu.addAction(QStringLiteral("数据详情"), this, [this, dsId, ddCode, name]{ emit detailRequested(dsId, ddCode, name); });
|
||||||
|
} else {
|
||||||
|
// 切片数据集:保存/保存为/导出/删除 — 色阶/显示·隐藏/数据详情
|
||||||
|
menu.addAction(QStringLiteral("保存"), this, [this, dsId]{ emit sliceSaveRequested(dsId); });
|
||||||
|
menu.addAction(QStringLiteral("保存为"), this, [this, dsId]{ emit sliceSaveAsRequested(dsId); });
|
||||||
|
menu.addAction(QStringLiteral("导出"), this, [this, dsId]{ emit sliceExportRequested(dsId); });
|
||||||
|
menu.addAction(QStringLiteral("删除"), this, [this, dsId]{ emit sliceDeleteRequested(dsId); });
|
||||||
|
menu.addSeparator();
|
||||||
|
menu.addAction(QStringLiteral("色阶"), this, [this, dsId]{ emit colorScaleRequested(dsId); });
|
||||||
|
menu.addAction(QStringLiteral("显示 / 隐藏"), this, [this, dsId]{ emit visibilityToggled(dsId); });
|
||||||
|
menu.addAction(QStringLiteral("数据详情"), this, [this, dsId, ddCode, name]{ emit detailRequested(dsId, ddCode, name); });
|
||||||
|
}
|
||||||
|
menu.exec(tree_->viewport()->mapToGlobal(pos));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
> `kDsIdRole/kDsDdCodeRole/kDsNameRole`:用 DatasetListPanel 的公开 role 常量(读 DatasetListPanel.hpp)。树构建:用 `populateDatasetList(tree_, analysisRows, false)` 起步(它已按 parentId 建树:切片挂三维体下),再补可勾选标志(同 Task3 注 2)。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 注册 CMake + 编译**
|
||||||
|
|
||||||
|
`src/app/CMakeLists.txt` 加 `panels/columns/Column3DAnalysis.cpp`。
|
||||||
|
Run: `cmd.exe /c "chcp 65001 >nul && cd /d D:\Git\lanbingtech\geopro && .\build.bat app"`
|
||||||
|
Expected: 编译通过。
|
||||||
|
|
||||||
|
- [ ] **Step 4: 提交**
|
||||||
|
```bash
|
||||||
|
git add src/app/panels/columns/Column3DAnalysis.hpp src/app/panels/columns/Column3DAnalysis.cpp src/app/CMakeLists.txt
|
||||||
|
git commit -m "feat(vtk): 三维分析栏 widget(对象→三维体→切片树+两套右键菜单)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: 抽屉容器 ColumnDrawer [UI]
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/app/panels/columns/ColumnDrawer.hpp/.cpp`
|
||||||
|
- Modify: `src/app/CMakeLists.txt`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 写头文件**
|
||||||
|
|
||||||
|
`ColumnDrawer.hpp`:
|
||||||
|
```cpp
|
||||||
|
#pragma once
|
||||||
|
#include <QWidget>
|
||||||
|
namespace geopro::app {
|
||||||
|
class Column3DDataset; class Column2DDataset; class Column3DAnalysis;
|
||||||
|
// VTK视图左侧内嵌抽屉:三 tab(三维数据集/二维数据集/三维分析) + 折叠开关。
|
||||||
|
class ColumnDrawer : public QWidget {
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
explicit ColumnDrawer(QWidget* parent = nullptr);
|
||||||
|
Column3DDataset* col3D() const { return col3D_; }
|
||||||
|
Column2DDataset* col2D() const { return col2D_; }
|
||||||
|
Column3DAnalysis* colAnalysis() const { return colAnalysis_; }
|
||||||
|
public slots:
|
||||||
|
void toggleCollapsed(); // 折叠/展开(宽度切换)
|
||||||
|
private:
|
||||||
|
Column3DDataset* col3D_ = nullptr;
|
||||||
|
Column2DDataset* col2D_ = nullptr;
|
||||||
|
Column3DAnalysis* colAnalysis_ = nullptr;
|
||||||
|
QWidget* body_ = nullptr; // QTabWidget 容器,折叠时隐藏
|
||||||
|
bool collapsed_ = false;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 写实现**
|
||||||
|
|
||||||
|
`QTabWidget` 三页(三维数据集/二维数据集/三维分析)放入 `body_`;旁边一个细长折叠按钮(◀/▶,调 `toggleCollapsed`)。`toggleCollapsed`:`collapsed_ = !collapsed_; body_->setVisible(!collapsed_);` 并切按钮箭头。固定展开宽度约 300(`setFixedWidth` 或 `setMaximumWidth`,折叠时设 0/隐藏 body)。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 注册 CMake + 编译**
|
||||||
|
|
||||||
|
Run: `cmd.exe /c "chcp 65001 >nul && cd /d D:\Git\lanbingtech\geopro && .\build.bat app"`
|
||||||
|
Expected: 编译通过。
|
||||||
|
|
||||||
|
- [ ] **Step 4: 提交**
|
||||||
|
```bash
|
||||||
|
git add src/app/panels/columns/ColumnDrawer.hpp src/app/panels/columns/ColumnDrawer.cpp src/app/CMakeLists.txt
|
||||||
|
git commit -m "feat(vtk): ColumnDrawer 抽屉容器(三tab+折叠)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: main.cpp 装配——删三浮层/切换,挂抽屉,接信号,改名 [UI]
|
||||||
|
|
||||||
|
这是核心整合。**一次性**替换,因 axisBar/sliceBar/layerPanel 的 connect 互相牵连,无法逐控件保持编译。改完一次编绿。
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/app/main.cpp`(多处,见下)
|
||||||
|
|
||||||
|
- [ ] **Step 1: 删旧 UI 构造**
|
||||||
|
|
||||||
|
删除:
|
||||||
|
- `layerPanel` 块(393-429)、`axisBar` 块(433-516)、`RightTopAnchor`(520)、`sliceBar` 块(525-556)、`BottomLeftAnchor`(556)。
|
||||||
|
- 分段切换:`buildSegmentedHeader`/`viewHeader`/`act2D`/`act3D`(380-389)改为**简单标题头**(见 Step 3)。
|
||||||
|
- `showLayerPanel` lambda(804-827)及其所有调用。
|
||||||
|
- `updateSliceButtons`(559-569)、`addSlice`(572-575)、sliceBar 按钮 connect(576-590)。
|
||||||
|
- 旧 connect:layer checkboxes(846-851)、axisBar 控件(857-889)、act2D/act3D(832-843)。
|
||||||
|
- 保留 `interactionMgr` 创建(309-321)、`emptyState`、`sceneCtrl`、`vtkWidget`。
|
||||||
|
|
||||||
|
- [ ] **Step 2: 建 ColumnDrawer + 改 centerWidget 布局为 [抽屉 | GL]**
|
||||||
|
|
||||||
|
在 `centerWidget` 构造处(374 一带)改为:顶部一个标题头(Step 3),下面一个 `QHBoxLayout` 装 `drawer` + `vtkWidget`:
|
||||||
|
```cpp
|
||||||
|
#include "app/panels/columns/ColumnDrawer.hpp"
|
||||||
|
#include "app/panels/columns/Column3DDataset.hpp"
|
||||||
|
#include "app/panels/columns/Column2DDataset.hpp"
|
||||||
|
#include "app/panels/columns/Column3DAnalysis.hpp"
|
||||||
|
// ...
|
||||||
|
auto* drawer = new geopro::app::ColumnDrawer(centerWidget);
|
||||||
|
auto* viewRow = new QHBoxLayout();
|
||||||
|
viewRow->setContentsMargins(0,0,0,0); viewRow->setSpacing(0);
|
||||||
|
viewRow->addWidget(drawer); // 左侧抽屉
|
||||||
|
viewRow->addWidget(vtkWidget, 1); // 右侧 GL 画布
|
||||||
|
// centerLayout: [标题头] + [viewRow]
|
||||||
|
centerLayout->addWidget(viewHeader); // Step 3 的新标题头
|
||||||
|
centerLayout->addLayout(viewRow, 1);
|
||||||
|
```
|
||||||
|
> 设计:抽屉是 vtkWidget 的**布局兄弟**(非 GL 子浮层),规避 `main.cpp:397-399` 注释提到的原生 GL 浮层圆角/底色伪影。视觉等同原型(栏在左、画布在右)。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 新标题头(含全屏按钮,Task 8 接线)**
|
||||||
|
|
||||||
|
替换分段头为 `buildPanelHeader`:
|
||||||
|
```cpp
|
||||||
|
auto* viewHeader = geopro::app::buildPanelHeader(
|
||||||
|
geopro::app::Glyph::Map, QStringLiteral("VTK视图"),
|
||||||
|
{{geopro::app::Glyph::Fullscreen, QStringLiteral("全屏")}});
|
||||||
|
```
|
||||||
|
(全屏按钮的 connect 在 Task 8。)
|
||||||
|
|
||||||
|
- [ ] **Step 4: 接三维数据集栏信号 → VtkSceneController**
|
||||||
|
```cpp
|
||||||
|
auto* c3 = drawer->col3D();
|
||||||
|
QObject::connect(c3, &geopro::app::Column3DDataset::axesModeChanged, sceneCtrl, &VtkSceneController::setAxesMode);
|
||||||
|
QObject::connect(c3, &geopro::app::Column3DDataset::axesUnitChanged, sceneCtrl, &VtkSceneController::setAxesUnit);
|
||||||
|
QObject::connect(c3, &geopro::app::Column3DDataset::verticalExaggerationChanged, sceneCtrl, &VtkSceneController::setVerticalExaggeration);
|
||||||
|
QObject::connect(c3, &geopro::app::Column3DDataset::viewRequested, sceneCtrl, &VtkSceneController::applyView);
|
||||||
|
QObject::connect(c3, &geopro::app::Column3DDataset::zoomInRequested, sceneCtrl, &VtkSceneController::zoomIn);
|
||||||
|
QObject::connect(c3, &geopro::app::Column3DDataset::zoomOutRequested, sceneCtrl, &VtkSceneController::zoomOut);
|
||||||
|
QObject::connect(c3, &geopro::app::Column3DDataset::fitRequested, sceneCtrl, &VtkSceneController::fit);
|
||||||
|
// O点位置/字体本期 stub:connect 到一个提示(可空 lambda)。
|
||||||
|
```
|
||||||
|
> 类型匹配:`Column3DDataset` 的枚举即 `geopro::controller::AxesMode/AxesUnit/ViewDir`(同 I3dSceneView.hpp),与 `setAxesMode/setAxesUnit/applyView` 形参一致,可直接连。
|
||||||
|
|
||||||
|
- [ ] **Step 5: 接三维分析栏「切片」→ InteractionManager**
|
||||||
|
```cpp
|
||||||
|
auto* ca = drawer->colAnalysis();
|
||||||
|
QObject::connect(ca, &geopro::app::Column3DAnalysis::sliceRequested, vtkWidget,
|
||||||
|
[interactionMgr](geopro::render::interact::SliceAxis axis){
|
||||||
|
interactionMgr->addSlice(axis);
|
||||||
|
});
|
||||||
|
QObject::connect(ca, &geopro::app::Column3DAnalysis::detailRequested, &detailCtrl,
|
||||||
|
[&detailCtrl](const QString& dsId, const QString& ddCode, const QString& name){
|
||||||
|
detailCtrl.openDataset(dsId, ddCode, name);
|
||||||
|
});
|
||||||
|
// colorScale/visibility/slice CRUD 本期 stub:connect 到提示 lambda(如 statusBar 显"待实现")。
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: 编译(维度过滤接线在 Task 9,此处先空列表)**
|
||||||
|
|
||||||
|
Run: `cmd.exe /c "chcp 65001 >nul && cd /d D:\Git\lanbingtech\geopro && .\build.bat rebuild"`
|
||||||
|
Expected: 全量编译通过,exe 刷新。
|
||||||
|
|
||||||
|
- [ ] **Step 7: 用户实测清单**(用户在其终端跑)
|
||||||
|
- [ ] app 启动,中央改名「VTK视图」,左侧出现三 tab 抽屉。
|
||||||
|
- [ ] 旧「二维地图/三维视图」分段按钮已消失;左上/右上/左下三浮层消失。
|
||||||
|
- [ ] 抽屉折叠开关:点 ◀ 收起、画布变宽;点 ▶ 展开。
|
||||||
|
- [ ] 三维数据集栏工具条:坐标轴下拉/比例滑块/快捷视图 6 钮/缩放 3 钮可点(功能接通后续 Task 9 验,但点击不崩)。
|
||||||
|
- [ ] 三维分析栏右键三维体 → 出「切片▸(上下/前后/左右/任意)/色阶/显隐/详情」;右键切片 → 出「保存/保存为/导出/删除/色阶/显隐/详情」。
|
||||||
|
|
||||||
|
- [ ] **Step 8: 提交**
|
||||||
|
```bash
|
||||||
|
git add src/app/main.cpp
|
||||||
|
git commit -m "refactor(vtk): 删三浮层+分段切换,改挂三栏抽屉,接信号,中央改名VTK视图"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: dockState 版本 bump + 全屏按钮 [UI]
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/app/main.cpp`(dockState 键 1428-1442;全屏 connect)
|
||||||
|
|
||||||
|
- [ ] **Step 1: bump dock 布局版本**
|
||||||
|
|
||||||
|
`main.cpp:1430` 与 1440:把 `ui/dockState_v2` 两处改为 `ui/dockState_v3`(dock 名/结构已变,旧布局须丢弃回落默认排布;遵循 1428-1430 注释)。
|
||||||
|
|
||||||
|
- [ ] **Step 2: 全屏切换实现**
|
||||||
|
|
||||||
|
全屏 = 隐藏其余 dock,仅留目标 dock 充满 dock 区;再点还原。用 ADS `CDockWidget::toggleView(bool)`。加一个 lambda + 状态:
|
||||||
|
```cpp
|
||||||
|
// 全屏:隐藏其余 dock,仅留 target;再点还原。docks 列表见 hideDockTitleBars(733-740)。
|
||||||
|
bool* vtkFs = new bool(false); // 或用 QObject property,避免裸 new:可挂到 window
|
||||||
|
auto makeFullscreen = [dockManager](ads::CDockWidget* target, const QList<ads::CDockWidget*>& others, bool on){
|
||||||
|
for (ads::CDockWidget* d : others) d->toggleView(!on); // on→隐藏其余
|
||||||
|
Q_UNUSED(target);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
> 落地建议:用 `QToolButton::setCheckable(true)` 的全屏按钮 + `toggled(bool)` 切换;状态存按钮 checked,免裸指针。`others` = 除目标外的全部 dock(vtkDock 全屏时 others={leftDock,datasetDock,detailDock,rightDock,propDock};detailDock 全屏时 others={其余})。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 接全屏按钮**
|
||||||
|
|
||||||
|
用 `findHeaderAction(box, Glyph::Fullscreen)`(main.cpp:1016-1020 的 helper)取到 VTK视图标题头与 数据详情头里的全屏按钮,connect 到 `makeFullscreen`。VTK视图头是 Step3(Task7) 的 `viewHeader`;数据详情头是 `detailDock` 的 `wrapWithHeader`(654-663)——给它加 `{{Glyph::Fullscreen,"全屏"}}` action。
|
||||||
|
```cpp
|
||||||
|
// 数据详情头加全屏 action(修改 654-663 的 wrapWithHeader 调用,加 actions 参数)。
|
||||||
|
// 然后:
|
||||||
|
auto* vtkFsBtn = findHeaderAction(viewHeader, geopro::app::Glyph::Fullscreen);
|
||||||
|
auto* detFsBtn = findHeaderAction(detailHeaderBox, geopro::app::Glyph::Fullscreen);
|
||||||
|
// 各自 setCheckable(true) + connect(&QToolButton::toggled, ... makeFullscreen ...)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 编译**
|
||||||
|
|
||||||
|
Run: `cmd.exe /c "chcp 65001 >nul && cd /d D:\Git\lanbingtech\geopro && .\build.bat rebuild"`
|
||||||
|
Expected: 编译通过。
|
||||||
|
|
||||||
|
- [ ] **Step 5: 用户实测清单**
|
||||||
|
- [ ] 点 VTK视图标题栏右侧全屏按钮 → VTK视图充满工作区(其余 dock 隐藏);再点 → 还原。
|
||||||
|
- [ ] 点 数据详情标题栏全屏按钮 → 同理。
|
||||||
|
- [ ] 首次启动(旧布局丢弃)dock 排布为默认,无错位。
|
||||||
|
|
||||||
|
- [ ] **Step 6: 提交**
|
||||||
|
```bash
|
||||||
|
git add src/app/main.cpp
|
||||||
|
git commit -m "feat(vtk): dockState bump v3 + VTK视图/数据详情 全屏按钮(隐藏其余dock)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 9: 维度过滤接线——三栏数据集列表数据驱动 [UI+逻辑]
|
||||||
|
|
||||||
|
把"勾选对象 → 取 ds → 按维度分三栏"接通,替换 `main.cpp:891-899` 的 "grid1" 假实现。
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/app/main.cpp`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 取勾选对象的 ds 行**
|
||||||
|
|
||||||
|
现状:`checkedTmsChanged(QStringList tmIds)` → 假 "grid1"。改为:用 `WorkbenchNavController`/`repo_.loadRowsAsync` 对每个勾选 TM 取 `DsRow`,汇总。`nav` 已有 `datasetsLoaded(tmObjectId, rows, total, append)` 信号(WorkbenchNavController.hpp:52)。**最简路径**:复用 nav 的取数,但 nav 现按"单击对象"取数(selectObject),勾选多 TM 需逐个取并合并。
|
||||||
|
|
||||||
|
实现:在 `checkedTmsChanged` 槽里,对每个 tmId 调 `nav.selectObject(tmId, 2)` 不合适(会刷左下列表)。改为直接调 repo:
|
||||||
|
```cpp
|
||||||
|
// 汇总所有勾选 TM 的 ds,按维度分三栏。projectRepo 是 IAsyncProjectRepository。
|
||||||
|
QObject::connect(objectTree, &geopro::app::ObjectTreePanel::checkedTmsChanged, &window,
|
||||||
|
[&projectRepo, drawer, sceneCtrl, emptyState, &window](const QStringList& tmIds){
|
||||||
|
emptyState->setVisible(tmIds.isEmpty());
|
||||||
|
auto acc = std::make_shared<std::vector<geopro::data::DsRow>>();
|
||||||
|
auto remaining = std::make_shared<int>(tmIds.size());
|
||||||
|
if (tmIds.isEmpty()) {
|
||||||
|
drawer->col3D()->setDatasets({});
|
||||||
|
drawer->col2D()->setDatasets({});
|
||||||
|
drawer->colAnalysis()->setDatasets({});
|
||||||
|
sceneCtrl->setCheckedDatasets({});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const QString& tm : tmIds) {
|
||||||
|
// classifyType=3, pageNo=1, 大 pageSize 取整树(同 WorkbenchNavController kFetchAllPageSize)
|
||||||
|
geopro::data::NavRequest* req = projectRepo.loadRowsAsync(
|
||||||
|
currentProjectIdStdString, tm.toStdString(), /*parentConfType*/2, /*classifyType*/3, 1, 100000);
|
||||||
|
req->onDone = [acc, remaining, drawer](const geopro::data::DsPage& page){
|
||||||
|
acc->insert(acc->end(), page.rows.begin(), page.rows.end());
|
||||||
|
if (--(*remaining) == 0) {
|
||||||
|
geopro::app::DimBuckets b = geopro::app::splitByDimension(*acc);
|
||||||
|
drawer->col3D()->setDatasets(b.dim3D);
|
||||||
|
drawer->col2D()->setDatasets(b.dim2D);
|
||||||
|
drawer->colAnalysis()->setDatasets(b.analysis);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// req->onFail 同样 --remaining 并在归零时刷新(避免一个失败卡死)。
|
||||||
|
}
|
||||||
|
});
|
||||||
|
#include "app/DatasetDimension.hpp"
|
||||||
|
```
|
||||||
|
> 注:`NavRequest` 的回调字段真名见 `src/data/repo/IAsyncProjectRepository.hpp` / NavRequest 定义(onDone/onFail 或 done/failed)——落地按真实字段。`currentProjectIdStdString` 取当前项目 id(main.cpp 里已有项目 id 来源,搜 `currentProjectId`/`projectId`)。
|
||||||
|
|
||||||
|
- [ ] **Step 2: 勾选数据集 → 渲染**
|
||||||
|
|
||||||
|
三栏列表勾选 → `setCheckedDatasets`。汇总三栏勾选的 dsId:
|
||||||
|
```cpp
|
||||||
|
auto pushChecked = [drawer, sceneCtrl]{
|
||||||
|
QStringList ids;
|
||||||
|
// 收集三栏当前勾选(各栏暴露 checkedDatasetsChanged;此处也可各自直接连)
|
||||||
|
// 简化:各栏 checkedDatasetsChanged 直接 setCheckedDatasets(合并)。
|
||||||
|
};
|
||||||
|
QObject::connect(drawer->col3D(), &geopro::app::Column3DDataset::checkedDatasetsChanged,
|
||||||
|
sceneCtrl, &VtkSceneController::setCheckedDatasets);
|
||||||
|
// 若需三栏合并,改为聚合后再 setCheckedDatasets;本期可先只接 col3D(3D 渲染主路径)。
|
||||||
|
```
|
||||||
|
> 本期渲染主路径是 3D 数据集(帘面/体素/地形),故先接 `col3D` 的勾选 → `setCheckedDatasets`。2D/分析渲染随各自维度后续完善。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 编译**
|
||||||
|
|
||||||
|
Run: `cmd.exe /c "chcp 65001 >nul && cd /d D:\Git\lanbingtech\geopro && .\build.bat rebuild"`
|
||||||
|
Expected: 编译通过。
|
||||||
|
|
||||||
|
- [ ] **Step 4: 用户实测清单**
|
||||||
|
- [ ] 勾选含 ds 的对象 → 三维数据集栏列表出现 3D 维度 ds(样本 "剖面网格数据1" dd_section)。
|
||||||
|
- [ ] 勾选三维数据集栏里的 ds → 中央渲染帘面(原 grid1 路径效果)。
|
||||||
|
- [ ] 取消全部勾选 → 三栏列表清空、中央清场、引导层 emptyState 显示。
|
||||||
|
- [ ] (样本数据若无 2D/切片 ds,2D/分析栏为空属正常;可后续在 LocalSample 加样本演示。)
|
||||||
|
|
||||||
|
- [ ] **Step 5: 提交**
|
||||||
|
```bash
|
||||||
|
git add src/app/main.cpp
|
||||||
|
git commit -m "feat(vtk): 三栏数据集列表按维度过滤数据驱动(替换grid1假实现)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 10: 全量回归 + 收尾 [逻辑]
|
||||||
|
|
||||||
|
- [ ] **Step 1: 全量 build + ctest**
|
||||||
|
|
||||||
|
Run: `cmd.exe /c "chcp 65001 >nul && cd /d D:\Git\lanbingtech\geopro && .\build.bat rebuild && .\build.bat test"`
|
||||||
|
Expected: 编译通过;ctest 全绿(≥ 223/223,含新 DatasetDimension.* 2 项)。
|
||||||
|
|
||||||
|
- [ ] **Step 2: 派 cpp-reviewer 审查本分支改动**
|
||||||
|
|
||||||
|
对 `src/app/panels/columns/*`、`DatasetDimension.*`、`main.cpp` diff 跑 cpp-reviewer,修 CRITICAL/HIGH(重点:裸 new/生命周期、信号槽断连、ADS toggleView 还原正确性、role 常量硬编码)。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 对照 spec 验收**
|
||||||
|
|
||||||
|
逐条核对 `2026-06-16-vtk-3d-three-column-refactor-design.md` §0 IN 项全部落地、OUT 项为 stub/禁用。
|
||||||
|
|
||||||
|
- [ ] **Step 4: 用户最终实测**(完整走查实测清单 Task7/8/9)
|
||||||
|
|
||||||
|
- [ ] **Step 5: 提交收尾(如有修改)**
|
||||||
|
```bash
|
||||||
|
git add -A && git commit -m "chore(vtk): 三栏重构 review 修复 + 回归"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review(写计划后自查)
|
||||||
|
|
||||||
|
**Spec 覆盖:**
|
||||||
|
- A1 三栏 → Task 3-7 ✅;单一 VTK视图/删切换/改名 → Task 7 ✅;三浮层收编 → Task 7 ✅;维度过滤列表 → Task 2+9 ✅;三维数据集 4 栏位 → Task 3 ✅;二维 地图/2D视图/自定义Z → Task 4 ✅;三维分析树+两右键菜单+切片接 SliceTool → Task 5+7 ✅;全屏 → Task 1+8 ✅;自定义Z绝对高程 → Task 4(spec 已记) ✅;dock 版本 bump → Task 8 ✅。
|
||||||
|
- OUT 项(CRUD/色阶/底图/异常体/详情/任务)→ stub 信号(Task 5),不实现 ✅。
|
||||||
|
|
||||||
|
**占位符扫描:** 已用真实 API/行号;少数"读真实 role 常量/NavRequest 字段名"是**有意指向源文件**(避免硬编码错值),非 TODO。
|
||||||
|
|
||||||
|
**类型一致性:** `AxesMode/AxesUnit/ViewDir`(I3dSceneView.hpp) 跨 Task3/7 一致;`SliceAxis`(SlicePlaneMath.hpp) 跨 Task5/7 一致;`DsRow/DsPage/DimBuckets` 跨 Task2/3/9 一致;`splitByDimension` 签名 Task2 定义、Task9 使用一致。
|
||||||
|
|
||||||
|
**风险:** Task 9 的多 TM 异步汇总 + NavRequest 回调字段名是最大不确定点(落地前先读 IAsyncProjectRepository.hpp 确认);全屏 toggleView 还原需保证 dock 顺序/可见性正确(Task8 用户实测把关)。
|
||||||
|
|
@ -0,0 +1,130 @@
|
||||||
|
# VTK 三维视图「三栏结构重构」设计
|
||||||
|
|
||||||
|
- 日期:2026-06-16
|
||||||
|
- 分支:`feat/vtk-3d-view`
|
||||||
|
- 状态:**设计稿(经高保真原型逐项对齐用户反馈后定稿)**
|
||||||
|
- 上位文档:`2026-06-15-vtk-3d-supplementary-design.md`(总设计 v2)。本文是其 **A1 三栏结构** 的实现增量设计,**就 UI 形态与若干控件细节,取代总 spec §7.1 中"左侧新 dock / tab 二选一"的开放表述**。
|
||||||
|
- 高保真原型:`docs/superpowers/mockups/2026-06-16-three-column-layout.html`(深色令牌取自 `src/app/Theme.cpp`;默认即定稿方案)。
|
||||||
|
- 需求来源:`Geopro3.0 需求表.xlsx`「补充需求」页签(已逐行精读,行号见下文)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 目标与范围(用户已确认)
|
||||||
|
|
||||||
|
把当前"旧二维/三维切换 + 三个浮层"的过渡态 UI,重组为需求 A1 的**三个子列表栏**,并接通到已有的渲染/交互能力。
|
||||||
|
|
||||||
|
### 本轮 IN
|
||||||
|
1. **三栏内嵌侧栏**(VTK 视图内左侧抽屉 + 三 tab + 可折叠);删除旧「二维地图/三维视图」互斥切换;中央 dock 改名 **「VTK视图」**。
|
||||||
|
2. **收编三浮层**:左上 `layerPanel`(图层勾选)、右上 `axisBar`(P2 工具条)→ 并入「三维数据集」栏;左下 `sliceBar` → **删除**(切片改走三维分析右键菜单)。
|
||||||
|
3. **三维数据集栏**:工具条 4 栏位接 P2 已实现控制器;数据集列表按 `dimensionOf` 过滤 3D ds + 勾选 → 接现有 `VtkSceneController` 渲染。
|
||||||
|
4. **二维数据集栏**:列表按 2D 过滤;「地图 / 2D视图」控件做出来(底图瓦片渲染留 P5,本轮控件 UI + 2D 视图 Z 平面接通)。
|
||||||
|
5. **三维分析栏**:对象→三维体模型→切片 树;两个右键菜单 UI 完整;右键「切片」(上下/前后/左右/任意) **接已有 `SliceTool`**(替代 sliceBar)。
|
||||||
|
6. **VTK视图 + 数据详情** 标题栏右侧加**全屏按钮**。
|
||||||
|
|
||||||
|
### 本轮 OUT(菜单项可见但暂 stub/禁用,留 P4/P5)
|
||||||
|
- 切片 CRUD:保存 / 保存为 / 导出 / 删除(P4,`I3dSceneRepository` 接口已留位)。
|
||||||
|
- 色阶编辑(F26 参考 Geopro 1.0,无参考,留 P4)。
|
||||||
|
- 底图瓦片渲染(P5)、异常体管理 / 三维体详情 / 任务管理(P4)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 核心架构决策(关键澄清)
|
||||||
|
|
||||||
|
**全工作台只有一个共享的中央 VTK 视图**,三栏是叠在其上的「子列表栏」,各自把数据喂进同一视图。证据(需求交叉验证):
|
||||||
|
- 行 2「**VTK视图上**提供三个子列表栏」;行 11「显示在 VTK 中」;行 16「显示在 VTK 的 2D 视图」;行 19「显示在 vtk 的三维视图」——三处同指一个 VTK。
|
||||||
|
- 需求行 36「VTK视图」挂在「三维分析」下,**只是把视图交互(选中拖动旋转 / 双击正视)归类描述**,不代表视图归三维分析独有。
|
||||||
|
|
||||||
|
**推论(本轮落地):**
|
||||||
|
- 旧「二维地图/三维视图」互斥分段按钮(`main.cpp:308-316` 一带)**删除**。
|
||||||
|
- 2D 不再是独立视图模式,而是 VTK 视图里的一个 **2D 图层/平面**(底图 + 2D 数据,摆在某 Z 平面),由「二维数据集」栏控制。
|
||||||
|
- 中央 dock 名 `二维地图/三维视图` → **`VTK视图`**(同时承载 2D/3D,且为需求原文叫法)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 三栏物理形态(方案 C·视图内嵌侧栏)
|
||||||
|
|
||||||
|
- 三栏 = **VTK 视图内左侧的抽屉式侧栏**,三个 tab:`三维数据集 / 二维数据集 / 三维分析`,一次显一栏。
|
||||||
|
- 画布在侧栏右侧、**不被遮挡**;侧栏右缘有折叠开关(◀/▶),折叠后画布全宽。
|
||||||
|
- 侧栏与画布同属 VTK 视图容器(侧栏是视图子控件,符合"VTK视图上提供")。
|
||||||
|
- 左侧保留现有 `ObjectTreePanel`(「对象」dock)作为**筛选来源**:三栏列表只在"被勾选对象"范围内、按维度过滤显示 ds(需求行 10/15)。两级关系:勾对象 → 三栏按维度显示其 ds。
|
||||||
|
- 现有左下「数据集」dock(详情查看)保留,与三栏列表并存(总 spec §7.1 已定的"两条线")。
|
||||||
|
|
||||||
|
> 取舍记录:曾考虑"左侧独立 dock + tab"(方案 A)与"竖向分段"(方案 B),均被否——需求「VTK视图**上**」明确栏在视图内;浮窗式(最初的 C)遮挡画面亦否,改为抽屉式。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 各栏内容(逐行对齐需求,含控件形态)
|
||||||
|
|
||||||
|
### 3.1 三维数据集栏(行 3–11)
|
||||||
|
工具条 = **4 个分组栏位** + 数据集列表:
|
||||||
|
|
||||||
|
1. **坐标轴设置**(行 4–6)——表单式,每项一行、左对齐:
|
||||||
|
- 显示方式:下拉(标准 / 三维立体 / 不显示)
|
||||||
|
- O点位置:按钮(弹框设原点)
|
||||||
|
- 刻度:下拉(无刻度 / 米 / 英尺 / 经纬度)
|
||||||
|
- 字体:按钮(设刻度文字字体)
|
||||||
|
2. **水平/垂直比例**(行 7)——**单个拖动滑块 + 数值**(如 2.0×,纵向放大系数;现有 `kVerticalExaggeration`)。**非两个独立控件**。
|
||||||
|
3. **快捷视图**(行 8)——6 钮:前 / 后 / 左 / 右 / 上 / 下。
|
||||||
|
4. **缩放 Zoom**(行 9)——3 钮:放大(In) / 缩小(Out) / 适配(Fit)。
|
||||||
|
5. **数据集列表**(行 10–11)——`dimensionOf==Dim3D` 过滤勾选对象的 ds,勾选→渲染。
|
||||||
|
|
||||||
|
### 3.2 二维数据集栏(行 12–16)= 3 栏位
|
||||||
|
1. **地图**(行 13)——下拉(天地图 / Google Map / 隐藏)。**底图瓦片渲染留 P5**。
|
||||||
|
2. **2D视图**(行 14)——下拉(关闭 / Z=0 / 顶部 / 底部 / **自定义**);选「自定义」显数值输入框。
|
||||||
|
- **「自定义 Z」= 世界绝对高程(米),向上为正**,与「Z=0/顶部/底部」同坐标系(`GeoLocalFrame` 世界 Z)。决策依据:同一下拉里「Z=0」即绝对值,「自定义」只是输入任意绝对 Z;与业界(Petrel/Leapfrog/ParaView)水平面高度用项目 CRS 绝对高程一致。
|
||||||
|
3. **数据集列表**(行 15–16)——`dimensionOf==Dim2D` 过滤,勾选→渲染到 VTK 的 2D 视图。
|
||||||
|
|
||||||
|
### 3.3 三维分析栏(行 17–35)
|
||||||
|
- **数据集列表 = 树**(行 18):对象 → 三维体模型数据集 → 切片。可勾选三维体/切片 → 渲染(行 19)。
|
||||||
|
- **右键菜单(按节点类型分派):**
|
||||||
|
|
||||||
|
**三维体数据集**(行 20–27,**直接项,无"创建切片"父级,无删除**):
|
||||||
|
| 菜单项 | 说明 | 本轮 |
|
||||||
|
|---|---|---|
|
||||||
|
| 切片 ▸ 上下 / 前后 / 左右 / 任意 | 一级「切片」父菜单 + 二级方向(上下/前后/左右=固定角度;任意=初始 45°可调)| **接已有 SliceTool** |
|
||||||
|
| 色阶 | 参考 Geopro 1.0 | OUT(stub/禁用)|
|
||||||
|
| 显示 / 隐藏 | actor 可见性 | IN |
|
||||||
|
| 数据详情 | 详情栏显示 | IN(接现有详情)|
|
||||||
|
|
||||||
|
> 注:需求字面是"上下切片/前后切片…"直接项;本轮按用户决策归入「切片」一级父菜单、二级去「切片」二字显「上下/前后/左右/任意」。
|
||||||
|
|
||||||
|
**切片数据集**(行 28–35,**有删除**):
|
||||||
|
| 菜单项 | 本轮 |
|
||||||
|
|---|---|
|
||||||
|
| 保存 / 保存为 / 导出 / 删除 | OUT(P4 CRUD,stub/禁用)|
|
||||||
|
| 色阶 | OUT |
|
||||||
|
| 显示 / 隐藏 | IN |
|
||||||
|
| 数据详情 | IN |
|
||||||
|
|
||||||
|
- **VTK视图交互**(行 36–38)、**切片分析**(行 39–51,含视图内切片右键:创建异常/保存/导出图片/导出dat/正视图/视图翻转/关闭)——属交互层,多已在 P3 实现或留 P4,本轮不在树结构范围内。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 全屏功能(新需求)
|
||||||
|
|
||||||
|
- 在 **「VTK视图」** 与 **「数据详情」** 两个 dock 标题栏右侧各加一个**全屏切换按钮**:点击→该视图充满工作区;再点→还原。
|
||||||
|
- 理由:两视图含图形、内容多,常需全屏操作。
|
||||||
|
- 实现方向(ADS):给 `CDockWidget` 标题栏插入自定义 `QToolButton`,切换时把该 dock 最大化覆盖 dock 管理区(隐藏同级 / 浮动后最大化,择一,落地时定)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 代码触点(落地指引,细节进 plan)
|
||||||
|
|
||||||
|
- `src/app/main.cpp::buildWorkbench`:
|
||||||
|
- 删 `layerPanel` / `axisBar` / `sliceBar` 三浮层及其锚定器(`RightTopAnchor`/`BottomLeftAnchor`)与 `showLayerPanel` 显隐逻辑。
|
||||||
|
- 删旧「二维地图/三维视图」分段切换;`vtkDock` 改名「VTK视图」。
|
||||||
|
- VTK 视图容器内新建**抽屉侧栏**(QTabWidget 或自绘,三 tab)+ 折叠开关;三栏工具条/列表迁入。
|
||||||
|
- **dock 布局持久化版本号须 bump**(见 `main.cpp:1429` 附近注释:改 dock 名/结构要升版本,否则旧布局反序列化错位)。
|
||||||
|
- 三维数据集工具条接 P2 已实现的 `VtkSceneController` 坐标轴/比例/快捷视图/zoom 槽(原 axisBar 的接线迁移,不重写控制器)。
|
||||||
|
- 数据集列表:用 `I3dSceneRepository::dimensionOf` 过滤;勾选信号接 `VtkSceneController`(复用现 `checkedTmsChanged` 一路的编排)。
|
||||||
|
- 三维分析树右键「切片」→ 调已有 `InteractionManager`/`SliceTool` 建切片(替代 sliceBar 原按钮路径)。
|
||||||
|
- 全屏按钮:ADS 标题栏自定义按钮 + 最大化/还原。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 待定 / 风险
|
||||||
|
|
||||||
|
- 全屏在 ADS 的最大化实现方式(隐藏同级 vs 浮动最大化)落地时定。
|
||||||
|
- 二维「地图」底图本轮仅控件,渲染 P5;需确保控件状态能持久化到 P5 不返工。
|
||||||
|
- 三栏侧栏与 ADS dock 的交互(侧栏是 VTK 容器子控件,不是 dock)——确保折叠/全屏时布局正确。
|
||||||
|
- dock 名变更后旧用户布局失效(bump 版本号后回落默认排布,可接受)。
|
||||||
|
|
@ -0,0 +1,341 @@
|
||||||
|
# 原版 Web 系统「子页面嵌入挂载」最小侵入改造设计
|
||||||
|
|
||||||
|
- 日期:2026-06-17
|
||||||
|
- 状态:**设计稿 v2(已经 opus 双评审 + 代码实测修订,可据以实现)**
|
||||||
|
- 涉及仓库:
|
||||||
|
- **Web 端(被改造方)**:`D:\Git\lanbingtech\commercial-admin`(Vue3 + Vite + Arco,hash 路由)
|
||||||
|
- **客户端(接入方)**:`D:\Git\lanbingtech\geopro`(Qt + `QWebEngineView`)
|
||||||
|
- 需求背景:客户端需把原版 web 系统的**单个子功能页**(如"系统管理"下的组织/用户/角色,以及项目空间下的数据视图等)**裸挂**进客户端窗口,不带原系统的"标题栏 + 左菜单 + 页签"外壳,复用既有页面与鉴权。
|
||||||
|
- 设计约束(用户已确认):
|
||||||
|
1. **尽可能少改动原有代码**——既有函数保持行为零变化,优先"新增"而非"修改"。
|
||||||
|
2. token 注入:客户端用 `QWebEngineProfile` 预置 localStorage(首选)或 `loadStarted` 注入,token **不进 URL**。
|
||||||
|
3. 挂载范围:**同时支持租户空间页面(`space=2`)与项目空间页面(`space=3`,需 `projectId`)**。
|
||||||
|
|
||||||
|
> v2 修订摘要(详见 §10):EmbedLayout 必须写成**懒加载函数**(否则被 `cloneDeep` 拷坏);`generateEmbedRoutes` 必须定义在 **store 闭包内**;**独立 `QWebEngineProfile` 由建议升为强制**,并在生成路由前清理 pinia 持久化残留;token 注入首选 profile 预置。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 为什么不能直接挂 URL(约束根因,已对照代码)
|
||||||
|
|
||||||
|
| 阻碍 | 证据 | 含义 |
|
||||||
|
|---|---|---|
|
||||||
|
| 所有业务页面都是 `Layout` 子路由 | `stores/modules/route.js` `transformComponentView`:`'layout'→Layout`;`layout/index.vue` = Asider+Header+Tabs+Main | 任何路径渲染都带整套外壳 |
|
||||||
|
| 业务路由运行时才动态注册 | `router/guard.js` `beforeEach` → `generateRoutes()`/`generateSpaceRoutes()` → `router.addRoute` | 不跑守卫,目标路由不存在,直接访问 404 |
|
||||||
|
| 进入需登录态 + 空间上下文 | `getToken()` 读 `localStorage['token']`;`space==2/3`;项目空间依赖 `projectStore.projectId`(`computed(projectItem.id)`) | 必须先备好 token / projectId 再生成路由 |
|
||||||
|
|
||||||
|
> 结论:裸挂子页 = 必须同时解决 **①去外壳、②触发动态路由生成、③补登录态/projectId** 三件事。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 总体方案:新增「embed 引导入口 + EmbedLayout」,叠加而非改造
|
||||||
|
|
||||||
|
新增固定路由 `/embed` 作为引导页:读 URL 参数 → 备好上下文 → 调用**新增的** `generateEmbedRoutes`(用 `EmbedLayout` 包裹,无外壳)→ `router.replace` 到目标子页。正常登录链路**完全不经过**这些新增物。
|
||||||
|
|
||||||
|
### 1.1 嵌入 URL 规范(统一入口,参数驱动)
|
||||||
|
|
||||||
|
hash 路由下统一入口为 `/#/embed`,目标子页靠 query 指定:
|
||||||
|
|
||||||
|
```
|
||||||
|
http://<host>/#/embed?space=<2|3>&projectId=<pid>&target=<encodeURIComponent(叶子页路径)>
|
||||||
|
```
|
||||||
|
|
||||||
|
| 参数 | 必填 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `space` | 是 | `2`=租户空间,`3`=项目空间 |
|
||||||
|
| `projectId` | 仅 `space=3` | 项目空间页面所属项目 id |
|
||||||
|
| `target` | 是 | 叶子菜单路由路径,经 `encodeURIComponent` 编码 |
|
||||||
|
|
||||||
|
示例:
|
||||||
|
- 系统管理·用户列表(租户):`http://<host>/#/embed?space=2&target=%2ForganiMange%2FuserList`
|
||||||
|
- 项目空间·数据视图:`http://<host>/#/embed?space=3&projectId=123&target=%2FprojectSpace%2FdataView`
|
||||||
|
|
||||||
|
> token 不进 URL(由客户端 profile 预置注入,见 §4)。`target` 即 `/organiMange/userList` 这类原叶子菜单路径,`encodeURIComponent` 后 `/`→`%2F`。
|
||||||
|
|
||||||
|
### 1.2 改造性质:一次性框架级,非逐页改造
|
||||||
|
|
||||||
|
§2 列的 5 处改动全部是**框架级**(路由层 + 一个引导页 + 一个空壳布局),**与任何具体业务页无关**。改造完成后:
|
||||||
|
|
||||||
|
- **嵌入任意叶子菜单页 = 只改 URL 的 `target`/`space`/`projectId`**,业务页代码一行不动。
|
||||||
|
- 不存在"为某个页面单独做嵌入适配"的工作。
|
||||||
|
|
||||||
|
唯一两个"非纯 URL"例外且均已收敛、非逐页:
|
||||||
|
- **D 类**(极少数读 `currentSpace` 的页):引导页对 `space=2` **统一**补 `getEnterpriseUserInfoFun`(§3.2),一处兜底覆盖,非逐页。
|
||||||
|
- **详情页带参**:属行点击详情、**不在叶子菜单范围**(§11)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 改动分级(核心:既有逻辑零修改)
|
||||||
|
|
||||||
|
| 项 | 文件 | 性质 | 是否改既有逻辑 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| ① 新增 `EmbedLayout.vue` | `commercial-admin/src/layout/EmbedLayout.vue` | 全新文件 | 否 |
|
||||||
|
| ② 新增引导页 `embed/index.vue` | `commercial-admin/src/views/embed/index.vue` | 全新文件 | 否 |
|
||||||
|
| ③ 注册 `/embed` 固定路由 | `commercial-admin/src/router/route.js` | 在 `constantRoutes` **插入一项(404 兜底之前)** | 否(不动既有项) |
|
||||||
|
| ④ 新增 `generateEmbedRoutes` | `commercial-admin/src/stores/modules/route.js` | **store 闭包内新增函数** + 给 `formatAsyncRoutes` 加 `export` | 既有函数体零修改 |
|
||||||
|
| ⑤ guard 顶部早返回 | `commercial-admin/src/router/guard.js` | **唯一的"修改"**:`beforeEach` 顶部加 `if(embed) return` | 既有分支原样保留 |
|
||||||
|
| ⑥ 客户端注入 token + 拼 URL + 独立 profile | `geopro` 客户端新增 `QWebEngineView` | 客户端侧新增 | 否 |
|
||||||
|
|
||||||
|
> 唯一动到既有执行路径的是 ⑤,做成"顶部早返回"。验收基线:**不带 `/embed`、且 `EMBED_MODE` 未点亮时,执行路径与改造前一致**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Web 端详细设计
|
||||||
|
|
||||||
|
### 3.1 ① `EmbedLayout.vue`(新文件)
|
||||||
|
|
||||||
|
仅保留 `<a-config-provider>` + `<router-view>`,**不引入** Asider/Header/Tabs/DkFooter,**也不要 keep-alive**(`cacheList` 由 Tabs 组件填充,embed 无 Tabs 故恒为空,keep-alive 是死代码,去掉更简单——见 §10 M2)。
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<a-config-provider :locale="arcoLocale">
|
||||||
|
<a-layout class="embed-main">
|
||||||
|
<router-view v-slot="{ Component, route }">
|
||||||
|
<component :is="Component" v-if="Component" :key="route.path" />
|
||||||
|
</router-view>
|
||||||
|
</a-layout>
|
||||||
|
</a-config-provider>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { arcoLocales } from '@/plugins/locales/i18n.js'
|
||||||
|
defineOptions({ name: 'EmbedLayout' })
|
||||||
|
const { locale } = useI18n()
|
||||||
|
const arcoLocale = computed(() => arcoLocales[locale.value])
|
||||||
|
</script>
|
||||||
|
<style scoped>.embed-main{width:100%;height:100%;overflow:hidden}</style>
|
||||||
|
```
|
||||||
|
|
||||||
|
> `useI18n`/`arcoLocales` 是全局插件,裸挂可用(评审已核实)。
|
||||||
|
|
||||||
|
### 3.2 ② 引导页 `views/embed/index.vue`(新文件)
|
||||||
|
|
||||||
|
无业务渲染(仅 loading/错误占位)。`onMounted` 内按序引导,**关键顺序**:先清理 pinia 持久化残留 → 写 `projectItem.id`(项目空间)→ 生成路由 → replace。
|
||||||
|
|
||||||
|
```js
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem('EMBED_MODE', '1') // 点亮 embed 标志(仅本 profile/tab)
|
||||||
|
const { space, projectId, target } = route.query // token 已由客户端预置/注入 localStorage
|
||||||
|
const sp = Number(space)
|
||||||
|
|
||||||
|
// ① 清理持久化残留,防止跨会话/复用 profile 时脏读(见 §10 S3/M3)
|
||||||
|
routeStore.$reset()
|
||||||
|
projectStore.$reset()
|
||||||
|
|
||||||
|
// ② 设置空间标识(廉价、无害,避免子页/外壳读到错误值)
|
||||||
|
routeStore.isProjectSpace = (sp === 3)
|
||||||
|
|
||||||
|
await userStore.getInfo() // 复用既有;401 时 /auth/user/info 被拦截器排除→仅 reject
|
||||||
|
|
||||||
|
if (sp === 3) {
|
||||||
|
projectStore.projectItem.id = projectId // 先写!generateEmbedRoutes(3) 读 projectId=computed(id)
|
||||||
|
} else {
|
||||||
|
await appStore.getEnterpriseUserInfoFun() // space=2 默认补企业信息(写 currentSpace),覆盖"企业空间"类叶子页
|
||||||
|
}
|
||||||
|
await routeStore.generateEmbedRoutes(sp) // 新增函数
|
||||||
|
|
||||||
|
router.replace(decodeURIComponent(target)) // 进入目标子页,EmbedLayout 生效
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[embed] bootstrap failed:', e)
|
||||||
|
// 显示错误占位,不主动跳登录(embed 无登录交互)
|
||||||
|
errorMsg.value = '页面加载失败,请检查登录态或权限'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 ③ 注册 `/embed`(`route.js`,插入 404 兜底之前)
|
||||||
|
|
||||||
|
`constantRoutes` 中 `/:pathMatch(.*)*` 是兜底项,`/embed` 必须**插在它之前**(精确路由优先,避免匹配歧义):
|
||||||
|
|
||||||
|
```js
|
||||||
|
export const constantRoutes = [
|
||||||
|
{ path: '/redirect', /* ...既有不动... */ },
|
||||||
|
{
|
||||||
|
path: '/embed',
|
||||||
|
name: 'Embed',
|
||||||
|
component: () => import('@/views/embed/index.vue'),
|
||||||
|
meta: { hidden: false },
|
||||||
|
},
|
||||||
|
{ path: '/:pathMatch(.*)*', /* 404 兜底,保持在最后 */ },
|
||||||
|
{ path: '/403', /* ... */ },
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 ④ `generateEmbedRoutes`(`route.js` **store 闭包内**新增)
|
||||||
|
|
||||||
|
复用既有 `formatAsyncRoutes`/`flatMultiLevelRoutes`,**仅把顶层路由的 `component` 由 `Layout` 换成 `EmbedLayout`**(动态路由顶层节点恒为外壳包裹层;`flatMultiLevelRoutes` 只展平 children、不动顶层 component——评审已核实机制成立)。
|
||||||
|
|
||||||
|
**两处必须遵守(否则跑不起来,见 §10 S1/必错-1):**
|
||||||
|
1. `EmbedLayout` 写成**懒加载函数**,与 `Layout` 一致。原因:`generateEmbedRoutes` 内沿用既有 `cloneDeep`,lodash 对**函数**按引用返回、对**组件 options 对象**会深拷贝并拷坏。静态 `import EmbedLayout` 是对象 → 必被拷坏。
|
||||||
|
2. 函数定义在 `storeSetup()` **闭包内**(与 `generateRoutes`/`generateSpaceRoutes` 并列),否则 `setRoutes`/`router`/`projectStore` 未定义(它们是闭包内符号)。
|
||||||
|
|
||||||
|
```js
|
||||||
|
// 模块顶层:给既有 formatAsyncRoutes 加 export(纯导出,零行为变化)
|
||||||
|
// 并新增懒加载的 EmbedLayout(不要静态 import!)
|
||||||
|
const EmbedLayout = () => import('@/layout/EmbedLayout.vue')
|
||||||
|
|
||||||
|
// —— 以下定义在 storeSetup() 内部,与 generateRoutes/generateSpaceRoutes 并列 ——
|
||||||
|
const generateEmbedRoutes = (space) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const p = space === 3
|
||||||
|
? getProjectRouteLimts({ projectId: projectStore.projectId })
|
||||||
|
: getUserRoute()
|
||||||
|
p.then((res) => {
|
||||||
|
try {
|
||||||
|
const tree = space === 3
|
||||||
|
? toArrayTree(res.data)
|
||||||
|
: searchTree(res.data, (i) => Number.parseInt(i.clientType) === 2)
|
||||||
|
const asyncRoutes = formatAsyncRoutes(tree) // 既有函数,零修改
|
||||||
|
asyncRoutes.forEach((r) => { r.component = EmbedLayout }) // ← 去外壳关键一步(顶层换壳)
|
||||||
|
setRoutes(asyncRoutes)
|
||||||
|
const flat = flatMultiLevelRoutes(cloneDeep(asyncRoutes)) // cloneDeep 对函数式组件按引用,安全
|
||||||
|
for (const route of flat) {
|
||||||
|
if (!isHttp(route.path) && !router.hasRoute(route.name)) router.addRoute(route)
|
||||||
|
}
|
||||||
|
resolve()
|
||||||
|
} catch (e) { reject(e) }
|
||||||
|
}).catch(reject)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// return { ...原有, generateEmbedRoutes }
|
||||||
|
```
|
||||||
|
|
||||||
|
> `generateRoutes`/`generateSpaceRoutes`/`transformComponentView` **函数体一行不动**,正常登录仍走它们(带 `Layout`)。
|
||||||
|
> `router.hasRoute(route.name)` 是对既有 `generateSpaceRoutes` 里 `hasRoute(route)`(误传对象)的修正用法,仅用于新增函数内部,不影响既有。
|
||||||
|
|
||||||
|
### 3.5 ⑤ guard 顶部早返回(`guard.js` 唯一修改)
|
||||||
|
|
||||||
|
`router.beforeEach` **最顶部**(`NProgress.start()` 之后)加:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// —— embed 嵌入模式:放行,鉴权 + 路由生成由 /embed 引导页自管 ——
|
||||||
|
if (to.path === '/embed' || sessionStorage.getItem('EMBED_MODE') === '1') {
|
||||||
|
NProgress.done()
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
既有所有分支(登录判断/空间检测/白名单/版本检测)**整段保留**。
|
||||||
|
**注意(见 §10 S2)**:早返回是 embed 下唯一放行路径——`router.replace(target)` 的二次 `beforeEach` 靠 `EMBED_MODE==='1'` 兜住,跳过 `generateSpaceRoutes`(避免再生成 Layout 版路由覆盖 EmbedLayout 版)。因此 `EMBED_MODE` 的可靠性是硬约束,必须配合独立 profile(§4)与生成前 `$reset`(§3.2)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 客户端 ↔ Web「嵌入契约」(geopro 侧)
|
||||||
|
|
||||||
|
**强制:每个 embed 视图使用独立 `QWebEngineProfile`**,与系统浏览器登录态、与正常登录链路隔离,避免 pinia 持久化(routeStore/projectStore/userStore 均 `persist` 到 localStorage)跨链路污染。
|
||||||
|
|
||||||
|
token 注入**首选 profile 预置**(彻底消除时序竞态),`loadStarted` 注入为备选:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// 首选:独立 profile + 预置脚本,在文档创建早期写 localStorage
|
||||||
|
auto* profile = new QWebEngineProfile(QStringLiteral("geopro-embed"), parent); // 独立 profile
|
||||||
|
QWebEngineScript s;
|
||||||
|
s.setInjectionPoint(QWebEngineScript::DocumentCreation);
|
||||||
|
s.setWorldId(QWebEngineScript::MainWorld);
|
||||||
|
s.setSourceCode(QStringLiteral(
|
||||||
|
"localStorage.setItem('token','%1');"
|
||||||
|
"localStorage.setItem('refleshToken','%2');").arg(token, refleshToken));
|
||||||
|
profile->scripts()->insert(s);
|
||||||
|
auto* page = new QWebEnginePage(profile, parent);
|
||||||
|
auto* view = new QWebEngineView(parent);
|
||||||
|
view->setPage(page);
|
||||||
|
|
||||||
|
// 目标页/空间/项目id 走 query(非敏感);hash 路由下 query 在 # 之后
|
||||||
|
// 租户(系统管理类): http://<host>/#/embed?space=2&target=%2ForganiMange%2FuserList
|
||||||
|
// 项目空间(数据视图): http://<host>/#/embed?space=3&projectId=<pid>&target=%2FprojectSpace%2FdataView
|
||||||
|
view->setUrl(QUrl(url));
|
||||||
|
```
|
||||||
|
|
||||||
|
约定:`target` 用 `encodeURIComponent` 编码(含 `/`),引导页 `decodeURIComponent` 还原。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 关键边界与风险(已对照代码确认)
|
||||||
|
|
||||||
|
1. **项目空间生成顺序**:`generateEmbedRoutes(3)` → `getProjectRouteLimts({projectId: projectStore.projectId})`,`projectId=computed(projectItem.id)`。**必须先 `projectItem.id=` 再生成**,否则拉空菜单 → `target` 404。`projectItem` 是 reactive,直接赋值即触发 computed(评审已背书)。
|
||||||
|
2. **target 须在该账号权限内**:后端按角色过滤菜单,无权限页生成后仍无该路由 → 仍 404。属权限问题。
|
||||||
|
3. **EMBED_MODE 隔离(安全关键)**:用 `sessionStorage`,**严禁持久化**;并**强制独立 profile**。否则普通访问可能误进 embed 分支、跳过鉴权(见 §3.5/§10 S3)。
|
||||||
|
4. **pinia 持久化残留**:`routeStore`/`projectStore`/`userStore` 均 `persist`(localStorage)。引导页生成前必须 `$reset`,否则复用 profile 时回灌旧 projectId / 旧路由表(§10 M3)。
|
||||||
|
5. **后端零改动**:复用 `getUserRoute`/`getProjectRouteLimts`,不碰接口。
|
||||||
|
6. **token 时序**:profile 预置脚本在 DocumentCreation 注入,早于 Vue 初始化与首个守卫,无竞态(优于 `loadStarted`,见 §10 可能-1)。
|
||||||
|
7. **token 失效行为**:`http.js:70` 仅对**非** `/auth/user/info` 的 401 弹"重新登录"并跳 `/login`。embed 下 token 失效会弹标准 401 框→无外壳的登录页;属可接受边界,由客户端决定是否重新引导。
|
||||||
|
8. **外壳态依赖**:`isProjectSpace`/`currentSpace` 仅外壳组件与"企业空间"专页消费(已 grep 确认)。普通业务子页裸挂安全;目标若为企业空间专页(`enterpriseManage/enterpriseSpace`、`setting/profile` 等),引导页需补 `appStore.getEnterpriseUserInfoFun()`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 非目标(本轮 OUT)
|
||||||
|
|
||||||
|
- 不改造外壳组件本身;不为子页做独立 Vite 打包入口;不在 web 端做 token 刷新的嵌入式交互;不用"CSS 隐藏外壳"临时方案(外壳仍实例化、有副作用,已否决)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 验收标准
|
||||||
|
|
||||||
|
1. **回归基线**:不带 `/embed` 且 `EMBED_MODE` 未点亮的所有访问行为与改造前一致;`generateRoutes`/`generateSpaceRoutes`/`transformComponentView`/`formatAsyncRoutes` 函数体未改(仅 `formatAsyncRoutes` 加 `export`)。
|
||||||
|
2. **租户空间挂载**:`#/embed?space=2&target=%2ForganiMange%2FuserList` 渲染用户列表且**无外壳**,数据正常。
|
||||||
|
3. **项目空间挂载**:`#/embed?space=3&projectId=<pid>&target=%2FprojectSpace%2FdataView` 正常渲染、projectId 正确。
|
||||||
|
4. **token 不外泄**:URL/历史/日志无 token;localStorage token 由 profile 预置成功。
|
||||||
|
5. **隔离**:普通浏览器新 tab 访问业务页,`EMBED_MODE` 不存在、守卫照常生效。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 回滚
|
||||||
|
|
||||||
|
新增物删除即回滚;guard 早返回分支删除即恢复。无数据/接口副作用。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 实现顺序
|
||||||
|
|
||||||
|
1. Web:EmbedLayout.vue → embed/index.vue → route.js 注册 + `generateEmbedRoutes`(闭包内、懒加载壳)→ guard 早返回。
|
||||||
|
2. 浏览器手验 `space=2`(console 预置 `localStorage.token` + `sessionStorage.EMBED_MODE=1` 后访问 `#/embed?...`)。
|
||||||
|
3. 验 `space=3`(有效 projectId)。
|
||||||
|
4. 客户端独立 profile + 预置脚本接入,端到端联调。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 评审修正记录(opus 双评审 + commercial-admin 代码实测)
|
||||||
|
|
||||||
|
**已采纳为硬性修正(不改跑不起来):**
|
||||||
|
- **S1 / 必错-2(闭包作用域)**:`generateEmbedRoutes` 定义在 `storeSetup()` 内(`route.js:103-183` 区间),否则 `setRoutes`/`router`/`projectStore` ReferenceError。→ 已落到 §3.4。
|
||||||
|
- **必错-1(cloneDeep 拷坏组件)**:`EmbedLayout` 必须懒加载函数 `() => import()`。lodash `cloneDeep` 对作为对象属性的**函数按引用返回**(故原系统 `Layout=()=>import()` 正常),对**静态导入的组件 options 对象会深拷贝**并破坏 Vue 内部标识。→ 已落到 §3.4。
|
||||||
|
- **S3(持久化污染 + 隔离)**:`routeStore/projectStore/userStore` 均 `persist`(localStorage)。EmbedLayout 路由会被持久化,复用 profile 时污染正常链路/反之。→ 独立 profile 升为**强制**(§4),生成前 `$reset`(§3.2)。
|
||||||
|
- **M3(projectId 脏读)**:`dataView` setup 阶段快照式读 `projectId`(`views/projectSpace/dataView/index.vue:132`),叠加 persist 可能回灌旧值。→ `projectStore.$reset()` + 先赋值后生成(§3.2)。
|
||||||
|
- **token 时序(可能-1)**:`loadStarted` 的 `runJavaScript` 异步排队,不保证早于首个守卫。→ 改用 profile 预置脚本 DocumentCreation 注入为首选(§4/§5.6)。
|
||||||
|
- **/embed 插入位置(需确认-4)**:插在 `/:pathMatch(.*)*` 之前(§3.3)。
|
||||||
|
- **keep-alive(M2)**:EmbedLayout 去掉 keep-alive(cacheList 恒空)(§3.1)。
|
||||||
|
|
||||||
|
**已核实成立、予以背书(原 spec 正确):**
|
||||||
|
- 顶层 `component` 换 EmbedLayout 去外壳机制成立(`formatAsyncRoutes` 顶层节点 component 即外壳,`flatMultiLevelRoutes` 不动顶层 component)。
|
||||||
|
- `projectStore.projectItem.id = projectId` 触发 computed,先赋值后生成"必要且充分"。
|
||||||
|
- `searchTree`/`toArrayTree` 用法与既有 `generateRoutes`/`generateSpaceRoutes` 一致。
|
||||||
|
- `useI18n`/`arcoLocales` 全局可用。
|
||||||
|
- 回归基线(仅 `formatAsyncRoutes` 加 `export` + guard 顶部分支)可验证。
|
||||||
|
|
||||||
|
**结论**:方向正确、工作量可控;上述硬性修正纳入 v2 后,**可据本 spec 进入实现**。"必错-1/必错-2"是两个不改连跑都跑不起来的点,实现时务必遵守 §3.4 两条约束。
|
||||||
|
|
||||||
|
**已核实安全(原列为"现场确认",实为静态可查,已查清):**
|
||||||
|
- 外壳 provide/inject 依赖:`grep` 确认 `src/layout` + `App.vue` **零 `provide()`**;外壳对事件总线**零 emit**。两个首批目标页 `organiMange/userList.vue`、`projectSpace/dataView/index.vue` 自身**既不 `inject()` 也不订阅任何 bus**。现存 `inject()` 仅在 `projectSpace/datasetInfo` 子树(colorLevel/contourLevel/GprHeader),是页面内部父子 provide,与外壳无关。`coustomEventBus`(`utils/event-bus.js`)定义后全仓无消费方。→ **裸挂这两页对外壳上下文依赖为 0,安全**。其他目标页若纳入,按同样三步静态核对(外壳 provide?页面 inject?总线发射方是否在外壳?)即可,无需等运行时。
|
||||||
|
|
||||||
|
**实现前必须用真实菜单接口数据核验(唯一未离线证实项):**
|
||||||
|
- **顶层菜单 component 恒为 `'layout'`**(见 §11 E 类):`generateEmbedRoutes` 换壳逻辑 `asyncRoutes.forEach(r => r.component = EmbedLayout)` 假设每个顶层路由都是外壳包裹层。需抓一次真实 `getUserRoute`(space=2)与 `getProjectRouteLimts`(space=3)响应,确认所有顶层节点 `component === 'layout'`(而非直接页面或 `ParentView`)。若存在非 layout 顶层,换壳会渲染空白 → 改为"仅替换 component 原为 'layout' 的顶层节点"。
|
||||||
|
|
||||||
|
**仍需留意:**
|
||||||
|
- embed 路由 name 与正常路由同名:独立 profile 下 embed SPA 不会生成 Layout 版路由,无冲突;若未来同一 SPA 既登录又 embed,需给 embed 路由 name 加前缀。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 覆盖范围:所有叶子菜单可导航页面(已代码实测)
|
||||||
|
|
||||||
|
**结论:本方案完整覆盖"所有叶子菜单可导航到的页面"。** 因为叶子菜单导航 = 只给路径,进入所需上下文仅 `path + space + projectId`,恰为 embed 契约所提供;且这些页路由与正常登录一致生成,仅顶层外壳由 `Layout` 换为 `EmbedLayout`。
|
||||||
|
|
||||||
|
### 为何成立(实测依据)
|
||||||
|
- **叶子菜单页不带行参数**:靠 `?id=` 进入的详情页(`projectMange/projectConfiguration/details`、`templateManage/details`、`datasetInfo` 等)是从列表页**行点击 `router.push` 带参**进入的(证据:`projectMange/configuration.vue:91`、`projectSpace/templateManage/index.vue:105`),**不属于叶子菜单**,不在本范围内。
|
||||||
|
- **项目空间叶子页自取数据**:`abnormalBody/List`、`dataMange`、`projectStructure` 等自调 `getGsTreeFun`/`queryProjectGsStruct`(证据:`grep` 命中 `src/views/projectSpace/*`),不依赖前序页面预加载的 store → 可独立裸挂。
|
||||||
|
- **页内向自身详情下钻仍可用**:叶子页里行点击 push 到详情页时,该详情路由**也已在同空间一次性生成(EmbedLayout 版)**,故详情同样无外壳渲染。
|
||||||
|
|
||||||
|
### 边界(非阻断,已收敛)
|
||||||
|
- **D 类(极少数读外壳态的叶子页)**:如"企业空间"读 `currentSpace`。引导页对 `space=2` 默认补 `appStore.getEnterpriseUserInfoFun()`(§3.2 已落),`isProjectSpace` 亦在引导页设置 → 覆盖。
|
||||||
|
- **C 类(仅当嵌入页内"跨空间/回首页")**:单个叶子页本身不受影响;只有离开本页、跨租户↔项目空间或回工作台的跳转会脱离 embed 语境。属"离开该叶子页"的范畴,非"该叶子页能否嵌"。
|
||||||
|
- **E 类(唯一结构性前提)**:换壳假设顶层菜单 component 恒为 `'layout'`。本系统所有业务页现均带外壳,强烈暗示成立;但需用真实菜单接口数据核验(见 §10「实现前必须核验」)。
|
||||||
|
- **F 类(不适合/无意义)**:login/redirect/error;以及"导航中枢"类(工作台、项目列表,职责即切空间/进项目,单独嵌无意义)。这些通常也不是普通叶子功能菜单。
|
||||||
|
|
@ -50,6 +50,10 @@ add_executable(geopro_desktop WIN32
|
||||||
panels/chart/ContourPlotItem.cpp
|
panels/chart/ContourPlotItem.cpp
|
||||||
panels/chart/LivePanner.cpp
|
panels/chart/LivePanner.cpp
|
||||||
panels/chart/ScatterHoverTip.cpp
|
panels/chart/ScatterHoverTip.cpp
|
||||||
|
panels/columns/Column2DDataset.cpp
|
||||||
|
panels/columns/Column3DDataset.cpp
|
||||||
|
panels/columns/Column3DAnalysis.cpp
|
||||||
|
panels/columns/ColumnDrawer.cpp
|
||||||
panels/AnomalyTablePanel.cpp
|
panels/AnomalyTablePanel.cpp
|
||||||
panels/LoadingOverlay.cpp
|
panels/LoadingOverlay.cpp
|
||||||
panels/DatasetDetailPage.cpp
|
panels/DatasetDetailPage.cpp
|
||||||
|
|
@ -60,14 +64,16 @@ add_executable(geopro_desktop WIN32
|
||||||
ImportDatasetDialog.cpp
|
ImportDatasetDialog.cpp
|
||||||
ExportDatasetDialog.cpp
|
ExportDatasetDialog.cpp
|
||||||
SettingsDialog.cpp
|
SettingsDialog.cpp
|
||||||
Logging.cpp)
|
Logging.cpp
|
||||||
|
DatasetDimension.cpp
|
||||||
|
TileBasemap.cpp)
|
||||||
|
|
||||||
target_include_directories(geopro_desktop PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
|
target_include_directories(geopro_desktop PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
|
||||||
# QtKeychain 经 FetchContent 接入,头文件不随 target 传播,显式加源/构建目录(含生成的 export 头)。
|
# QtKeychain 经 FetchContent 接入,头文件不随 target 传播,显式加源/构建目录(含生成的 export 头)。
|
||||||
target_include_directories(geopro_desktop PRIVATE ${qtkeychain_SOURCE_DIR} ${qtkeychain_BINARY_DIR})
|
target_include_directories(geopro_desktop PRIVATE ${qtkeychain_SOURCE_DIR} ${qtkeychain_BINARY_DIR})
|
||||||
|
|
||||||
target_link_libraries(geopro_desktop PRIVATE
|
target_link_libraries(geopro_desktop PRIVATE
|
||||||
Qt6::Core Qt6::Gui Qt6::Widgets Qt6::Svg
|
Qt6::Core Qt6::Gui Qt6::Widgets Qt6::Svg Qt6::Network
|
||||||
Qt6::WebEngineWidgets Qt6::WebEngineQuick
|
Qt6::WebEngineWidgets Qt6::WebEngineQuick
|
||||||
${VTK_LIBRARIES}
|
${VTK_LIBRARIES}
|
||||||
ads::qt6advanceddocking
|
ads::qt6advanceddocking
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
#include "DatasetDimension.hpp"
|
||||||
|
|
||||||
|
namespace geopro::app {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
// 与 LocalSample3dRepository::dimensionOf 同一映射(spec §6.1)。
|
||||||
|
enum class Dim { D3, D2, Analysis, Other };
|
||||||
|
Dim dimOf(const std::string& c) {
|
||||||
|
if (c == "dd_voxel" || c == "dd_Structual3D" || c == "dd_Property3D" ||
|
||||||
|
c == "dd_section" || c == "dd_inversion_data")
|
||||||
|
return Dim::D3;
|
||||||
|
if (c == "dd_slice") return Dim::Analysis;
|
||||||
|
if (c == "dd_trajectory_data") return Dim::D2;
|
||||||
|
return Dim::Other;
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
DimBuckets splitByDimension(const std::vector<geopro::data::DsRow>& rows) {
|
||||||
|
DimBuckets b;
|
||||||
|
for (const auto& r : rows) {
|
||||||
|
switch (dimOf(r.ddCode)) {
|
||||||
|
case Dim::D3: b.dim3D.push_back(r); break;
|
||||||
|
case Dim::D2: b.dim2D.push_back(r); break;
|
||||||
|
case Dim::Analysis: b.analysis.push_back(r); break;
|
||||||
|
case Dim::Other: break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace geopro::app
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
#pragma once
|
||||||
|
#include <vector>
|
||||||
|
#include "repo/RepoTypes.hpp"
|
||||||
|
|
||||||
|
namespace geopro::app {
|
||||||
|
|
||||||
|
struct DimBuckets {
|
||||||
|
std::vector<geopro::data::DsRow> dim3D;
|
||||||
|
std::vector<geopro::data::DsRow> dim2D;
|
||||||
|
std::vector<geopro::data::DsRow> analysis;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 按 ddCode 把 ds 分流到 三维数据集 / 二维数据集 / 三维分析 三栏。
|
||||||
|
// Other 维度不入任何栏(保留原顺序)。
|
||||||
|
DimBuckets splitByDimension(const std::vector<geopro::data::DsRow>& rows);
|
||||||
|
|
||||||
|
} // namespace geopro::app
|
||||||
|
|
@ -65,6 +65,14 @@ QString svgPathFor(Glyph t)
|
||||||
"<path d='M7 10l5 5 5-5'/><path d='M12 15V3'/>");
|
"<path d='M7 10l5 5 5-5'/><path d='M12 15V3'/>");
|
||||||
case Glyph::Collapse:
|
case Glyph::Collapse:
|
||||||
return QStringLiteral("<path d='m17 11-5-5-5 5'/><path d='m17 18-5-5-5 5'/>");
|
return QStringLiteral("<path d='m17 11-5-5-5 5'/><path d='m17 18-5-5-5 5'/>");
|
||||||
|
case Glyph::Fullscreen:
|
||||||
|
return QStringLiteral(
|
||||||
|
"<path d='M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3"
|
||||||
|
"M21 16v3a2 2 0 0 1-2 2h-3M8 21H5a2 2 0 0 1-2-2v-3'/>");
|
||||||
|
case Glyph::ChevronLeft:
|
||||||
|
return QStringLiteral("<path d='m15 18-6-6 6-6'/>");
|
||||||
|
case Glyph::ChevronRight:
|
||||||
|
return QStringLiteral("<path d='m9 18 6-6-6-6'/>");
|
||||||
case Glyph::Workspace:
|
case Glyph::Workspace:
|
||||||
return QStringLiteral(
|
return QStringLiteral(
|
||||||
"<rect width='7' height='7' x='3' y='3' rx='1'/>"
|
"<rect width='7' height='7' x='3' y='3' rx='1'/>"
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,10 @@ enum class Glyph {
|
||||||
Filter, // 筛选(漏斗)
|
Filter, // 筛选(漏斗)
|
||||||
Upload, // 上传
|
Upload, // 上传
|
||||||
Download, // 导出/下载
|
Download, // 导出/下载
|
||||||
Collapse, // 折叠(双箭头)
|
Collapse, // 折叠(双箭头)
|
||||||
|
Fullscreen, // 全屏 / 最大化
|
||||||
|
ChevronLeft, // 折叠抽屉(向左)
|
||||||
|
ChevronRight, // 展开抽屉(向右)
|
||||||
// 顶部应用栏图标
|
// 顶部应用栏图标
|
||||||
Workspace, // 工作空间(2x2 宫格)
|
Workspace, // 工作空间(2x2 宫格)
|
||||||
Folder, // 项目(文件夹)
|
Folder, // 项目(文件夹)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,539 @@
|
||||||
|
#include "TileBasemap.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstring>
|
||||||
|
#include <utility>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <QDebug>
|
||||||
|
#include <QImage>
|
||||||
|
#include <QNetworkReply>
|
||||||
|
#include <QNetworkRequest>
|
||||||
|
#include <QString>
|
||||||
|
#include <QUrl>
|
||||||
|
|
||||||
|
#include <vtkActor.h>
|
||||||
|
#include <vtkCallbackCommand.h>
|
||||||
|
#include <vtkCamera.h>
|
||||||
|
#include <vtkCommand.h>
|
||||||
|
#include <vtkDataArray.h>
|
||||||
|
#include <vtkImageData.h>
|
||||||
|
#include <vtkInteractorObserver.h>
|
||||||
|
#include <vtkNew.h>
|
||||||
|
#include <vtkPlaneSource.h>
|
||||||
|
#include <vtkPointData.h>
|
||||||
|
#include <vtkPolyData.h>
|
||||||
|
#include <vtkPolyDataMapper.h>
|
||||||
|
#include <vtkPoints.h>
|
||||||
|
#include <vtkProperty.h>
|
||||||
|
#include <vtkRenderWindow.h>
|
||||||
|
#include <vtkRenderWindowInteractor.h>
|
||||||
|
#include <vtkRenderer.h>
|
||||||
|
#include <vtkTexture.h>
|
||||||
|
|
||||||
|
#include "Scene.hpp"
|
||||||
|
#include "geo/GeoLocalFrame.hpp"
|
||||||
|
#include "ground/TileMath.hpp"
|
||||||
|
|
||||||
|
namespace geopro::app {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
// 天地图 WMTS 令牌(与轨迹图 trajectory_map.html 同源)。
|
||||||
|
const char* kTk = "aca91d8c9f59a4f779f39061b8a07737";
|
||||||
|
constexpr int kRootZoom = 9; // 四叉树根层级(单块~78km,±1 覆盖~234km 到天边)
|
||||||
|
constexpr double kTargetPx = 384.0; // 瓦片屏幕像素阈值:超过则细分(越小越清晰但块更多)
|
||||||
|
constexpr int kMaxLeaves = 200; // 一次覆盖的叶瓦片上限(安全兜底,防细分爆炸)
|
||||||
|
// 底图最大距离按剖面范围动态定:半径×倍数,夹在[下限,上限]。随勾选增删自动伸缩;无数据时用下限。
|
||||||
|
constexpr double kRangeFactor = 10.0;
|
||||||
|
constexpr double kRangeFloor = 2000.0; // 至少 2km(小剖面也有足够地理背景)
|
||||||
|
constexpr double kRangeCeil = 30000.0; // 最多 30km(防远裁剪面失控)
|
||||||
|
constexpr int kMaxConcurrent = 12; // 瓦片请求最大并发(天地图 8 子域+Mapbox,适度提高吞吐)
|
||||||
|
constexpr int kMinZoom = 3;
|
||||||
|
constexpr int kMaxZoom = 18;
|
||||||
|
constexpr double kGroundZ = 0.0; // 底图置于 z=0 地面参考(剖面深度向下为负,落其下)
|
||||||
|
constexpr double kZEps = 0.02; // 每层级 Z 微偏移:高层级压上面,避免共面瓦片 z-fighting
|
||||||
|
constexpr int kHardCap = 400; // 瓦片硬上限:超过则即便未落地也强制清理,兜底内存
|
||||||
|
constexpr double kPi = 3.14159265358979323846;
|
||||||
|
constexpr double kTerrainOpacity = 0.55; // 地形半透明:地下剖面可从任意角度透过地面看到(不再被遮挡)
|
||||||
|
|
||||||
|
// 地面起伏:Mapbox terrain-RGB DEM 瓦片(原版 web 同款源,全球 CDN,比 AWS Terrarium 快)。
|
||||||
|
// 公式 elev(米) = -10000 + (R*65536 + G*256 + B)*0.1。数据到 z15,更高层级取祖先块。
|
||||||
|
// kMapboxToken:原版 commercial-admin 的 Mapbox 公开 token(pk.*,客户端用,同 天地图 tk 性质)。
|
||||||
|
const char* kMapboxToken =
|
||||||
|
"pk.eyJ1IjoidGJ1c2FuIiwiYSI6ImNtZjY2emZneDBkY24ybXB4cmpvdmwzNWYifQ.h6tcQ380WN5AW6fZr08how";
|
||||||
|
constexpr int kDemMaxZoom = 15;
|
||||||
|
constexpr int kTerrainGrid = 32; // 每瓦片网格分辨率(33x33 顶点)
|
||||||
|
|
||||||
|
// key 打包:z<<44 | x<<22 | y(z≤18, x/y<2^18 < 2^22)。
|
||||||
|
void unpackKey(long long key, int& z, int& x, int& y) {
|
||||||
|
z = static_cast<int>(key >> 44);
|
||||||
|
x = static_cast<int>((key >> 22) & 0x3FFFFF);
|
||||||
|
y = static_cast<int>(key & 0x3FFFFF);
|
||||||
|
}
|
||||||
|
|
||||||
|
// QImage → vtkTexture:转 RGBA + 垂直翻转,使纹理 v=0 对应瓦片南边(与 PlaneSource tcoord 一致)。
|
||||||
|
vtkSmartPointer<vtkTexture> makeTexture(const QImage& img) {
|
||||||
|
const QImage rgba = img.convertToFormat(QImage::Format_RGBA8888);
|
||||||
|
const int w = rgba.width(), h = rgba.height();
|
||||||
|
if (w <= 0 || h <= 0) return nullptr;
|
||||||
|
vtkNew<vtkImageData> vimg;
|
||||||
|
vimg->SetDimensions(w, h, 1);
|
||||||
|
vimg->AllocateScalars(VTK_UNSIGNED_CHAR, 4);
|
||||||
|
for (int row = 0; row < h; ++row) {
|
||||||
|
const uchar* src = rgba.scanLine(h - 1 - row);
|
||||||
|
auto* dst = static_cast<uchar*>(vimg->GetScalarPointer(0, row, 0));
|
||||||
|
std::memcpy(dst, src, static_cast<size_t>(w) * 4);
|
||||||
|
}
|
||||||
|
auto tex = vtkSmartPointer<vtkTexture>::New();
|
||||||
|
tex->SetInputData(vimg);
|
||||||
|
tex->InterpolateOn(); // 双线性
|
||||||
|
tex->MipmapOn(); // 缩小/斜视不闪烁、不糊
|
||||||
|
tex->SetMaximumAnisotropicFiltering(16); // 斜视角下纹理保持清晰
|
||||||
|
tex->EdgeClampOn(); // 边缘夹紧,避免相邻瓦片接缝渗色
|
||||||
|
return tex;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Terrarium 像素解码高程:(fx,fy)∈[0,1],fy=0 北/顶行。
|
||||||
|
double demElev(const QImage& dem, double fx, double fy) {
|
||||||
|
const int w = dem.width(), h = dem.height();
|
||||||
|
if (w <= 0 || h <= 0) return 0.0;
|
||||||
|
const int px = std::clamp(static_cast<int>(std::lround(fx * (w - 1))), 0, w - 1);
|
||||||
|
const int py = std::clamp(static_cast<int>(std::lround(fy * (h - 1))), 0, h - 1);
|
||||||
|
const QRgb c = dem.pixel(px, py);
|
||||||
|
return -10000.0 + (qRed(c) * 65536.0 + qGreen(c) * 256.0 + qBlue(c)) * 0.1; // Mapbox terrain-RGB
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
long long TileBasemap::tileKey(int z, int x, int y) {
|
||||||
|
return (static_cast<long long>(z) << 44) | (static_cast<long long>(x) << 22) |
|
||||||
|
static_cast<long long>(y);
|
||||||
|
}
|
||||||
|
|
||||||
|
TileBasemap::TileBasemap(geopro::render::Scene& scene, vtkRenderWindow* rw,
|
||||||
|
std::shared_ptr<geopro::core::GeoLocalFrame> frame, QObject* parent)
|
||||||
|
: QObject(parent), scene_(scene), rw_(rw), frame_(std::move(frame)) {}
|
||||||
|
|
||||||
|
void TileBasemap::requestRender() {
|
||||||
|
// 合并渲染:同一事件循环轮次内的多次请求只渲染一帧(标准做法,避免逐瓦片重复 Render 卡顿)。
|
||||||
|
if (renderPending_) return;
|
||||||
|
renderPending_ = true;
|
||||||
|
QMetaObject::invokeMethod(
|
||||||
|
this,
|
||||||
|
[this]() {
|
||||||
|
renderPending_ = false;
|
||||||
|
// 渲染前更新裁剪面:把异步刚落地的瓦片纳入近/远裁剪范围,否则它们会被切(屏幕暗带)。
|
||||||
|
if (auto* ren = scene_.renderer()) ren->ResetCameraClippingRange();
|
||||||
|
if (rw_) rw_->Render();
|
||||||
|
},
|
||||||
|
Qt::QueuedConnection);
|
||||||
|
}
|
||||||
|
|
||||||
|
TileBasemap::~TileBasemap() {
|
||||||
|
if (styleObs_ && observer_) styleObs_->RemoveObserver(observer_);
|
||||||
|
}
|
||||||
|
|
||||||
|
void TileBasemap::ensureObserver() {
|
||||||
|
if (styleObs_) return;
|
||||||
|
if (!rw_) return;
|
||||||
|
auto* iren = rw_->GetInteractor();
|
||||||
|
if (!iren) return;
|
||||||
|
auto* style = iren->GetInteractorStyle(); // EndInteractionEvent 由交互样式发出
|
||||||
|
if (!style) return;
|
||||||
|
styleObs_ = style;
|
||||||
|
observer_ = vtkSmartPointer<vtkCallbackCommand>::New();
|
||||||
|
observer_->SetClientData(this);
|
||||||
|
observer_->SetCallback(&TileBasemap::onInteractionEnd);
|
||||||
|
styleObs_->AddObserver(vtkCommand::EndInteractionEvent, observer_);
|
||||||
|
}
|
||||||
|
|
||||||
|
void TileBasemap::onInteractionEnd(vtkObject*, unsigned long, void* clientData, void*) {
|
||||||
|
if (auto* self = static_cast<TileBasemap*>(clientData)) self->refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
void TileBasemap::enqueueGet(const QString& url, std::function<void(QNetworkReply*)> onDone) {
|
||||||
|
netQueue_.push_back({url, std::move(onDone)});
|
||||||
|
pumpNetQueue();
|
||||||
|
}
|
||||||
|
|
||||||
|
void TileBasemap::pumpNetQueue() {
|
||||||
|
while (netInFlight_ < kMaxConcurrent && !netQueue_.empty()) {
|
||||||
|
const PendingGet req = std::move(netQueue_.front());
|
||||||
|
netQueue_.pop_front();
|
||||||
|
++netInFlight_;
|
||||||
|
QNetworkReply* reply = nam_.get(QNetworkRequest(QUrl(req.url)));
|
||||||
|
auto cb = req.cb;
|
||||||
|
connect(reply, &QNetworkReply::finished, this, [this, reply, cb]() {
|
||||||
|
cb(reply); // 回调内部 deleteLater + 处理
|
||||||
|
--netInFlight_;
|
||||||
|
pumpNetQueue();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void TileBasemap::hide() { show(Hidden); }
|
||||||
|
|
||||||
|
void TileBasemap::show(Kind kind) {
|
||||||
|
ensureObserver();
|
||||||
|
++generation_; // 旧回包(含换源前的层)按 generation 丢弃
|
||||||
|
for (auto& kv : placed_) scene_.renderer()->RemoveViewProp(kv.second);
|
||||||
|
placed_.clear();
|
||||||
|
inFlight_.clear();
|
||||||
|
netQueue_.clear(); // 丢弃换源前排队中的请求(在途的按 gen 自然作废)
|
||||||
|
desired_.clear();
|
||||||
|
// demCache_/texCache_ 跨隐藏-重选保留 → 重选地图秒出,不重拉。
|
||||||
|
terrainProbed_ = false;
|
||||||
|
kind_ = kind;
|
||||||
|
if (kind == Hidden) {
|
||||||
|
requestRender();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
refresh(); // 四叉树覆盖:近细远粗一次铺满(地形按真实高程,与剖面同系)
|
||||||
|
}
|
||||||
|
|
||||||
|
void TileBasemap::setVerticalExaggeration(double ve) {
|
||||||
|
if (ve <= 0.0 || ve == ve_) return;
|
||||||
|
ve_ = ve;
|
||||||
|
if (kind_ != Hidden) show(kind_); // 重建地形(高程×新VE),与剖面 VE 保持一致
|
||||||
|
}
|
||||||
|
|
||||||
|
void TileBasemap::refineTile(int z, int x, int y, std::set<long long>& out, int& count) {
|
||||||
|
if (count >= kMaxLeaves) { out.insert(tileKey(z, x, y)); return; } // 安全上限:停止细分
|
||||||
|
const int n = 1 << z;
|
||||||
|
if (x < 0 || y < 0 || x >= n || y >= n) return;
|
||||||
|
|
||||||
|
const geopro::render::LonLatBox b = geopro::render::tileBounds(z, x, y);
|
||||||
|
const auto sw = frame_->toLocal(b.south, b.west);
|
||||||
|
const auto ne = frame_->toLocal(b.north, b.east);
|
||||||
|
|
||||||
|
// 视锥剔除:瓦片 AABB 全在某侧面外侧 → 不在视野内,丢弃(否则屏幕外乱细分耗尽预算)。
|
||||||
|
// 只用 4 个侧面(左右上下),不用近/远裁剪面——远裁剪面随已加载几何变化,
|
||||||
|
// 首帧底图未齐时远面贴得近会误剔除远处可见瓦片(等多久都不出、微动才出)。
|
||||||
|
const double zmin = -1000.0, zmax = 1000.0; // 地形起伏远小于瓦片尺度,给宽松 z 带
|
||||||
|
for (int p = 0; p < 4; ++p) {
|
||||||
|
const double* pl = &frustum_[p * 4]; // 内法向:内侧 a·x+b·y+c·z+d ≥ 0
|
||||||
|
const double vx = pl[0] >= 0 ? ne.x : sw.x; // 取最朝法向的角点(p-vertex)
|
||||||
|
const double vy = pl[1] >= 0 ? ne.y : sw.y;
|
||||||
|
const double vz = pl[2] >= 0 ? zmax : zmin;
|
||||||
|
if (pl[0] * vx + pl[1] * vy + pl[2] * vz + pl[3] < 0.0) return; // 全在外 → 剔除
|
||||||
|
}
|
||||||
|
|
||||||
|
const double cx = (sw.x + ne.x) * 0.5, cy = (sw.y + ne.y) * 0.5; // 瓦片中心(局部米)
|
||||||
|
const double g = std::max(std::abs(ne.x - sw.x), std::abs(ne.y - sw.y)); // 瓦片地面尺寸(米)
|
||||||
|
|
||||||
|
// 距离上限(按剖面范围动态):数据中心在局部原点(0,0);瓦片离它太远则不加载——远裁剪面有界
|
||||||
|
// (剖面不被近裁剪面切),也避免拉远无限铺。叶块本身可大于此距离(其近端仍在范围内即保留)。
|
||||||
|
if (std::sqrt(cx * cx + cy * cy) - g * 0.5 > maxTileDist_) return;
|
||||||
|
|
||||||
|
// 该瓦片投影到屏幕的近似像素尺寸 > 阈值且未到最大层级 → 细分为 4 子块(近处更细)。
|
||||||
|
double screenPx;
|
||||||
|
if (projParallel_) {
|
||||||
|
screenPx = g * projK_; // 平行投影:projK_ = H/(2·parallelScale)
|
||||||
|
} else {
|
||||||
|
const double dx = cx - camX_, dy = cy - camY_, dz = -camZ_; // 相对相机(瓦片 z≈0)
|
||||||
|
const double dist = std::max(1.0, std::sqrt(dx * dx + dy * dy + dz * dz));
|
||||||
|
screenPx = g * projK_ / dist; // 透视:projK_ = H/(2·tan(vfov/2))
|
||||||
|
}
|
||||||
|
// 细分条件:屏幕上太大 → 细分(近细远粗);或瓦片本身比允许范围还大 → 也强制细分,
|
||||||
|
// 否则拉到最远时一块巨瓦(如 78km)正好盖住数据中心、过不了距离剔除 → 覆盖超大面积。
|
||||||
|
if ((screenPx > kTargetPx || g > maxTileDist_) && z < kMaxZoom) {
|
||||||
|
refineTile(z + 1, 2 * x, 2 * y, out, count);
|
||||||
|
refineTile(z + 1, 2 * x + 1, 2 * y, out, count);
|
||||||
|
refineTile(z + 1, 2 * x, 2 * y + 1, out, count);
|
||||||
|
refineTile(z + 1, 2 * x + 1, 2 * y + 1, out, count);
|
||||||
|
} else {
|
||||||
|
out.insert(tileKey(z, x, y));
|
||||||
|
++count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void TileBasemap::refresh() {
|
||||||
|
if (kind_ == Hidden || refreshing_) return;
|
||||||
|
refreshing_ = true;
|
||||||
|
|
||||||
|
auto* ren = scene_.renderer();
|
||||||
|
auto* cam = ren ? ren->GetActiveCamera() : nullptr;
|
||||||
|
if (!cam) { refreshing_ = false; return; }
|
||||||
|
|
||||||
|
const int* sz = ren->GetSize();
|
||||||
|
const double H = (sz && sz[1] > 0) ? sz[1] : 800.0;
|
||||||
|
double camPos[3];
|
||||||
|
cam->GetPosition(camPos);
|
||||||
|
camX_ = camPos[0]; camY_ = camPos[1]; camZ_ = camPos[2];
|
||||||
|
projParallel_ = cam->GetParallelProjection();
|
||||||
|
projK_ = projParallel_ ? H / (2.0 * std::max(1.0, cam->GetParallelScale()))
|
||||||
|
: H / (2.0 * std::tan(cam->GetViewAngle() * 0.5 * kPi / 180.0));
|
||||||
|
const double aspect = (sz && sz[1] > 0) ? double(sz[0]) / double(sz[1]) : 1.0;
|
||||||
|
cam->GetFrustumPlanes(aspect, frustum_); // 6 视锥面(供 refineTile 剔除屏幕外瓦片)
|
||||||
|
// 用焦点(必在视锥内)统一各面方向为"内侧≥0",规避 VTK 法向内/外约定差异(否则可能全剔成黑屏)。
|
||||||
|
double fp[3];
|
||||||
|
cam->GetFocalPoint(fp);
|
||||||
|
for (int p = 0; p < 6; ++p) {
|
||||||
|
double* pl = &frustum_[p * 4];
|
||||||
|
if (pl[0] * fp[0] + pl[1] * fp[1] + pl[2] * fp[2] + pl[3] < 0.0)
|
||||||
|
for (int k = 0; k < 4; ++k) pl[k] = -pl[k];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 底图最大距离按当前剖面合并范围动态定(随勾选增删自动伸缩);无数据用下限。
|
||||||
|
maxTileDist_ = kRangeFloor;
|
||||||
|
if (dataRadiusProvider_) {
|
||||||
|
const double r = dataRadiusProvider_();
|
||||||
|
if (r > 0.0) maxTileDist_ = std::clamp(r * kRangeFactor, kRangeFloor, kRangeCeil);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 四叉树:从数据中心一圈粗根块出发,按屏幕误差细分 → 近细远粗、铺满视野,无单层级盲区。
|
||||||
|
desired_.clear();
|
||||||
|
int count = 0;
|
||||||
|
const auto c = frame_->toLatLon(0.0, 0.0); // 数据中心
|
||||||
|
const geopro::render::TileXY root = geopro::render::lonLatToTile(c.lon, c.lat, kRootZoom);
|
||||||
|
for (int dy = -1; dy <= 1; ++dy)
|
||||||
|
for (int dx = -1; dx <= 1; ++dx)
|
||||||
|
refineTile(kRootZoom, root.x + dx, root.y + dy, desired_, count);
|
||||||
|
|
||||||
|
// 拉取缺失瓦片:按离相机距离排序,最近的先拉 → 用户正看的区域最先出现(而非粗/远块先出)。
|
||||||
|
std::vector<std::pair<double, long long>> todo;
|
||||||
|
for (long long key : desired_) {
|
||||||
|
if (placed_.count(key) || inFlight_.count(key)) continue;
|
||||||
|
int z, x, y;
|
||||||
|
unpackKey(key, z, x, y);
|
||||||
|
const geopro::render::LonLatBox b = geopro::render::tileBounds(z, x, y);
|
||||||
|
const auto sw = frame_->toLocal(b.south, b.west);
|
||||||
|
const auto ne = frame_->toLocal(b.north, b.east);
|
||||||
|
const double cx = (sw.x + ne.x) * 0.5 - camX_, cy = (sw.y + ne.y) * 0.5 - camY_;
|
||||||
|
todo.push_back({cx * cx + cy * cy, key});
|
||||||
|
}
|
||||||
|
std::sort(todo.begin(), todo.end());
|
||||||
|
for (const auto& t : todo) {
|
||||||
|
int z, x, y;
|
||||||
|
unpackKey(t.second, z, x, y);
|
||||||
|
fetchTile(z, x, y, t.second);
|
||||||
|
}
|
||||||
|
|
||||||
|
purgeStale();
|
||||||
|
ren->ResetCameraClippingRange(); // 交互后扩裁剪面以含新载入的底图瓦片(防被"蒙版"切)
|
||||||
|
requestRender();
|
||||||
|
refreshing_ = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void TileBasemap::purgeStale() {
|
||||||
|
// 仅当本轮所有请求都落地(inFlight 空)后再删旧层;否则缩放/平移期间老瓦片留作回退,避免空白闪烁。
|
||||||
|
// 超过硬上限则强制清理兜底内存(可能短暂空白,极少触发)。
|
||||||
|
if (!inFlight_.empty() && placed_.size() <= static_cast<size_t>(kHardCap)) return;
|
||||||
|
bool removed = false;
|
||||||
|
for (auto it = placed_.begin(); it != placed_.end();) {
|
||||||
|
if (desired_.find(it->first) == desired_.end()) {
|
||||||
|
scene_.renderer()->RemoveViewProp(it->second);
|
||||||
|
it = placed_.erase(it);
|
||||||
|
removed = true;
|
||||||
|
} else {
|
||||||
|
++it;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (removed) requestRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
void TileBasemap::fetchTile(int z, int x, int y, long long key) {
|
||||||
|
// 命中影像缓存 → 不走网络,直接落地(DEM 多半也已缓存)。重选地图/缩放回看即秒出。
|
||||||
|
auto cit = texCache_.find(key);
|
||||||
|
if (cit != texCache_.end()) {
|
||||||
|
inFlight_.insert(key);
|
||||||
|
auto tex = cit->second;
|
||||||
|
if (kind_ == Satellite) {
|
||||||
|
fetchTerrain(z, x, y, key, tex);
|
||||||
|
} else {
|
||||||
|
placeActor(key, buildFlat(z, x, y, tex));
|
||||||
|
inFlight_.erase(key);
|
||||||
|
purgeStale();
|
||||||
|
requestRender();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QString layerDir = (kind_ == Satellite) ? QStringLiteral("img_w") : QStringLiteral("vec_w");
|
||||||
|
const QString layer = (kind_ == Satellite) ? QStringLiteral("img") : QStringLiteral("vec");
|
||||||
|
const int sub = (x + y) % 8; // 子域负载分担 t0-t7
|
||||||
|
const QString url =
|
||||||
|
QStringLiteral("http://t%1.tianditu.gov.cn/%2/wmts?service=wmts&request=GetTile"
|
||||||
|
"&version=1.0.0&LAYER=%3&tileMatrixSet=w&TileMatrix=%4&TileRow=%5"
|
||||||
|
"&TileCol=%6&style=default&format=tiles&tk=%7")
|
||||||
|
.arg(sub)
|
||||||
|
.arg(layerDir, layer)
|
||||||
|
.arg(z)
|
||||||
|
.arg(y)
|
||||||
|
.arg(x)
|
||||||
|
.arg(QString::fromLatin1(kTk));
|
||||||
|
|
||||||
|
const int gen = generation_;
|
||||||
|
inFlight_.insert(key);
|
||||||
|
enqueueGet(url, [this, key, z, x, y, gen](QNetworkReply* reply) {
|
||||||
|
reply->deleteLater();
|
||||||
|
// inFlight 保持到瓦片最终落地(起伏/平面),使旧层在新块就位前不被清理 → 无空白闪烁。
|
||||||
|
QImage img;
|
||||||
|
const bool stale = (gen != generation_) || kind_ == Hidden ||
|
||||||
|
desired_.find(key) == desired_.end() || placed_.count(key);
|
||||||
|
const bool ok = !stale && reply->error() == QNetworkReply::NoError &&
|
||||||
|
img.loadFromData(reply->readAll());
|
||||||
|
if (!ok) {
|
||||||
|
inFlight_.erase(key);
|
||||||
|
purgeStale();
|
||||||
|
requestRender();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
auto tex = makeTexture(img);
|
||||||
|
if (texCache_.size() > 1200) texCache_.clear(); // 兜底内存;在用纹理由 actor 自身保活
|
||||||
|
texCache_[key] = tex; // 缓存供重选/缩放回看复用
|
||||||
|
if (kind_ == Satellite) {
|
||||||
|
fetchTerrain(z, x, y, key, tex); // 拉 DEM 后直接落地起伏块(inFlight 续到那时)
|
||||||
|
} else {
|
||||||
|
placeActor(key, buildFlat(z, x, y, tex)); // 街道图无地形 → 直接平面
|
||||||
|
inFlight_.erase(key);
|
||||||
|
purgeStale();
|
||||||
|
requestRender();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void TileBasemap::placeActor(long long key, vtkSmartPointer<vtkActor> actor) {
|
||||||
|
if (!actor) return;
|
||||||
|
scene_.addActor(actor);
|
||||||
|
placed_[key] = actor;
|
||||||
|
}
|
||||||
|
|
||||||
|
vtkSmartPointer<vtkActor> TileBasemap::buildFlat(int z, int x, int y,
|
||||||
|
vtkSmartPointer<vtkTexture> tex) {
|
||||||
|
const geopro::render::LonLatBox b = geopro::render::tileBounds(z, x, y);
|
||||||
|
const auto sw = frame_->toLocal(b.south, b.west);
|
||||||
|
const auto se = frame_->toLocal(b.south, b.east);
|
||||||
|
const auto nw = frame_->toLocal(b.north, b.west);
|
||||||
|
const double gz = kGroundZ + (z - kMinZoom) * kZEps; // 高层级略抬高,压在旧层之上防共面闪烁
|
||||||
|
|
||||||
|
// PlaneSource 自动 tcoord:origin=SW→u 西0东1、v 南0北1(与翻转后纹理对齐)。
|
||||||
|
vtkNew<vtkPlaneSource> plane;
|
||||||
|
plane->SetOrigin(sw.x, sw.y, gz);
|
||||||
|
plane->SetPoint1(se.x, se.y, gz);
|
||||||
|
plane->SetPoint2(nw.x, nw.y, gz);
|
||||||
|
vtkNew<vtkPolyDataMapper> mapper;
|
||||||
|
mapper->SetInputConnection(plane->GetOutputPort());
|
||||||
|
auto actor = vtkSmartPointer<vtkActor>::New();
|
||||||
|
actor->SetMapper(mapper);
|
||||||
|
actor->SetTexture(tex);
|
||||||
|
actor->GetProperty()->LightingOff(); // 底图不受场景光照
|
||||||
|
actor->GetProperty()->SetOpacity(kTerrainOpacity); // 半透明:不遮挡地下剖面
|
||||||
|
// 注意:UseBounds 保持默认 true → 参与相机裁剪面计算,否则底图会被裁剪面"蒙版"切掉。
|
||||||
|
// 坐标轴/取景不被底图撑大,由 VtkSceneView 改用"数据自身包围盒"解决(非靠 UseBounds=false)。
|
||||||
|
return actor;
|
||||||
|
}
|
||||||
|
|
||||||
|
void TileBasemap::fetchTerrain(int z, int x, int y, long long key, vtkSmartPointer<vtkTexture> tex) {
|
||||||
|
// Terrarium 数据约到 z15;更高层级取覆盖本块的祖先 DEM 瓦片,按经纬采样其子区域。
|
||||||
|
const int dz = std::min(z, kDemMaxZoom);
|
||||||
|
const int shift = z - dz;
|
||||||
|
const int dx = x >> shift, dy = y >> shift;
|
||||||
|
const long long demKey = tileKey(dz, dx, dy);
|
||||||
|
|
||||||
|
// 落地一块瓦片:DEM 有效→起伏,否则→平面兜底;并推进 inFlight/清理。
|
||||||
|
auto place = [this, key, z, x, y, dz, dx, dy, tex](const QImage* dem) {
|
||||||
|
if (dem && !dem->isNull()) {
|
||||||
|
placeActor(key, buildWarped(z, x, y, dz, dx, dy, tex, *dem));
|
||||||
|
} else {
|
||||||
|
placeActor(key, buildFlat(z, x, y, tex)); // DEM 拉不到 → 平面兜底
|
||||||
|
}
|
||||||
|
inFlight_.erase(key);
|
||||||
|
purgeStale();
|
||||||
|
requestRender();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 命中缓存:同一祖先 DEM 块的多个瓦片瞬间起伏,免重复网络。
|
||||||
|
auto cached = demCache_.find(demKey);
|
||||||
|
if (cached != demCache_.end()) {
|
||||||
|
place(&cached->second);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!terrainProbed_) {
|
||||||
|
terrainProbed_ = true;
|
||||||
|
qInfo() << "[basemap] 首次拉DEM 卫星z=" << z << " → DEMz=" << dz << "(" << dx << "," << dy
|
||||||
|
<< ")";
|
||||||
|
}
|
||||||
|
// Mapbox terrain-RGB(pngraw 无损,保证高程解码准确);原版同源,全球 CDN。
|
||||||
|
const QString url =
|
||||||
|
QStringLiteral("https://api.mapbox.com/v4/mapbox.terrain-rgb/%1/%2/%3.pngraw?access_token=%4")
|
||||||
|
.arg(dz)
|
||||||
|
.arg(dx)
|
||||||
|
.arg(dy)
|
||||||
|
.arg(QString::fromLatin1(kMapboxToken));
|
||||||
|
const int gen = generation_;
|
||||||
|
enqueueGet(url, [this, key, demKey, gen, place](QNetworkReply* reply) {
|
||||||
|
reply->deleteLater();
|
||||||
|
if (gen != generation_ || kind_ != Satellite ||
|
||||||
|
desired_.find(key) == desired_.end() || placed_.count(key)) {
|
||||||
|
inFlight_.erase(key); // 过期/移出视野 → 不落地
|
||||||
|
purgeStale();
|
||||||
|
requestRender();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
QImage dem;
|
||||||
|
if (reply->error() != QNetworkReply::NoError) {
|
||||||
|
qWarning() << "[basemap] DEM 拉取失败(降级平面)" << reply->url().toString()
|
||||||
|
<< reply->errorString();
|
||||||
|
} else if (!dem.loadFromData(reply->readAll())) {
|
||||||
|
qWarning() << "[basemap] DEM 解码失败(降级平面)" << reply->url().toString();
|
||||||
|
dem = QImage();
|
||||||
|
} else {
|
||||||
|
demCache_[demKey] = dem; // 缓存供同祖先块复用
|
||||||
|
}
|
||||||
|
place(dem.isNull() ? nullptr : &dem);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
vtkSmartPointer<vtkActor> TileBasemap::buildWarped(int sz, int sx, int sy, int dz, int dx, int dy,
|
||||||
|
vtkSmartPointer<vtkTexture> tex,
|
||||||
|
const QImage& dem) {
|
||||||
|
const geopro::render::LonLatBox sb = geopro::render::tileBounds(sz, sx, sy); // 卫星块(几何)
|
||||||
|
const geopro::render::LonLatBox db = geopro::render::tileBounds(dz, dx, dy); // DEM 块(采样)
|
||||||
|
const auto sw = frame_->toLocal(sb.south, sb.west);
|
||||||
|
const auto se = frame_->toLocal(sb.south, sb.east);
|
||||||
|
const auto nw = frame_->toLocal(sb.north, sb.west);
|
||||||
|
const double base = kGroundZ + (sz - kMinZoom) * kZEps;
|
||||||
|
|
||||||
|
// PlaneSource(等距圆柱下平面插值即正确 x/y) + 自动 tcoord;再按各点真实经纬采 DEM 位移 Z。
|
||||||
|
vtkNew<vtkPlaneSource> plane;
|
||||||
|
plane->SetOrigin(sw.x, sw.y, base);
|
||||||
|
plane->SetPoint1(se.x, se.y, base);
|
||||||
|
plane->SetPoint2(nw.x, nw.y, base);
|
||||||
|
plane->SetResolution(kTerrainGrid, kTerrainGrid);
|
||||||
|
plane->Update();
|
||||||
|
|
||||||
|
auto warped = vtkSmartPointer<vtkPolyData>::New();
|
||||||
|
warped->DeepCopy(plane->GetOutput());
|
||||||
|
vtkDataArray* tc = warped->GetPointData()->GetTCoords();
|
||||||
|
vtkPoints* pts = warped->GetPoints();
|
||||||
|
const double sLonSpan = sb.east - sb.west, sLatSpan = sb.north - sb.south;
|
||||||
|
const double dLonSpan = db.east - db.west, dLatSpan = db.north - db.south;
|
||||||
|
const vtkIdType n = pts->GetNumberOfPoints();
|
||||||
|
for (vtkIdType id = 0; id < n; ++id) {
|
||||||
|
double t[2];
|
||||||
|
tc->GetTuple(id, t); // u:西0东1, v:南0北1
|
||||||
|
const double lon = sb.west + t[0] * sLonSpan;
|
||||||
|
const double lat = sb.south + t[1] * sLatSpan;
|
||||||
|
const double fx = (lon - db.west) / dLonSpan; // DEM 块内列比例
|
||||||
|
const double fy = (db.north - lat) / dLatSpan; // DEM 顶行=北 → fy
|
||||||
|
const double elev = demElev(dem, fx, fy);
|
||||||
|
double p[3];
|
||||||
|
pts->GetPoint(id, p);
|
||||||
|
p[2] = base + elev * ve_; // 真实高程×垂直夸张:与剖面(同样真实高程×VE)同系对齐
|
||||||
|
pts->SetPoint(id, p);
|
||||||
|
}
|
||||||
|
pts->Modified();
|
||||||
|
|
||||||
|
vtkNew<vtkPolyDataMapper> mapper;
|
||||||
|
mapper->SetInputData(warped);
|
||||||
|
auto actor = vtkSmartPointer<vtkActor>::New();
|
||||||
|
actor->SetMapper(mapper);
|
||||||
|
actor->SetTexture(tex);
|
||||||
|
actor->GetProperty()->LightingOff();
|
||||||
|
actor->GetProperty()->SetOpacity(kTerrainOpacity); // 半透明:不遮挡地下剖面
|
||||||
|
return actor; // UseBounds 默认 true:参与裁剪面,避免被"蒙版"切掉
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace geopro::app
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QImage>
|
||||||
|
#include <QNetworkAccessManager>
|
||||||
|
#include <QObject>
|
||||||
|
|
||||||
|
#include <deque>
|
||||||
|
#include <functional>
|
||||||
|
#include <map>
|
||||||
|
#include <memory>
|
||||||
|
#include <set>
|
||||||
|
#include <utility>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <vtkSmartPointer.h>
|
||||||
|
|
||||||
|
class vtkActor;
|
||||||
|
class vtkObject;
|
||||||
|
class vtkTexture;
|
||||||
|
class vtkRenderWindow;
|
||||||
|
class vtkInteractorObserver;
|
||||||
|
class vtkCallbackCommand;
|
||||||
|
class QNetworkReply;
|
||||||
|
namespace geopro::render { class Scene; }
|
||||||
|
namespace geopro::core { class GeoLocalFrame; }
|
||||||
|
|
||||||
|
namespace geopro::app {
|
||||||
|
|
||||||
|
// 天地图 WMTS 底图层(局部平面,B 方案)+ LOD:按相机视距自动选瓦片层级、覆盖可视范围,
|
||||||
|
// 缩放/平移结束后增量增删瓦片。复用轨迹图同款 token;瓦片经同一 GeoLocalFrame 配准。
|
||||||
|
class TileBasemap : public QObject {
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
enum Kind { Street = 0, Satellite = 1, Hidden = 2 };
|
||||||
|
TileBasemap(geopro::render::Scene& scene, vtkRenderWindow* rw,
|
||||||
|
std::shared_ptr<geopro::core::GeoLocalFrame> frame, QObject* parent = nullptr);
|
||||||
|
~TileBasemap() override;
|
||||||
|
|
||||||
|
void show(Kind kind); // 显示某底图(Hidden 等同 hide);记住类型供 LOD 刷新复用
|
||||||
|
void hide(); // 移除全部瓦片
|
||||||
|
void refresh(); // 按当前相机重算层级+覆盖,增量更新瓦片(交互结束回调)
|
||||||
|
void setVerticalExaggeration(double ve); // 地形垂向夸张(须与剖面 VE 一致才对齐)
|
||||||
|
// 数据半径提供者:刷新时查询当前所有勾选剖面的合并范围(半径,米),据此动态定底图最大范围。
|
||||||
|
void setDataRadiusProvider(std::function<double()> fn) { dataRadiusProvider_ = std::move(fn); }
|
||||||
|
|
||||||
|
private:
|
||||||
|
static long long tileKey(int z, int x, int y);
|
||||||
|
void ensureObserver(); // 首次显示时挂到交互样式的 EndInteractionEvent
|
||||||
|
void requestRender(); // 合并渲染:同一事件循环轮次多次请求只渲染一帧
|
||||||
|
void purgeStale(); // 本轮请求全部落地后再删旧层瓦片,避免缩放空白闪烁
|
||||||
|
// 四叉树细分:按瓦片投影屏幕尺寸递归(近细远粗),收集叶瓦片到 out。
|
||||||
|
void refineTile(int z, int x, int y, std::set<long long>& out, int& count);
|
||||||
|
void fetchTile(int z, int x, int y, long long key);
|
||||||
|
void fetchTerrain(int z, int x, int y, long long key,
|
||||||
|
vtkSmartPointer<vtkTexture> tex); // 拉覆盖该瓦片的 DEM(z>15 取祖先块)后落地
|
||||||
|
void placeActor(long long key, vtkSmartPointer<vtkActor> actor);
|
||||||
|
vtkSmartPointer<vtkActor> buildFlat(int z, int x, int y,
|
||||||
|
vtkSmartPointer<vtkTexture> tex); // 平面瓦片(DEM 兜底)
|
||||||
|
vtkSmartPointer<vtkActor> buildWarped(int sz, int sx, int sy, int dz, int dx, int dy,
|
||||||
|
vtkSmartPointer<vtkTexture> tex,
|
||||||
|
const QImage& dem); // DEM 位移网格 + 卫星贴图
|
||||||
|
void enqueueGet(const QString& url,
|
||||||
|
std::function<void(QNetworkReply*)> onDone); // 限并发取瓦片(回调内负责 deleteLater)
|
||||||
|
void pumpNetQueue();
|
||||||
|
static void onInteractionEnd(vtkObject*, unsigned long, void* clientData, void*);
|
||||||
|
|
||||||
|
geopro::render::Scene& scene_;
|
||||||
|
vtkRenderWindow* rw_;
|
||||||
|
std::shared_ptr<geopro::core::GeoLocalFrame> frame_;
|
||||||
|
QNetworkAccessManager nam_;
|
||||||
|
Kind kind_ = Hidden;
|
||||||
|
int generation_ = 0; // show/hide/换源 自增,丢弃过期回包
|
||||||
|
std::map<long long, vtkSmartPointer<vtkActor>> placed_; // 已贴瓦片:key→actor
|
||||||
|
std::set<long long> desired_; // 当前视野应显示的瓦片 key
|
||||||
|
std::set<long long> inFlight_; // 在途瓦片(续到起伏/平面最终落地)
|
||||||
|
std::map<long long, QImage> demCache_; // DEM 块缓存(key=DEMz/x/y),跨隐藏/重选复用
|
||||||
|
std::map<long long, vtkSmartPointer<vtkTexture>> texCache_; // 影像纹理缓存,重选/缩放回看免重拉
|
||||||
|
double ve_ = 1.0; // 地形垂向夸张(与剖面 verticalExaggeration 一致才对齐)
|
||||||
|
double maxTileDist_ = 2000.0; // 底图最大距离(米),每次刷新按剖面范围动态算
|
||||||
|
std::function<double()> dataRadiusProvider_; // 返回当前勾选剖面合并范围的半径
|
||||||
|
// 四叉树当前帧相机参数(refresh 写, refineTile 读):相机位置 + 投影系数 + 视锥 6 面。
|
||||||
|
double camX_ = 0, camY_ = 0, camZ_ = 0;
|
||||||
|
double projK_ = 1.0;
|
||||||
|
bool projParallel_ = false;
|
||||||
|
double frustum_[24] = {0}; // 6 个视锥平面(内法向),AABB 全在某面外则剔除
|
||||||
|
struct PendingGet { QString url; std::function<void(QNetworkReply*)> cb; };
|
||||||
|
std::deque<PendingGet> netQueue_; // 限并发请求队列(防瓦片暴发饱和卡死)
|
||||||
|
int netInFlight_ = 0;
|
||||||
|
bool terrainProbed_ = false; // 首次 fetchTerrain 打一行诊断日志
|
||||||
|
vtkSmartPointer<vtkInteractorObserver> styleObs_; // 持引用保证回调期有效
|
||||||
|
vtkSmartPointer<vtkCallbackCommand> observer_;
|
||||||
|
bool renderPending_ = false; // 合并渲染:同轮多次请求只渲染一帧
|
||||||
|
bool refreshing_ = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace geopro::app
|
||||||
|
|
@ -1,8 +1,17 @@
|
||||||
#include "VtkSceneView.hpp"
|
#include "VtkSceneView.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
#include <memory>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
|
|
||||||
|
#include <QDebug>
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
#include <vtkActor.h>
|
#include <vtkActor.h>
|
||||||
|
#include <vtkBoundingBox.h>
|
||||||
|
#include <vtkCubeAxesActor.h>
|
||||||
|
#include <vtkProp.h>
|
||||||
#include <vtkRenderWindow.h>
|
#include <vtkRenderWindow.h>
|
||||||
#include <vtkRenderer.h>
|
#include <vtkRenderer.h>
|
||||||
#include <vtkVolume.h>
|
#include <vtkVolume.h>
|
||||||
|
|
@ -10,6 +19,7 @@
|
||||||
#include "CameraPreset.hpp"
|
#include "CameraPreset.hpp"
|
||||||
#include "Scene.hpp"
|
#include "Scene.hpp"
|
||||||
#include "Theme.hpp"
|
#include "Theme.hpp"
|
||||||
|
#include "actors/AxesActor.hpp"
|
||||||
#include "actors/CurtainActor.hpp"
|
#include "actors/CurtainActor.hpp"
|
||||||
#include "actors/MapLineActor.hpp"
|
#include "actors/MapLineActor.hpp"
|
||||||
#include "actors/TerrainActor.hpp"
|
#include "actors/TerrainActor.hpp"
|
||||||
|
|
@ -18,42 +28,222 @@
|
||||||
|
|
||||||
namespace geopro::app {
|
namespace geopro::app {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
// 控制器层枚举 → render 层枚举(保持控制器不依赖 render)。
|
||||||
|
geopro::render::AxesMode toRenderMode(geopro::controller::AxesMode m) {
|
||||||
|
switch (m) {
|
||||||
|
case geopro::controller::AxesMode::Standard: return geopro::render::AxesMode::Standard;
|
||||||
|
case geopro::controller::AxesMode::Stereo: return geopro::render::AxesMode::Stereo;
|
||||||
|
case geopro::controller::AxesMode::None: return geopro::render::AxesMode::None;
|
||||||
|
}
|
||||||
|
return geopro::render::AxesMode::Standard;
|
||||||
|
}
|
||||||
|
geopro::render::AxesUnit toRenderUnit(geopro::controller::AxesUnit u) {
|
||||||
|
switch (u) {
|
||||||
|
case geopro::controller::AxesUnit::None: return geopro::render::AxesUnit::None;
|
||||||
|
case geopro::controller::AxesUnit::Meter: return geopro::render::AxesUnit::Meter;
|
||||||
|
case geopro::controller::AxesUnit::Feet: return geopro::render::AxesUnit::Feet;
|
||||||
|
case geopro::controller::AxesUnit::LatLon: return geopro::render::AxesUnit::LatLon;
|
||||||
|
}
|
||||||
|
return geopro::render::AxesUnit::Meter;
|
||||||
|
}
|
||||||
|
geopro::render::ViewDir toRenderViewDir(geopro::controller::ViewDir d) {
|
||||||
|
switch (d) {
|
||||||
|
case geopro::controller::ViewDir::Front: return geopro::render::ViewDir::Front;
|
||||||
|
case geopro::controller::ViewDir::Back: return geopro::render::ViewDir::Back;
|
||||||
|
case geopro::controller::ViewDir::Left: return geopro::render::ViewDir::Left;
|
||||||
|
case geopro::controller::ViewDir::Right: return geopro::render::ViewDir::Right;
|
||||||
|
case geopro::controller::ViewDir::Top: return geopro::render::ViewDir::Top;
|
||||||
|
case geopro::controller::ViewDir::Bottom: return geopro::render::ViewDir::Bottom;
|
||||||
|
}
|
||||||
|
return geopro::render::ViewDir::Front;
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
VtkSceneView::VtkSceneView(geopro::render::Scene& scene, vtkRenderWindow* renderWindow,
|
VtkSceneView::VtkSceneView(geopro::render::Scene& scene, vtkRenderWindow* renderWindow,
|
||||||
std::shared_ptr<geopro::core::GeoLocalFrame> frame, double zRefElev)
|
std::shared_ptr<geopro::core::GeoLocalFrame> frame, double zRefElev)
|
||||||
: scene_(scene),
|
: scene_(scene),
|
||||||
renderWindow_(renderWindow),
|
renderWindow_(renderWindow),
|
||||||
frame_(std::move(frame)),
|
frame_(std::move(frame)),
|
||||||
zRefElev_(zRefElev) {}
|
zRefElev_(zRefElev) {
|
||||||
|
// 近裁剪容差调小:场景含近处剖面 + 远处底图(几十km),默认容差会把近裁剪面随远面推出去、
|
||||||
|
// 切掉离相机近的剖面。调小后近面可贴近,剖面不被切(代价:远处深度精度略降,不可见层无所谓)。
|
||||||
|
scene_.renderer()->SetNearClippingPlaneTolerance(1e-5);
|
||||||
|
}
|
||||||
|
|
||||||
void VtkSceneView::clear() { scene_.clear(); }
|
void VtkSceneView::removeProps(std::vector<vtkSmartPointer<vtkProp>>& props) {
|
||||||
|
for (auto& p : props)
|
||||||
|
if (p) scene_.renderer()->RemoveViewProp(p);
|
||||||
|
props.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool VtkSceneView::computeDataBounds(double out[6]) const {
|
||||||
|
vtkBoundingBox bb;
|
||||||
|
for (const auto& kv : dsProps_)
|
||||||
|
for (const auto& p : kv.second)
|
||||||
|
if (p) { if (double* b = p->GetBounds()) bb.AddBounds(b); }
|
||||||
|
for (const auto& p : miscProps_)
|
||||||
|
if (p) { if (double* b = p->GetBounds()) bb.AddBounds(b); }
|
||||||
|
if (!bb.IsValid()) return false;
|
||||||
|
bb.GetBounds(out);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
double VtkSceneView::dataHorizontalRadius() const {
|
||||||
|
double b[6];
|
||||||
|
if (!computeDataBounds(b)) return 0.0;
|
||||||
|
const double dx = b[1] - b[0], dy = b[3] - b[2];
|
||||||
|
return 0.5 * std::sqrt(dx * dx + dy * dy); // 水平对角线半径
|
||||||
|
}
|
||||||
|
|
||||||
|
void VtkSceneView::clear() {
|
||||||
|
// 只移除数据 prop(按 ds 跟踪)+ 杂项(地形/测线)+ 坐标轴;不动底图(TileBasemap 自管)→ 重建不丢图。
|
||||||
|
for (auto& kv : dsProps_) removeProps(kv.second);
|
||||||
|
dsProps_.clear();
|
||||||
|
removeProps(miscProps_);
|
||||||
|
if (currentAxes_) {
|
||||||
|
scene_.renderer()->RemoveViewProp(currentAxes_);
|
||||||
|
currentAxes_ = nullptr;
|
||||||
|
}
|
||||||
|
// 体素 image 失效:置空并通知上层关闭切片(防切片附着到已移除的 image)。
|
||||||
|
currentVolumeImage_ = nullptr;
|
||||||
|
volumeOwnerDs_.clear();
|
||||||
|
frameAnchoredToData_ = false; // 新一轮选择重新按其首个真实剖面重锚原点
|
||||||
|
if (onVolumeChanged) onVolumeChanged();
|
||||||
|
}
|
||||||
|
|
||||||
void VtkSceneView::setVerticalExaggeration(double ve) { verticalExaggeration_ = ve; }
|
void VtkSceneView::setVerticalExaggeration(double ve) { verticalExaggeration_ = ve; }
|
||||||
|
|
||||||
void VtkSceneView::addSurveyLine(const geopro::core::Grid& grid) {
|
void VtkSceneView::addSurveyLine(const geopro::core::Grid& grid) {
|
||||||
auto line = geopro::render::buildSurveyLine(grid, *frame_);
|
auto line = geopro::render::buildSurveyLine(grid, *frame_);
|
||||||
if (line) scene_.addActor(line);
|
if (line) {
|
||||||
|
scene_.addActor(line);
|
||||||
|
miscProps_.push_back(line);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void VtkSceneView::addCurtain(const geopro::core::Grid& grid, const geopro::core::ColorScale& cs) {
|
void VtkSceneView::addCurtain(const std::string& dsId, const geopro::core::Grid& grid,
|
||||||
|
const geopro::core::ColorScale& cs) {
|
||||||
|
// 首个带经纬度的剖面到达 → 把 GeoLocalFrame 原点重锚到该剖面 lat/lon 中心:使局部坐标从 0 附近起
|
||||||
|
// (轴刻度有意义),同一选择内多条剖面共用此原点 → 相互地理配准。无经纬剖面是平面、不受原点影响。
|
||||||
|
const int nx = grid.nx();
|
||||||
|
if (!frameAnchoredToData_ && nx > 0 && static_cast<int>(grid.lat.size()) >= nx &&
|
||||||
|
static_cast<int>(grid.lon.size()) >= nx) {
|
||||||
|
double la0 = grid.lat[0], la1 = grid.lat[0], lo0 = grid.lon[0], lo1 = grid.lon[0];
|
||||||
|
for (int i = 1; i < nx; ++i) {
|
||||||
|
la0 = std::min(la0, grid.lat[i]); la1 = std::max(la1, grid.lat[i]);
|
||||||
|
lo0 = std::min(lo0, grid.lon[i]); lo1 = std::max(lo1, grid.lon[i]);
|
||||||
|
}
|
||||||
|
// 就地重锚共享 frame(不换对象)→ 同持此 frame 的底图层等随即一致对齐。
|
||||||
|
frame_->reanchor((la0 + la1) / 2.0, (lo0 + lo1) / 2.0);
|
||||||
|
frameAnchoredToData_ = true;
|
||||||
|
if (onFrameReanchored) onFrameReanchored(); // 通知底图刷新到数据位置
|
||||||
|
}
|
||||||
auto curtain = geopro::render::buildCurtain(grid, cs, *frame_);
|
auto curtain = geopro::render::buildCurtain(grid, cs, *frame_);
|
||||||
if (curtain) {
|
if (curtain) {
|
||||||
curtain->SetScale(1.0, 1.0, verticalExaggeration_); // 纵向夸张成墙
|
curtain->SetScale(1.0, 1.0, verticalExaggeration_); // 纵向夸张成墙
|
||||||
scene_.addActor(curtain);
|
scene_.addActor(curtain);
|
||||||
|
dsProps_[dsId].push_back(curtain);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void VtkSceneView::addVolume(const geopro::data::VolumeGrid& vol, const geopro::core::ColorScale& cs) {
|
void VtkSceneView::addVolume(const std::string& dsId, const geopro::data::VolumeGrid& vol,
|
||||||
|
const geopro::core::ColorScale& cs) {
|
||||||
// 纵向夸张烤进 image 的 z 原点/间距(与帘面 SetScale 同倍,保证纵向一致)。
|
// 纵向夸张烤进 image 的 z 原点/间距(与帘面 SetScale 同倍,保证纵向一致)。
|
||||||
|
// 用暴露 image 的 buildVoxel 重载:保留 currentVolumeImage_ 供 P3 切片附着(几何含 VE)。
|
||||||
|
vtkSmartPointer<vtkImageData> image;
|
||||||
auto volume = geopro::render::buildVoxel(
|
auto volume = geopro::render::buildVoxel(
|
||||||
vol.vol, cs, vol.origin[0], vol.origin[1], vol.origin[2] * verticalExaggeration_,
|
vol.vol, cs, vol.origin[0], vol.origin[1], vol.origin[2] * verticalExaggeration_,
|
||||||
vol.spacing[0], vol.spacing[1], vol.spacing[2] * verticalExaggeration_, vol.vmin, vol.vmax);
|
vol.spacing[0], vol.spacing[1], vol.spacing[2] * verticalExaggeration_, vol.vmin, vol.vmax,
|
||||||
if (volume) scene_.addViewProp(volume);
|
image);
|
||||||
|
if (volume) {
|
||||||
|
scene_.addViewProp(volume);
|
||||||
|
dsProps_[dsId].push_back(volume);
|
||||||
|
currentVolumeImage_ = image;
|
||||||
|
currentColorScale_ = cs;
|
||||||
|
currentVmin_ = vol.vmin;
|
||||||
|
currentVmax_ = vol.vmax;
|
||||||
|
volumeOwnerDs_ = dsId;
|
||||||
|
if (onVolumeChanged) onVolumeChanged();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void VtkSceneView::addTerrain(const geopro::data::TerrainPaths& paths) {
|
void VtkSceneView::addTerrain(const geopro::data::TerrainPaths& paths) {
|
||||||
auto terrain = geopro::render::buildTerrain(paths.demPath, paths.imagePath, *frame_, zRefElev_,
|
auto terrain = geopro::render::buildTerrain(paths.demPath, paths.imagePath, *frame_, zRefElev_,
|
||||||
verticalExaggeration_);
|
verticalExaggeration_);
|
||||||
if (terrain) scene_.addActor(terrain);
|
if (terrain) {
|
||||||
|
scene_.addActor(terrain);
|
||||||
|
miscProps_.push_back(terrain);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void VtkSceneView::removeDataset(const std::string& dsId) {
|
||||||
|
auto it = dsProps_.find(dsId);
|
||||||
|
if (it == dsProps_.end()) return;
|
||||||
|
removeProps(it->second);
|
||||||
|
dsProps_.erase(it);
|
||||||
|
if (volumeOwnerDs_ == dsId) { // 该 ds 的体素被移除 → 切片源失效
|
||||||
|
currentVolumeImage_ = nullptr;
|
||||||
|
volumeOwnerDs_.clear();
|
||||||
|
if (onVolumeChanged) onVolumeChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void VtkSceneView::setAxes(geopro::controller::AxesMode mode, geopro::controller::AxesUnit unit,
|
||||||
|
int fontSize) {
|
||||||
|
axesMode_ = mode;
|
||||||
|
axesUnit_ = unit;
|
||||||
|
axesFontSize_ = fontSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
void VtkSceneView::applyCameraView(geopro::controller::ViewDir dir) {
|
||||||
|
geopro::render::applyView(scene_.renderer(), toRenderViewDir(dir)); // 设朝向(内部 ResetCamera 含底图)
|
||||||
|
double bounds[6];
|
||||||
|
if (computeDataBounds(bounds))
|
||||||
|
scene_.renderer()->ResetCamera(bounds); // 重新取景到数据(否则被~公里级底图推到超远)
|
||||||
|
scene_.renderer()->ResetCameraClippingRange(); // 裁剪面含底图
|
||||||
|
if (renderWindow_) renderWindow_->Render();
|
||||||
|
if (onCameraChanged) onCameraChanged(); // 相机变了 → 底图按新视锥重算覆盖
|
||||||
|
}
|
||||||
|
|
||||||
|
void VtkSceneView::zoom(double factor) {
|
||||||
|
geopro::render::zoomBy(scene_.renderer(), factor);
|
||||||
|
if (renderWindow_) renderWindow_->Render();
|
||||||
|
if (onCameraChanged) onCameraChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
void VtkSceneView::fitView() {
|
||||||
|
double bounds[6];
|
||||||
|
if (computeDataBounds(bounds))
|
||||||
|
scene_.renderer()->ResetCamera(bounds); // 取景到数据(不含底图)
|
||||||
|
else
|
||||||
|
geopro::render::fitView(scene_.renderer());
|
||||||
|
scene_.renderer()->ResetCameraClippingRange(); // 裁剪面含底图 → 不被"蒙版"切掉
|
||||||
|
if (renderWindow_) renderWindow_->Render();
|
||||||
|
if (onCameraChanged) onCameraChanged(); // 取景后 → 底图按新视锥重算覆盖(治首帧部分瓦片不出)
|
||||||
|
}
|
||||||
|
|
||||||
|
void VtkSceneView::rebuildAxes() {
|
||||||
|
// 先移除上一次的坐标轴 prop:render 可能在一次 rebuild 内多次调用(末尾统一 render +
|
||||||
|
// 异步回灌 render),不先移除会叠加坐标轴(评审 HIGH)。移除后再算 bounds(仅数据图元)。
|
||||||
|
if (currentAxes_) {
|
||||||
|
scene_.renderer()->RemoveViewProp(currentAxes_);
|
||||||
|
currentAxes_ = nullptr;
|
||||||
|
}
|
||||||
|
// 坐标轴随数据包围盒重建:仅按数据图元算 bounds(不含底图,否则被~公里级底图撑大),
|
||||||
|
// 再造 vtkCubeAxesActor 入场。None 模式或无数据 → buildAxes 返回 nullptr,场景无坐标轴。
|
||||||
|
double bounds[6];
|
||||||
|
if (!computeDataBounds(bounds)) return; // 无数据 → 不建坐标轴
|
||||||
|
geopro::render::AxesOptions opts;
|
||||||
|
opts.mode = toRenderMode(axesMode_);
|
||||||
|
opts.unit = toRenderUnit(axesUnit_);
|
||||||
|
opts.fontSize = axesFontSize_;
|
||||||
|
opts.frame = frame_.get();
|
||||||
|
auto axes = geopro::render::buildAxes(bounds, opts, scene_.renderer());
|
||||||
|
if (axes) {
|
||||||
|
scene_.addViewProp(axes);
|
||||||
|
currentAxes_ = axes;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void VtkSceneView::render(bool is2D) {
|
void VtkSceneView::render(bool is2D) {
|
||||||
|
|
@ -61,11 +251,26 @@ void VtkSceneView::render(bool is2D) {
|
||||||
double bgR, bgG, bgB;
|
double bgR, bgG, bgB;
|
||||||
geopro::app::vtkBackground(bgR, bgG, bgB);
|
geopro::app::vtkBackground(bgR, bgG, bgB);
|
||||||
scene_.renderer()->SetBackground(bgR, bgG, bgB);
|
scene_.renderer()->SetBackground(bgR, bgG, bgB);
|
||||||
|
// 坐标轴仅三维视图显示(2D 俯视测线不需要立体坐标轴)。
|
||||||
|
if (!is2D) rebuildAxes();
|
||||||
if (is2D)
|
if (is2D)
|
||||||
geopro::render::applyTop2D(scene_.renderer());
|
geopro::render::applyTop2D(scene_.renderer());
|
||||||
else
|
else
|
||||||
geopro::render::applyFree3D(scene_.renderer());
|
geopro::render::applyFree3D(scene_.renderer());
|
||||||
scene_.renderer()->ResetCamera();
|
double bounds[6];
|
||||||
|
if (computeDataBounds(bounds))
|
||||||
|
scene_.renderer()->ResetCamera(bounds); // 取景到数据(不含底图,否则数据缩成小点)
|
||||||
|
else
|
||||||
|
scene_.renderer()->ResetCamera();
|
||||||
|
scene_.renderer()->ResetCameraClippingRange(); // 裁剪面含底图 → 不被"蒙版"切掉
|
||||||
|
if (renderWindow_) renderWindow_->Render();
|
||||||
|
if (onCameraChanged) onCameraChanged(); // 取景后 → 底图按新视锥重算覆盖
|
||||||
|
}
|
||||||
|
|
||||||
|
void VtkSceneView::renderIncremental() {
|
||||||
|
// 增量渲染:仅按新包围盒重建坐标轴并提交,不动相机(勾选/取消时视角不跳)。
|
||||||
|
rebuildAxes();
|
||||||
|
scene_.renderer()->ResetCameraClippingRange(); // 数据/底图变化后扩裁剪面,防被切
|
||||||
if (renderWindow_) renderWindow_->Render();
|
if (renderWindow_) renderWindow_->Render();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,22 @@
|
||||||
#pragma once
|
#pragma once
|
||||||
|
#include <functional>
|
||||||
|
#include <map>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <vtkCubeAxesActor.h>
|
||||||
|
#include <vtkImageData.h>
|
||||||
|
#include <vtkSmartPointer.h>
|
||||||
|
|
||||||
#include "I3dSceneView.hpp"
|
#include "I3dSceneView.hpp"
|
||||||
|
#include "model/ColorScale.hpp"
|
||||||
|
|
||||||
namespace geopro::core { class GeoLocalFrame; }
|
namespace geopro::core { class GeoLocalFrame; }
|
||||||
namespace geopro::render { class Scene; }
|
namespace geopro::render { class Scene; }
|
||||||
class vtkRenderer;
|
class vtkRenderer;
|
||||||
class vtkRenderWindow;
|
class vtkRenderWindow;
|
||||||
|
class vtkProp;
|
||||||
|
|
||||||
namespace geopro::app {
|
namespace geopro::app {
|
||||||
|
|
||||||
|
|
@ -23,17 +33,79 @@ public:
|
||||||
void clear() override;
|
void clear() override;
|
||||||
void setVerticalExaggeration(double ve) override;
|
void setVerticalExaggeration(double ve) override;
|
||||||
void addSurveyLine(const geopro::core::Grid& grid) override;
|
void addSurveyLine(const geopro::core::Grid& grid) override;
|
||||||
void addCurtain(const geopro::core::Grid& grid, const geopro::core::ColorScale& cs) override;
|
void addCurtain(const std::string& dsId, const geopro::core::Grid& grid,
|
||||||
void addVolume(const geopro::data::VolumeGrid& vol, const geopro::core::ColorScale& cs) override;
|
const geopro::core::ColorScale& cs) override;
|
||||||
|
void addVolume(const std::string& dsId, const geopro::data::VolumeGrid& vol,
|
||||||
|
const geopro::core::ColorScale& cs) override;
|
||||||
void addTerrain(const geopro::data::TerrainPaths& paths) override;
|
void addTerrain(const geopro::data::TerrainPaths& paths) override;
|
||||||
|
void removeDataset(const std::string& dsId) override;
|
||||||
|
void setAxes(geopro::controller::AxesMode mode, geopro::controller::AxesUnit unit,
|
||||||
|
int fontSize) override;
|
||||||
|
void applyCameraView(geopro::controller::ViewDir dir) override;
|
||||||
|
void zoom(double factor) override;
|
||||||
|
void fitView() override;
|
||||||
void render(bool is2D) override;
|
void render(bool is2D) override;
|
||||||
|
void renderIncremental() override;
|
||||||
|
|
||||||
|
// ── P3 切片交互:暴露当前体素 image(含 VE 烤入的 origin/spacing)供切片附着 ──
|
||||||
|
// addVolume 用暴露 image 的 buildVoxel 重载保留;clear/无体素时置空。
|
||||||
|
vtkImageData* currentVolumeImage() const { return currentVolumeImage_.Get(); }
|
||||||
|
const geopro::core::ColorScale& currentColorScale() const { return currentColorScale_; }
|
||||||
|
double currentVmin() const { return currentVmin_; }
|
||||||
|
double currentVmax() const { return currentVmax_; }
|
||||||
|
bool hasVolume() const { return currentVolumeImage_ != nullptr; }
|
||||||
|
|
||||||
|
// 体素 image 变化(addVolume 附着新 image / clear 置空)时回调,供上层把新 image 推给
|
||||||
|
// InteractionManager(重附着或关闭切片)。clear 时以 nullptr 触发。
|
||||||
|
std::function<void()> onVolumeChanged;
|
||||||
|
|
||||||
|
// frame 原点重锚(首个带经纬剖面到达)后回调,供底图等随之刷新到数据所在位置。
|
||||||
|
std::function<void()> onFrameReanchored;
|
||||||
|
|
||||||
|
// 相机程序化变化(取景/预设/缩放)后回调,供底图按新视锥重算覆盖(否则首帧部分瓦片要手动微动才出)。
|
||||||
|
std::function<void()> onCameraChanged;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
// 按当前坐标轴设置 + 场景包围盒重建坐标轴 prop(render 末尾调)。
|
||||||
|
void rebuildAxes();
|
||||||
|
void removeProps(std::vector<vtkSmartPointer<vtkProp>>& props); // 从 renderer 移除并清空
|
||||||
|
// 仅数据图元(剖面/体素/地形/测线)的包围盒,不含底图 → 坐标轴/取景不被~公里级底图撑大。
|
||||||
|
bool computeDataBounds(double out[6]) const;
|
||||||
|
|
||||||
|
public:
|
||||||
|
// 当前所有数据图元(剖面等)合并范围的水平半径(米);无数据返回 0。供底图动态定最大范围。
|
||||||
|
double dataHorizontalRadius() const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
|
||||||
geopro::render::Scene& scene_;
|
geopro::render::Scene& scene_;
|
||||||
vtkRenderWindow* renderWindow_;
|
vtkRenderWindow* renderWindow_;
|
||||||
std::shared_ptr<geopro::core::GeoLocalFrame> frame_;
|
std::shared_ptr<geopro::core::GeoLocalFrame> frame_;
|
||||||
double zRefElev_;
|
double zRefElev_;
|
||||||
double verticalExaggeration_ = 2.0;
|
double verticalExaggeration_ = 1.0;
|
||||||
|
// 是否已按真实剖面 lat/lon 重锚 frame 原点(每次 clear 重置):默认原点取自样本、可能离真实数据
|
||||||
|
// 很远→局部坐标巨大、轴刻度无意义;首个带经纬剖面到达时重锚到其中心,同选择内多剖面共用配准。
|
||||||
|
bool frameAnchoredToData_ = false;
|
||||||
|
|
||||||
|
// 坐标轴设置(P2):默认标准 + 米。
|
||||||
|
geopro::controller::AxesMode axesMode_ = geopro::controller::AxesMode::Standard;
|
||||||
|
geopro::controller::AxesUnit axesUnit_ = geopro::controller::AxesUnit::Meter;
|
||||||
|
int axesFontSize_ = 12;
|
||||||
|
// 当前坐标轴 prop:render 可能多次调用 rebuildAxes(rebuild 末尾 + 异步回灌),
|
||||||
|
// 持引用以便重建前移除旧 prop,避免叠加(评审 HIGH)。
|
||||||
|
vtkSmartPointer<vtkCubeAxesActor> currentAxes_;
|
||||||
|
|
||||||
|
// 当前体素 image + 色阶(P3 切片附着源);无体素时为空。
|
||||||
|
vtkSmartPointer<vtkImageData> currentVolumeImage_;
|
||||||
|
geopro::core::ColorScale currentColorScale_;
|
||||||
|
double currentVmin_ = 0.0;
|
||||||
|
double currentVmax_ = 0.0;
|
||||||
|
|
||||||
|
// 增量渲染:按 dsId 跟踪该数据集的 props(帘面/体素),支持单独移除而不全量重建;
|
||||||
|
// miscProps_ 为非数据集 prop(地形/测线),仅随 clear 全量移除。底图由 TileBasemap 自管、不在此。
|
||||||
|
std::map<std::string, std::vector<vtkSmartPointer<vtkProp>>> dsProps_;
|
||||||
|
std::vector<vtkSmartPointer<vtkProp>> miscProps_;
|
||||||
|
std::string volumeOwnerDs_; // 当前 currentVolumeImage_ 归属的 ds(其被移除时置空切片源)
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace geopro::app
|
} // namespace geopro::app
|
||||||
|
|
|
||||||
337
src/app/main.cpp
337
src/app/main.cpp
|
|
@ -34,8 +34,11 @@
|
||||||
#include <QFile>
|
#include <QFile>
|
||||||
#include <QButtonGroup>
|
#include <QButtonGroup>
|
||||||
#include <QCheckBox>
|
#include <QCheckBox>
|
||||||
|
#include <QComboBox>
|
||||||
#include <QFrame>
|
#include <QFrame>
|
||||||
#include <QHBoxLayout>
|
#include <QHBoxLayout>
|
||||||
|
#include <QPushButton>
|
||||||
|
#include <QSlider>
|
||||||
#include <QGraphicsOpacityEffect>
|
#include <QGraphicsOpacityEffect>
|
||||||
#include <QDate>
|
#include <QDate>
|
||||||
#include <QLabel>
|
#include <QLabel>
|
||||||
|
|
@ -59,6 +62,7 @@
|
||||||
#include <QStatusBar>
|
#include <QStatusBar>
|
||||||
#include <QStyle>
|
#include <QStyle>
|
||||||
#include <QSurfaceFormat>
|
#include <QSurfaceFormat>
|
||||||
|
#include <QSignalBlocker>
|
||||||
#include <QTimer>
|
#include <QTimer>
|
||||||
#include <QToolBar>
|
#include <QToolBar>
|
||||||
#include <QTreeWidget>
|
#include <QTreeWidget>
|
||||||
|
|
@ -78,10 +82,10 @@
|
||||||
#include "model/ColorScale.hpp"
|
#include "model/ColorScale.hpp"
|
||||||
#include "model/Field.hpp"
|
#include "model/Field.hpp"
|
||||||
#include "repo/LocalSampleRepository.hpp"
|
#include "repo/LocalSampleRepository.hpp"
|
||||||
#include "repo/LocalSample3dRepository.hpp"
|
|
||||||
|
|
||||||
#include "ApiClient.hpp"
|
#include "ApiClient.hpp"
|
||||||
#include "AuthService.hpp"
|
#include "AuthService.hpp"
|
||||||
|
#include "DatasetDimension.hpp"
|
||||||
#include "Credential.hpp"
|
#include "Credential.hpp"
|
||||||
#include "Glyphs.hpp"
|
#include "Glyphs.hpp"
|
||||||
#include "Logging.hpp"
|
#include "Logging.hpp"
|
||||||
|
|
@ -105,6 +109,7 @@
|
||||||
#include "panels/chart/GridStrategy.hpp"
|
#include "panels/chart/GridStrategy.hpp"
|
||||||
#include "api/ApiProjectRepository.hpp"
|
#include "api/ApiProjectRepository.hpp"
|
||||||
#include "api/ApiDatasetRepository.hpp"
|
#include "api/ApiDatasetRepository.hpp"
|
||||||
|
#include "api/Api3dRepository.hpp"
|
||||||
#include "panels/ObjectTreePanel.hpp"
|
#include "panels/ObjectTreePanel.hpp"
|
||||||
#include "login/LoginWindow.hpp"
|
#include "login/LoginWindow.hpp"
|
||||||
#include "panels/DatasetListPanel.hpp"
|
#include "panels/DatasetListPanel.hpp"
|
||||||
|
|
@ -113,11 +118,18 @@
|
||||||
#include "panels/ObjectAttrPanel.hpp"
|
#include "panels/ObjectAttrPanel.hpp"
|
||||||
#include "panels/DatasetAttrPanel.hpp"
|
#include "panels/DatasetAttrPanel.hpp"
|
||||||
#include "panels/ObjectExceptionPanel.hpp"
|
#include "panels/ObjectExceptionPanel.hpp"
|
||||||
|
#include "TileBasemap.hpp"
|
||||||
|
#include "panels/columns/ColumnDrawer.hpp"
|
||||||
|
#include "panels/columns/Column3DDataset.hpp"
|
||||||
|
#include "panels/columns/Column2DDataset.hpp"
|
||||||
|
#include "panels/columns/Column3DAnalysis.hpp"
|
||||||
|
|
||||||
#include "CameraPreset.hpp"
|
#include "CameraPreset.hpp"
|
||||||
#include "ColorLutBuilder.hpp"
|
#include "ColorLutBuilder.hpp"
|
||||||
#include "Scene.hpp"
|
#include "Scene.hpp"
|
||||||
#include "VoxelFromScatters.hpp"
|
#include "VoxelFromScatters.hpp"
|
||||||
|
#include "interact/InteractionManager.hpp"
|
||||||
|
#include "interact/SlicePlaneMath.hpp"
|
||||||
#include "actors/AnomalyActor.hpp"
|
#include "actors/AnomalyActor.hpp"
|
||||||
#include "actors/CurtainActor.hpp"
|
#include "actors/CurtainActor.hpp"
|
||||||
#include "actors/ElectrodeActor.hpp"
|
#include "actors/ElectrodeActor.hpp"
|
||||||
|
|
@ -203,7 +215,7 @@ double median(std::vector<double> v)
|
||||||
// 纵向夸张倍数(Z 基准统一,M-3):全项目共用同一倍数,使 帘面(z) / 体素 / 切片 /
|
// 纵向夸张倍数(Z 基准统一,M-3):全项目共用同一倍数,使 帘面(z) / 体素 / 切片 /
|
||||||
// 数据详情剖面(y) / 地形(relief) 的纵向比例一致——避免「剖面×1.5、帘面×3」不一致。
|
// 数据详情剖面(y) / 地形(relief) 的纵向比例一致——避免「剖面×1.5、帘面×3」不一致。
|
||||||
// 单一可调常量:要整体调纵向观感改这一处即可。
|
// 单一可调常量:要整体调纵向观感改这一处即可。
|
||||||
constexpr double kVerticalExaggeration = 2.0;
|
constexpr double kVerticalExaggeration = 1.0;
|
||||||
|
|
||||||
// 项目 CRS(实证确定,STATUS §4):散点 projX/Y→经纬→GeoLocalFrame 配准体素到世界系。
|
// 项目 CRS(实证确定,STATUS §4):散点 projX/Y→经纬→GeoLocalFrame 配准体素到世界系。
|
||||||
constexpr const char* kProjectCrs = "EPSG:4547"; // CGCS2000 / 3-degree GK CM 114E
|
constexpr const char* kProjectCrs = "EPSG:4547"; // CGCS2000 / 3-degree GK CM 114E
|
||||||
|
|
@ -213,6 +225,7 @@ constexpr const char* kWgs84 = "EPSG:4326";
|
||||||
// repo 生命周期须覆盖到事件循环结束(由调用方保证)。
|
// repo 生命周期须覆盖到事件循环结束(由调用方保证)。
|
||||||
void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& repo,
|
void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& repo,
|
||||||
geopro::data::IAsyncProjectRepository& projectRepo,
|
geopro::data::IAsyncProjectRepository& projectRepo,
|
||||||
|
geopro::data::IAsyncDatasetRepository& datasetRepo,
|
||||||
geopro::controller::WorkbenchNavController& nav,
|
geopro::controller::WorkbenchNavController& nav,
|
||||||
geopro::controller::DatasetDetailController& detailCtrl)
|
geopro::controller::DatasetDetailController& detailCtrl)
|
||||||
{
|
{
|
||||||
|
|
@ -236,23 +249,34 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
|
||||||
vtkGenericOpenGLRenderWindow* renderWindowPtr = renderWindow.Get();
|
vtkGenericOpenGLRenderWindow* renderWindowPtr = renderWindow.Get();
|
||||||
|
|
||||||
// 中央渲染编排(VtkSceneController + VtkSceneView,取代旧 rebuildCentral lambda 与裸 show* 标志)。
|
// 中央渲染编排(VtkSceneController + VtkSceneView,取代旧 rebuildCentral lambda 与裸 show* 标志)。
|
||||||
// 3D 场景仓储用 LocalSample3dRepository(本期样本驱动;接口异步,将来换 Api 实现不动上层)。
|
// 3D 场景仓储用 Api3dRepository(真实后端:loadSection 走真实 ERT 反演端点,委托 datasetRepo)。
|
||||||
// 视图(VtkSceneView)非 QObject、控制器/3D 仓储亦然:随 scene 一并在 vtkWidget 销毁时清理。
|
// 视图(VtkSceneView)非 QObject、控制器/3D 仓储亦然:随 scene 一并在 vtkWidget 销毁时清理。
|
||||||
auto* scene3dRepo = new geopro::data::LocalSample3dRepository(repo, kProjectCrs, lat0, lon0);
|
auto* scene3dRepo = new geopro::data::Api3dRepository(datasetRepo);
|
||||||
auto* sceneView = new geopro::app::VtkSceneView(*scene, renderWindowPtr,
|
auto* sceneView = new geopro::app::VtkSceneView(*scene, renderWindowPtr,
|
||||||
frame, refElev);
|
frame, refElev);
|
||||||
auto* sceneCtrl = new geopro::controller::VtkSceneController(repo, *scene3dRepo, *sceneView,
|
auto* sceneCtrl = new geopro::controller::VtkSceneController(repo, *scene3dRepo, *sceneView,
|
||||||
vtkWidget);
|
vtkWidget);
|
||||||
sceneCtrl->setVerticalExaggeration(kVerticalExaggeration);
|
sceneCtrl->setVerticalExaggeration(kVerticalExaggeration);
|
||||||
// 非 QObject 堆对象统一在此清理,按构造逆序:sceneView(持 scene&) → scene3dRepo → scene。
|
|
||||||
// (sceneCtrl 是 vtkWidget 的 QObject 子对象,由 Qt 在 destroyed 前先析构,不再触发信号回灌。)
|
|
||||||
QObject::connect(vtkWidget, &QObject::destroyed, [scene, scene3dRepo, sceneView]() {
|
|
||||||
delete sceneView;
|
|
||||||
delete scene3dRepo;
|
|
||||||
delete scene;
|
|
||||||
});
|
|
||||||
|
|
||||||
// PROJ 可用性(体素/地形/切片层都需配准):失败则浮层相应勾选禁用并提示。
|
// ── P3 切片交互编排(InteractionManager)─────────────────────────────────
|
||||||
|
// interactor 由 QVTK 在 setRenderWindow 后提供(renderWindow->GetInteractor())。
|
||||||
|
// 安装自定义拾取样式 + 持活动切片。仅三维 + 有体素可用;切到二维 closeAll。
|
||||||
|
auto* interactionMgr = new geopro::render::interact::InteractionManager(
|
||||||
|
renderWindowPtr->GetInteractor(), renderWindowPtr, scene->renderer());
|
||||||
|
// sceneView->onVolumeChanged 在三栏接线处设置(把体素 image 推给 InteractionManager,见下)。
|
||||||
|
// 非 QObject 堆对象统一在此清理,按构造逆序:
|
||||||
|
// interactionMgr(持 interactor/切片观察者) → sceneView(持 scene&) → scene3dRepo → scene。
|
||||||
|
// interactionMgr 先析构:closeAll() 解绑所有切片观察者,再拆 scene/interactor,防悬挂崩溃。
|
||||||
|
QObject::connect(vtkWidget, &QObject::destroyed,
|
||||||
|
[scene, scene3dRepo, sceneView, interactionMgr]() {
|
||||||
|
delete interactionMgr;
|
||||||
|
delete sceneView;
|
||||||
|
delete scene3dRepo;
|
||||||
|
delete scene;
|
||||||
|
});
|
||||||
|
|
||||||
|
// PROJ 可用性探测(体素/地形/切片层都需配准):三栏重构后浮层勾选已移除,
|
||||||
|
// 仅保留探测以便将来在三栏里据此禁用相关项;本期结果暂未消费。
|
||||||
bool crsAvailable = false;
|
bool crsAvailable = false;
|
||||||
try {
|
try {
|
||||||
geopro::core::CrsTransform probe(kProjectCrs, kWgs84);
|
geopro::core::CrsTransform probe(kProjectCrs, kWgs84);
|
||||||
|
|
@ -260,6 +284,7 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
|
||||||
} catch (const std::exception&) {
|
} catch (const std::exception&) {
|
||||||
crsAvailable = false;
|
crsAvailable = false;
|
||||||
}
|
}
|
||||||
|
(void)crsAvailable;
|
||||||
|
|
||||||
// 停靠系统配置(必须在 CDockManager 构造前设置):对齐原型——面板固定、
|
// 停靠系统配置(必须在 CDockManager 构造前设置):对齐原型——面板固定、
|
||||||
// 标题栏不显示「关闭 / 浮动 / 标签菜单」等子窗口操作按钮,并关闭自动隐藏(钉住)。
|
// 标题栏不显示「关闭 / 浮动 / 标签菜单」等子窗口操作按钮,并关闭自动隐藏(钉住)。
|
||||||
|
|
@ -303,67 +328,100 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
|
||||||
return box;
|
return box;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 中央容器:顶部「二维地图/三维视图」工具条 + 下方 QVTK 视图。
|
// 中央容器:顶部「VTK视图」表头 + 下方 [左三栏抽屉 | 右 QVTK 画布]。
|
||||||
auto* centerWidget = new QWidget();
|
auto* centerWidget = new QWidget();
|
||||||
auto* centerLayout = new QVBoxLayout(centerWidget);
|
auto* centerLayout = new QVBoxLayout(centerWidget);
|
||||||
centerLayout->setContentsMargins(0, 0, 0, 0);
|
centerLayout->setContentsMargins(0, 0, 0, 0);
|
||||||
centerLayout->setSpacing(0);
|
centerLayout->setSpacing(0);
|
||||||
|
|
||||||
// 「二维地图/三维视图」分段切换表头:与「异常/属性」面板表头同款(42px 表头底 + 强调色下划线页签)。
|
// VTK视图面板表头(Task 7):图标 + 标题「VTK视图」+ 全屏操作按钮(全屏 connect 见 Task 8)。
|
||||||
auto seg = geopro::app::buildSegmentedHeader(
|
auto* viewHeader = geopro::app::buildPanelHeader(
|
||||||
{QStringLiteral("二维地图"), QStringLiteral("三维视图")},
|
geopro::app::Glyph::Map, QStringLiteral("VTK视图"),
|
||||||
{{geopro::app::Glyph::Collapse, QStringLiteral("折叠")},
|
{{geopro::app::Glyph::Fullscreen, QStringLiteral("全屏")}});
|
||||||
{geopro::app::Glyph::Download, QStringLiteral("导出")}});
|
|
||||||
auto* viewHeader = seg.header;
|
|
||||||
auto* act2D = seg.buttons[0];
|
|
||||||
auto* act3D = seg.buttons[1];
|
|
||||||
centerLayout->addWidget(viewHeader);
|
|
||||||
centerLayout->addWidget(vtkWidget, 1);
|
|
||||||
|
|
||||||
// ──「视图详情」图层浮层(对齐原型 3D 视图左上):浮在 QVTK 之上,控制三维图层显隐。
|
// 左侧内嵌三栏抽屉(自带折叠按钮)+ 右侧 GL 画布,水平并列(非 GL 覆盖层,避免 z-order/圆角伪影)。
|
||||||
// 仅三维视图显示;含 帘面 / 体素 勾选(体素=两交叉测线散点配准 IDW 的派生层,正确归宿)。
|
auto* drawer = new geopro::app::ColumnDrawer(centerWidget);
|
||||||
auto* layerPanel = new QFrame(centerWidget);
|
auto* viewRow = new QHBoxLayout();
|
||||||
layerPanel->setFrameShape(QFrame::StyledPanel);
|
viewRow->setContentsMargins(0, 0, 0, 0);
|
||||||
geopro::app::applyTokenizedStyleSheet(
|
viewRow->setSpacing(0);
|
||||||
layerPanel,
|
viewRow->addWidget(drawer); // 左侧抽屉(自带折叠按钮)
|
||||||
// 不设 border-radius:浮层是 centerWidget 的子控件,悬于原生 GL 画布上,圆角四角处会
|
viewRow->addWidget(vtkWidget, 1); // 右侧 GL 画布
|
||||||
// 露出父级浅底(浅色模式下即四个白直角)。改为直角矩形,不透明底铺满整块,无四角伪影。
|
centerLayout->addWidget(viewHeader);
|
||||||
QStringLiteral("QFrame{background:{{canvas/bg-soft}};border:1px solid {{canvas/grid}};}"
|
centerLayout->addLayout(viewRow, 1);
|
||||||
"QCheckBox{padding:2px 1px;color:{{canvas/text}};}"
|
|
||||||
"QCheckBox:disabled{color:{{canvas/text-dim}};}"));
|
// 体素变化(重建/清场)后把体素 image 推给 InteractionManager(切片基底)。
|
||||||
auto* layerLayout = new QVBoxLayout(layerPanel);
|
sceneView->onVolumeChanged = [interactionMgr, sceneView]() {
|
||||||
// 浮层内边距取间距令牌:左右 lg(12)、上下 ml(10),对称(原 13/10/15/11 是手调奇数)。
|
if (sceneView->hasVolume())
|
||||||
layerLayout->setContentsMargins(geopro::app::space::kLg, geopro::app::space::kMl,
|
interactionMgr->setVolumeImage(sceneView->currentVolumeImage(),
|
||||||
geopro::app::space::kLg, geopro::app::space::kMl);
|
sceneView->currentColorScale(), sceneView->currentVmin(),
|
||||||
layerLayout->setSpacing(geopro::app::space::kSm);
|
sceneView->currentVmax());
|
||||||
auto* layerTitle = new QLabel(QStringLiteral("视图详情"));
|
else
|
||||||
geopro::app::applyTokenizedStyleSheet(
|
interactionMgr->setVolumeImage(nullptr, sceneView->currentColorScale(), 0.0, 0.0);
|
||||||
layerTitle, QStringLiteral("font-weight:%1;color:{{canvas/text}};border:none;background:transparent;"
|
};
|
||||||
"padding-bottom:3px;font-size:%2px;")
|
|
||||||
.arg(geopro::app::type::kWeightSemibold)
|
// ── 三栏抽屉信号 → 控制器/交互(Task 7 接线)──────────────────────────────
|
||||||
.arg(geopro::app::scaledPx(geopro::app::type::kTitle)));
|
auto* c3 = drawer->col3D();
|
||||||
auto* chkCurtain = new QCheckBox(QStringLiteral("帘面(断面墙)"));
|
QObject::connect(c3, &geopro::app::Column3DDataset::axesModeChanged, sceneCtrl,
|
||||||
chkCurtain->setChecked(true);
|
&geopro::controller::VtkSceneController::setAxesMode);
|
||||||
auto* chkVoxel = new QCheckBox(QStringLiteral("体素(dd_voxel)"));
|
QObject::connect(c3, &geopro::app::Column3DDataset::axesUnitChanged, sceneCtrl,
|
||||||
chkVoxel->setChecked(false);
|
&geopro::controller::VtkSceneController::setAxesUnit);
|
||||||
auto* chkTerrain = new QCheckBox(QStringLiteral("地形(DEM+影像)"));
|
QObject::connect(c3, &geopro::app::Column3DDataset::verticalExaggerationChanged, sceneCtrl,
|
||||||
chkTerrain->setChecked(false);
|
&geopro::controller::VtkSceneController::setVerticalExaggeration);
|
||||||
auto* chkSlice = new QCheckBox(QStringLiteral("切片(dd_slice)"));
|
QObject::connect(c3, &geopro::app::Column3DDataset::viewRequested, sceneCtrl,
|
||||||
chkSlice->setChecked(false);
|
&geopro::controller::VtkSceneController::applyView);
|
||||||
if (!crsAvailable) { // PROJ 不可用 → 体素/地形层(都需配准)禁用并提示
|
QObject::connect(c3, &geopro::app::Column3DDataset::zoomInRequested, sceneCtrl,
|
||||||
const QString tip = QStringLiteral("PROJ 数据(proj.db)缺失,配准不可用");
|
&geopro::controller::VtkSceneController::zoomIn);
|
||||||
chkVoxel->setEnabled(false); chkVoxel->setToolTip(tip);
|
QObject::connect(c3, &geopro::app::Column3DDataset::zoomOutRequested, sceneCtrl,
|
||||||
chkTerrain->setEnabled(false); chkTerrain->setToolTip(tip);
|
&geopro::controller::VtkSceneController::zoomOut);
|
||||||
}
|
QObject::connect(c3, &geopro::app::Column3DDataset::fitRequested, sceneCtrl,
|
||||||
// 切片(dd_slice)交互切片留待 P3:本轮禁用。
|
&geopro::controller::VtkSceneController::fit);
|
||||||
chkSlice->setEnabled(false);
|
// 渲染勾选的 3D 数据集:真实 ds id 直达控制器异步帘面路径
|
||||||
chkSlice->setToolTip(QStringLiteral("(切片交互 P3 接入)"));
|
// (setCheckedDatasets → Api3dRepository.loadSection(realId) → 真实 ERT 反演端点 → 真实帘面)。
|
||||||
layerLayout->addWidget(layerTitle);
|
QObject::connect(c3, &geopro::app::Column3DDataset::checkedDatasetsChanged, sceneCtrl,
|
||||||
layerLayout->addWidget(chkCurtain);
|
&geopro::controller::VtkSceneController::setCheckedDatasets);
|
||||||
layerLayout->addWidget(chkVoxel);
|
// O点位置/字体本期 stub(TODO P4:弹框)。
|
||||||
layerLayout->addWidget(chkSlice);
|
QObject::connect(c3, &geopro::app::Column3DDataset::oPointClicked, vtkWidget,
|
||||||
layerLayout->addWidget(chkTerrain);
|
[]() { /* TODO P4: O点位置弹框 */ });
|
||||||
layerPanel->setVisible(false); // 默认二维,不显示图层浮层
|
QObject::connect(c3, &geopro::app::Column3DDataset::fontClicked, vtkWidget,
|
||||||
|
[]() { /* TODO P4: 字体弹框 */ });
|
||||||
|
|
||||||
|
auto* ca = drawer->colAnalysis();
|
||||||
|
QObject::connect(ca, &geopro::app::Column3DAnalysis::sliceRequested, vtkWidget,
|
||||||
|
[interactionMgr](geopro::render::interact::SliceAxis axis) {
|
||||||
|
interactionMgr->addSlice(axis);
|
||||||
|
});
|
||||||
|
QObject::connect(ca, &geopro::app::Column3DAnalysis::detailRequested, &detailCtrl,
|
||||||
|
[&detailCtrl](const QString& dsId, const QString& ddCode, const QString& name) {
|
||||||
|
detailCtrl.openDataset(dsId, ddCode, name);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 二维数据集栏:天地图底图开关(③,复用轨迹图 token,经同一共享 GeoLocalFrame 配准)──
|
||||||
|
auto* basemap = new geopro::app::TileBasemap(*scene, renderWindowPtr, frame, &window);
|
||||||
|
// 当前底图选择(默认 天地图=Satellite,对齐 Column2DDataset 默认项);数据重锚后据此在数据位置加载。
|
||||||
|
auto basemapKind =
|
||||||
|
std::make_shared<geopro::app::TileBasemap::Kind>(geopro::app::TileBasemap::Satellite);
|
||||||
|
QObject::connect(drawer->col2D(), &geopro::app::Column2DDataset::basemapChanged, basemap,
|
||||||
|
[basemap, basemapKind](int idx) {
|
||||||
|
// 地图下拉:0 天地图(卫星影像) / 1 Google(暂未实现→隐藏) / 2 隐藏。
|
||||||
|
*basemapKind = (idx == 0) ? geopro::app::TileBasemap::Satellite
|
||||||
|
: geopro::app::TileBasemap::Hidden;
|
||||||
|
basemap->show(*basemapKind);
|
||||||
|
});
|
||||||
|
// 首个真实剖面到达 → frame 重锚到数据 lat/lon 后,把选中的底图加载到数据所在位置
|
||||||
|
// (默认天地图即在此刻出现,避免启动时在样本原点拉无关瓦片)。
|
||||||
|
sceneView->onFrameReanchored = [basemap, basemapKind]() {
|
||||||
|
if (*basemapKind != geopro::app::TileBasemap::Hidden) basemap->show(*basemapKind);
|
||||||
|
};
|
||||||
|
// 相机程序化变化(取景/预设/缩放)后,底图按新视锥重算覆盖(治首帧部分瓦片需手动微动才出)。
|
||||||
|
sceneView->onCameraChanged = [basemap]() { basemap->refresh(); };
|
||||||
|
// 底图最大范围按当前勾选剖面合并范围动态定(随增删自动伸缩);刷新时实时查询。
|
||||||
|
basemap->setDataRadiusProvider([sceneView]() { return sceneView->dataHorizontalRadius(); });
|
||||||
|
// 垂直夸张:地形须与剖面用同一 VE 才对齐(都按真实高程×VE)。
|
||||||
|
QObject::connect(drawer->col3D(), &geopro::app::Column3DDataset::verticalExaggerationChanged,
|
||||||
|
basemap, [basemap](double ve) { basemap->setVerticalExaggeration(ve); });
|
||||||
|
// 单一来源:kVerticalExaggeration 一处定义,组合根下发到 控制器(上方259) / 底图 / UI 显示。
|
||||||
|
basemap->setVerticalExaggeration(kVerticalExaggeration);
|
||||||
|
drawer->col3D()->setVerticalExaggeration(kVerticalExaggeration);
|
||||||
|
|
||||||
// ── 中央“空状态”引导浮层:未接入真实 sections 时,引导首次使用者从左侧入手。──
|
// ── 中央“空状态”引导浮层:未接入真实 sections 时,引导首次使用者从左侧入手。──
|
||||||
// 透明背景 + 鼠标穿透(不挡 QVTK 交互);CenterOverlay 随视口尺寸保持居中;
|
// 透明背景 + 鼠标穿透(不挡 QVTK 交互);CenterOverlay 随视口尺寸保持居中;
|
||||||
|
|
@ -411,7 +469,7 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
|
||||||
auto* emptyCentering = new CenterOverlay(emptyState, vtkWidget);
|
auto* emptyCentering = new CenterOverlay(emptyState, vtkWidget);
|
||||||
emptyCentering->reposition();
|
emptyCentering->reposition();
|
||||||
|
|
||||||
auto* vtkDock = new ads::CDockWidget(QStringLiteral("二维地图/三维视图"));
|
auto* vtkDock = new ads::CDockWidget(QStringLiteral("VTK视图"));
|
||||||
vtkDock->setWidget(centerWidget);
|
vtkDock->setWidget(centerWidget);
|
||||||
auto* centerDockArea = dockManager->addDockWidget(ads::CenterDockWidgetArea, vtkDock);
|
auto* centerDockArea = dockManager->addDockWidget(ads::CenterDockWidgetArea, vtkDock);
|
||||||
|
|
||||||
|
|
@ -422,9 +480,10 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
|
||||||
// ForceNoScrollArea:禁止 ADS 默认把整块内容(含标题栏/页签栏)包进 QScrollArea。
|
// ForceNoScrollArea:禁止 ADS 默认把整块内容(含标题栏/页签栏)包进 QScrollArea。
|
||||||
// 否则内容最小尺寸超过 dock 时,整个面板(标题+页签+图)一起滚动。改为内容自适应填充;
|
// 否则内容最小尺寸超过 dock 时,整个面板(标题+页签+图)一起滚动。改为内容自适应填充;
|
||||||
// 需要时由内层(图表内容区)自行滚动,标题/页签固定。
|
// 需要时由内层(图表内容区)自行滚动,标题/页签固定。
|
||||||
detailDock->setWidget(wrapWithHeader(
|
auto* detailHeader = wrapWithHeader(
|
||||||
geopro::app::Glyph::Detail, QStringLiteral("数据详情"), detailPanel),
|
geopro::app::Glyph::Detail, QStringLiteral("数据详情"), detailPanel,
|
||||||
ads::CDockWidget::ForceNoScrollArea);
|
{{geopro::app::Glyph::Fullscreen, QStringLiteral("全屏")}});
|
||||||
|
detailDock->setWidget(detailHeader, ads::CDockWidget::ForceNoScrollArea);
|
||||||
// 放在中央视图下方。
|
// 放在中央视图下方。
|
||||||
dockManager->addDockWidget(ads::BottomDockWidgetArea, detailDock, centerDockArea);
|
dockManager->addDockWidget(ads::BottomDockWidgetArea, detailDock, centerDockArea);
|
||||||
|
|
||||||
|
|
@ -565,52 +624,49 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 「视图详情」浮层显隐:仅三维显示,置于 QVTK 左上(工具条下方)并置顶。
|
// ── 左上对象树勾选 → 拉取各 TM 的 ds 子树,按维度分发到三栏列表(spec §6.1/§8)──
|
||||||
auto showLayerPanel = [layerPanel, viewHeader](bool show3D) {
|
// 渲染由三栏勾选框驱动(Task 7:Column3DDataset::checkedDatasetsChanged → setCheckedDatasets)。
|
||||||
if (show3D) {
|
auto generation = std::make_shared<unsigned long long>(0);
|
||||||
layerPanel->move(14, viewHeader->height() + 12);
|
QObject::connect(
|
||||||
layerPanel->adjustSize();
|
objectTree, &geopro::app::ObjectTreePanel::checkedTmsChanged, &window,
|
||||||
layerPanel->setVisible(true);
|
[&projectRepo, &nav, drawer, emptyState, generation](const QStringList& tmIds) {
|
||||||
layerPanel->raise();
|
const unsigned long long myGen = ++(*generation);
|
||||||
} else {
|
emptyState->setVisible(tmIds.isEmpty()); // 有勾选→隐藏引导层,露出中央渲染
|
||||||
layerPanel->setVisible(false);
|
if (tmIds.isEmpty()) {
|
||||||
}
|
drawer->col3D()->setDatasets({});
|
||||||
};
|
drawer->col2D()->setDatasets({});
|
||||||
|
drawer->colAnalysis()->setDatasets({});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 多 TM 异步汇总:每个 TM 取整棵 ds 子树,全部回来后按维度分发到三栏。
|
||||||
|
auto acc = std::make_shared<std::vector<geopro::data::DsRow>>();
|
||||||
|
auto remaining = std::make_shared<int>(tmIds.size());
|
||||||
|
auto finish = [acc, drawer, generation, myGen]() {
|
||||||
|
if (*generation != myGen) return; // 已被更新的勾选批次取代→丢弃陈旧结果
|
||||||
|
geopro::app::DimBuckets b = geopro::app::splitByDimension(*acc);
|
||||||
|
drawer->col3D()->setDatasets(b.dim3D);
|
||||||
|
drawer->col2D()->setDatasets(b.dim2D);
|
||||||
|
drawer->colAnalysis()->setDatasets(b.analysis);
|
||||||
|
};
|
||||||
|
for (const QString& tm : tmIds) {
|
||||||
|
geopro::data::NavRequest* req = projectRepo.loadRowsAsync(
|
||||||
|
nav.currentProjectId().toStdString(), tm.toStdString(), 2, 3, 1, 100000);
|
||||||
|
QObject::connect(req, &geopro::data::NavRequest::done, drawer,
|
||||||
|
[acc, remaining, finish](const QVariant& v) {
|
||||||
|
auto page = qvariant_cast<geopro::data::DsPage>(v);
|
||||||
|
acc->insert(acc->end(), page.rows.begin(), page.rows.end());
|
||||||
|
if (--(*remaining) == 0) finish();
|
||||||
|
});
|
||||||
|
QObject::connect(req, &geopro::data::NavRequest::failed, drawer,
|
||||||
|
[remaining, finish](const QString&) {
|
||||||
|
if (--(*remaining) == 0) finish(); // 单个失败不卡死,其余照常分发
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ── 工具条「二维地图/三维视图」:切换互斥视图 → 控制器重建 + 图层浮层显隐 ──
|
// ── 启动:建立一次中央视图。三栏重构后删除了 2D/3D 切换,统一固定为三维视图
|
||||||
using geopro::controller::SceneLayer;
|
// (帘面默认开启 showCurtain_=true,勾选 dd_section → 帘面)。无勾选 → 空场景 + 背景。
|
||||||
using CtrlViewMode = geopro::controller::ViewMode;
|
sceneCtrl->setViewMode(geopro::controller::ViewMode::View3D);
|
||||||
QObject::connect(act2D, &QAbstractButton::clicked, vtkWidget,
|
|
||||||
[sceneCtrl, showLayerPanel]() {
|
|
||||||
showLayerPanel(false);
|
|
||||||
sceneCtrl->setViewMode(CtrlViewMode::Map2D);
|
|
||||||
});
|
|
||||||
QObject::connect(act3D, &QAbstractButton::clicked, vtkWidget,
|
|
||||||
[sceneCtrl, showLayerPanel]() {
|
|
||||||
showLayerPanel(true);
|
|
||||||
sceneCtrl->setViewMode(CtrlViewMode::View3D);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ──「视图详情」图层勾选 → 控制器更新图层 → 重建中央 ──
|
|
||||||
QObject::connect(chkCurtain, &QCheckBox::toggled, vtkWidget,
|
|
||||||
[sceneCtrl](bool on) { sceneCtrl->setLayer(SceneLayer::Curtain, on); });
|
|
||||||
QObject::connect(chkVoxel, &QCheckBox::toggled, vtkWidget,
|
|
||||||
[sceneCtrl](bool on) { sceneCtrl->setLayer(SceneLayer::Voxel, on); });
|
|
||||||
QObject::connect(chkTerrain, &QCheckBox::toggled, vtkWidget,
|
|
||||||
[sceneCtrl](bool on) { sceneCtrl->setLayer(SceneLayer::Terrain, on); });
|
|
||||||
|
|
||||||
// ── 左上对象树勾选 → 渲染勾选数据集(本期样本驱动:任意勾选 → 样本 ds "grid1",空 → 清场)──
|
|
||||||
// 真实接 Api 时改为把勾选 TM 映射到其 ds 维度过滤后的真实 dsId 列表(spec §6.1/§8)。
|
|
||||||
QObject::connect(objectTree, &geopro::app::ObjectTreePanel::checkedTmsChanged, sceneCtrl,
|
|
||||||
[sceneCtrl, emptyState](const QStringList& tmIds) {
|
|
||||||
const bool hasData = !tmIds.isEmpty();
|
|
||||||
emptyState->setVisible(!hasData); // 有勾选→隐藏引导层,露出中央渲染
|
|
||||||
sceneCtrl->setCheckedDatasets(
|
|
||||||
hasData ? QStringList{QStringLiteral("grid1")} : QStringList{});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── 启动:建立一次中央视图(默认 2D,无勾选 → 空场景 + 背景)。
|
|
||||||
sceneCtrl->setViewMode(CtrlViewMode::Map2D);
|
|
||||||
|
|
||||||
// VTK 背景随主题切换:控制器重渲染(走完整渲染路径、末尾必 Render)。
|
// VTK 背景随主题切换:控制器重渲染(走完整渲染路径、末尾必 Render)。
|
||||||
// context 用 sceneCtrl(非 window):ThemeManager 是进程级单例,连接须随 sceneCtrl 析构自动断开,
|
// context 用 sceneCtrl(非 window):ThemeManager 是进程级单例,连接须随 sceneCtrl 析构自动断开,
|
||||||
|
|
@ -731,6 +787,49 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
|
||||||
return nullptr;
|
return nullptr;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── 全屏切换:VTK视图 / 数据详情 表头右上角「全屏」按钮 ──────────────────────────
|
||||||
|
// 点击 → 目标 dock 全屏(隐藏其余所有 dock);再点 → 还原(全部显示)。
|
||||||
|
// 使用 ADS CDockWidget::toggleView(bool) 控制可见性(标准 ADS API,v4+)。
|
||||||
|
{
|
||||||
|
const QList<ads::CDockWidget*> allDocks{vtkDock, detailDock, leftDock, datasetDock,
|
||||||
|
rightDock, propDock};
|
||||||
|
auto applyFullscreen = [](ads::CDockWidget* target,
|
||||||
|
const QList<ads::CDockWidget*>& all, bool on) {
|
||||||
|
for (ads::CDockWidget* d : all) {
|
||||||
|
if (d == target) continue;
|
||||||
|
d->toggleView(!on); // on=进入全屏→隐藏其它; off=还原→全部显示
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
auto* vtkFsBtn = findHeaderAction(viewHeader, geopro::app::Glyph::Fullscreen);
|
||||||
|
auto* detailFsBtn = findHeaderAction(detailHeader, geopro::app::Glyph::Fullscreen);
|
||||||
|
|
||||||
|
if (vtkFsBtn) {
|
||||||
|
vtkFsBtn->setCheckable(true);
|
||||||
|
QObject::connect(vtkFsBtn, &QToolButton::toggled, &window,
|
||||||
|
[applyFullscreen, vtkDock, allDocks, detailFsBtn, drawer](bool on) {
|
||||||
|
if (on && detailFsBtn && detailFsBtn->isChecked()) {
|
||||||
|
QSignalBlocker b(detailFsBtn);
|
||||||
|
detailFsBtn->setChecked(false);
|
||||||
|
}
|
||||||
|
// VTK 全屏含左侧三栏(drawer 本就在 vtkDock 内):进入时确保展开可见。
|
||||||
|
if (on) drawer->expand();
|
||||||
|
applyFullscreen(vtkDock, allDocks, on);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (detailFsBtn) {
|
||||||
|
detailFsBtn->setCheckable(true);
|
||||||
|
QObject::connect(detailFsBtn, &QToolButton::toggled, &window,
|
||||||
|
[applyFullscreen, detailDock, allDocks, vtkFsBtn](bool on) {
|
||||||
|
if (on && vtkFsBtn && vtkFsBtn->isChecked()) {
|
||||||
|
QSignalBlocker b(vtkFsBtn);
|
||||||
|
vtkFsBtn->setChecked(false);
|
||||||
|
}
|
||||||
|
applyFullscreen(detailDock, allDocks, on);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 对象树右键菜单动作路由。
|
// 对象树右键菜单动作路由。
|
||||||
QObject::connect(
|
QObject::connect(
|
||||||
objectTree, &geopro::app::ObjectTreePanel::contextActionRequested, &window,
|
objectTree, &geopro::app::ObjectTreePanel::contextActionRequested, &window,
|
||||||
|
|
@ -1138,7 +1237,7 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
|
||||||
if (!geo.isEmpty()) window.restoreGeometry(geo);
|
if (!geo.isEmpty()) window.restoreGeometry(geo);
|
||||||
// 注意:ADS 按 dock 唯一名作键。改过 dock 名后旧布局会失配 → bump 此键丢弃旧布局,
|
// 注意:ADS 按 dock 唯一名作键。改过 dock 名后旧布局会失配 → bump 此键丢弃旧布局,
|
||||||
// 回落到下方 addDockWidget 的默认排布(再改 dock 名时同样要 bump 版本号)。
|
// 回落到下方 addDockWidget 的默认排布(再改 dock 名时同样要 bump 版本号)。
|
||||||
const QByteArray dockState = settings.value(QStringLiteral("ui/dockState_v2")).toByteArray();
|
const QByteArray dockState = settings.value(QStringLiteral("ui/dockState_v3")).toByteArray();
|
||||||
if (!dockState.isEmpty()) {
|
if (!dockState.isEmpty()) {
|
||||||
dockManager->restoreState(dockState);
|
dockManager->restoreState(dockState);
|
||||||
// restoreState 重建停靠区会重新显示 ADS 标题栏,再隐藏一次保持“无双标题”。
|
// restoreState 重建停靠区会重新显示 ADS 标题栏,再隐藏一次保持“无双标题”。
|
||||||
|
|
@ -1149,7 +1248,7 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
|
||||||
QObject::connect(qApp, &QCoreApplication::aboutToQuit, dockManager, [dockManager, &window]() {
|
QObject::connect(qApp, &QCoreApplication::aboutToQuit, dockManager, [dockManager, &window]() {
|
||||||
QSettings settings;
|
QSettings settings;
|
||||||
settings.setValue(QStringLiteral("ui/geometry"), window.saveGeometry());
|
settings.setValue(QStringLiteral("ui/geometry"), window.saveGeometry());
|
||||||
settings.setValue(QStringLiteral("ui/dockState_v2"), dockManager->saveState());
|
settings.setValue(QStringLiteral("ui/dockState_v3"), dockManager->saveState());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1278,7 +1377,7 @@ int main(int argc, char* argv[])
|
||||||
window->setWindowTitle(kTitle);
|
window->setWindowTitle(kTitle);
|
||||||
window->resize(1280, 800);
|
window->resize(1280, 800);
|
||||||
window->setMinimumSize(1024, 680);
|
window->setMinimumSize(1024, 680);
|
||||||
buildWorkbench(*window, repo, projectRepo, nav, detailCtrl);
|
buildWorkbench(*window, repo, projectRepo, datasetRepo, nav, detailCtrl);
|
||||||
|
|
||||||
// 主题桥:ThemeManager 明/暗切换 → 重应用全局 QSS+调色板(标准控件 + ADS;内联 chrome 经各自连接)。
|
// 主题桥:ThemeManager 明/暗切换 → 重应用全局 QSS+调色板(标准控件 + ADS;内联 chrome 经各自连接)。
|
||||||
QObject::connect(&geopro::app::ThemeManager::instance(), &geopro::app::ThemeManager::changed,
|
QObject::connect(&geopro::app::ThemeManager::instance(), &geopro::app::ThemeManager::changed,
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,18 @@
|
||||||
#include "panels/DatasetListPanel.hpp"
|
#include "panels/DatasetListPanel.hpp"
|
||||||
|
|
||||||
#include <QAbstractItemView>
|
#include <QAbstractItemView>
|
||||||
|
#include <QApplication>
|
||||||
#include <QColor>
|
#include <QColor>
|
||||||
#include <QHash>
|
#include <QHash>
|
||||||
|
#include <QKeyEvent>
|
||||||
#include <QListWidget>
|
#include <QListWidget>
|
||||||
#include <QListWidgetItem>
|
#include <QListWidgetItem>
|
||||||
|
#include <QMouseEvent>
|
||||||
#include <QObject>
|
#include <QObject>
|
||||||
#include <QPainter>
|
#include <QPainter>
|
||||||
#include <QPainterPath>
|
#include <QPainterPath>
|
||||||
#include <QString>
|
#include <QString>
|
||||||
|
#include <QStyle>
|
||||||
#include <QStyledItemDelegate>
|
#include <QStyledItemDelegate>
|
||||||
#include <QTreeWidget>
|
#include <QTreeWidget>
|
||||||
#include <QTreeWidgetItem>
|
#include <QTreeWidgetItem>
|
||||||
|
|
@ -77,6 +81,23 @@ public:
|
||||||
geopro::app::tokenColor("accent/primary"));
|
geopro::app::tokenColor("accent/primary"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 可勾选项:左侧画复选框(用当前 style 的指示器),文本整体右移。
|
||||||
|
int textLeftPad = 14;
|
||||||
|
const bool checkable = (idx.flags() & Qt::ItemIsUserCheckable);
|
||||||
|
if (checkable) {
|
||||||
|
const int box = 16;
|
||||||
|
QRect checkRect(r.left() + 12, r.top() + (r.height() - box) / 2, box, box);
|
||||||
|
const auto cs = static_cast<Qt::CheckState>(idx.data(Qt::CheckStateRole).toInt());
|
||||||
|
QStyleOptionViewItem o(opt);
|
||||||
|
o.rect = checkRect;
|
||||||
|
o.state &= ~QStyle::State_HasFocus;
|
||||||
|
o.state |= (cs == Qt::Checked ? QStyle::State_On : QStyle::State_Off);
|
||||||
|
const QWidget* w = opt.widget;
|
||||||
|
QStyle* st = w ? w->style() : QApplication::style();
|
||||||
|
st->drawPrimitive(QStyle::PE_IndicatorItemViewItemCheck, &o, p, w);
|
||||||
|
textLeftPad = 12 + box + 8; // 复选框右侧留白后再放文本
|
||||||
|
}
|
||||||
|
|
||||||
QString title = disp, meta;
|
QString title = disp, meta;
|
||||||
const int nl = disp.indexOf(QLatin1Char('\n'));
|
const int nl = disp.indexOf(QLatin1Char('\n'));
|
||||||
if (nl >= 0) {
|
if (nl >= 0) {
|
||||||
|
|
@ -84,7 +105,7 @@ public:
|
||||||
meta = disp.mid(nl + 1);
|
meta = disp.mid(nl + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const QRect textR = r.adjusted(14, 6, -12, -6);
|
const QRect textR = r.adjusted(textLeftPad, 6, -12, -6);
|
||||||
// 标题
|
// 标题
|
||||||
QFont tf = opt.font;
|
QFont tf = opt.font;
|
||||||
tf.setPixelSize(geopro::app::scaledPx(13));
|
tf.setPixelSize(geopro::app::scaledPx(13));
|
||||||
|
|
@ -106,6 +127,34 @@ public:
|
||||||
}
|
}
|
||||||
p->restore();
|
p->restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool editorEvent(QEvent* ev, QAbstractItemModel* model, const QStyleOptionViewItem& opt,
|
||||||
|
const QModelIndex& idx) override {
|
||||||
|
if (!(idx.flags() & Qt::ItemIsUserCheckable))
|
||||||
|
return QStyledItemDelegate::editorEvent(ev, model, opt, idx);
|
||||||
|
const QRect r = opt.rect.adjusted(4, 2, -4, -2);
|
||||||
|
const int box = 16;
|
||||||
|
// 命中区放宽到复选框左侧整段(含一点文本起始),便于点击。
|
||||||
|
const QRect hit(r.left(), r.top(), 12 + box + 8, r.height());
|
||||||
|
auto toggle = [&]() {
|
||||||
|
const auto cur = static_cast<Qt::CheckState>(idx.data(Qt::CheckStateRole).toInt());
|
||||||
|
model->setData(idx, cur == Qt::Checked ? Qt::Unchecked : Qt::Checked, Qt::CheckStateRole);
|
||||||
|
};
|
||||||
|
if (ev->type() == QEvent::MouseButtonRelease) {
|
||||||
|
auto* me = static_cast<QMouseEvent*>(ev);
|
||||||
|
if (me->button() == Qt::LeftButton && hit.contains(me->pos())) {
|
||||||
|
toggle();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} else if (ev->type() == QEvent::KeyPress) {
|
||||||
|
auto* ke = static_cast<QKeyEvent*>(ev);
|
||||||
|
if (ke->key() == Qt::Key_Space || ke->key() == Qt::Key_Select) {
|
||||||
|
toggle();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return QStyledItemDelegate::editorEvent(ev, model, opt, idx);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
#include "panels/columns/Column2DDataset.hpp"
|
||||||
|
|
||||||
|
#include <QComboBox>
|
||||||
|
#include <QDoubleSpinBox>
|
||||||
|
#include <QFormLayout>
|
||||||
|
#include <QHBoxLayout>
|
||||||
|
#include <QLabel>
|
||||||
|
#include <QSignalBlocker>
|
||||||
|
#include <QTreeWidget>
|
||||||
|
#include <QTreeWidgetItemIterator>
|
||||||
|
#include <QVBoxLayout>
|
||||||
|
|
||||||
|
#include "Theme.hpp"
|
||||||
|
#include "panels/DatasetListPanel.hpp"
|
||||||
|
|
||||||
|
namespace geopro::app {
|
||||||
|
|
||||||
|
Column2DDataset::Column2DDataset(QWidget* parent) : QWidget(parent) {
|
||||||
|
auto* root = new QVBoxLayout(this);
|
||||||
|
root->setContentsMargins(space::kMd, space::kMd, space::kMd, space::kMd);
|
||||||
|
root->setSpacing(space::kMd);
|
||||||
|
|
||||||
|
// 地图
|
||||||
|
{
|
||||||
|
auto* form = new QFormLayout();
|
||||||
|
auto* basemap = new QComboBox();
|
||||||
|
basemap->addItem(QStringLiteral("天地图"));
|
||||||
|
basemap->addItem(QStringLiteral("Google Map"));
|
||||||
|
basemap->addItem(QStringLiteral("隐藏"));
|
||||||
|
basemap->setCurrentIndex(0); // 默认天地图:数据重锚后由 onFrameReanchored 在数据位置加载
|
||||||
|
connect(basemap, qOverload<int>(&QComboBox::currentIndexChanged), this,
|
||||||
|
[this](int index) { emit basemapChanged(index); });
|
||||||
|
form->addRow(QStringLiteral("底图源"), basemap);
|
||||||
|
root->addWidget(new QLabel(QStringLiteral("地图")));
|
||||||
|
root->addLayout(form);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2D视图
|
||||||
|
{
|
||||||
|
auto* form = new QFormLayout();
|
||||||
|
auto* view2d = new QComboBox();
|
||||||
|
view2d->addItem(QStringLiteral("关闭"));
|
||||||
|
view2d->addItem(QStringLiteral("Z=0"));
|
||||||
|
view2d->addItem(QStringLiteral("顶部"));
|
||||||
|
view2d->addItem(QStringLiteral("底部"));
|
||||||
|
view2d->addItem(QStringLiteral("自定义"));
|
||||||
|
view2d->setCurrentIndex(1);
|
||||||
|
auto* zSpin = new QDoubleSpinBox();
|
||||||
|
zSpin->setRange(-1000000, 1000000);
|
||||||
|
zSpin->setSuffix(QStringLiteral(" m"));
|
||||||
|
zSpin->setValue(0);
|
||||||
|
connect(view2d, qOverload<int>(&QComboBox::currentIndexChanged), this,
|
||||||
|
[this, form, zSpin](int idx) {
|
||||||
|
form->setRowVisible(zSpin, idx == 4); // 整行隐藏(含"Z 值"标签),非自定义时不留孤标签
|
||||||
|
emit view2DModeChanged(idx);
|
||||||
|
});
|
||||||
|
connect(zSpin, qOverload<double>(&QDoubleSpinBox::valueChanged), this,
|
||||||
|
[this](double z) { emit customZChanged(z); });
|
||||||
|
form->addRow(QStringLiteral("位置"), view2d);
|
||||||
|
form->addRow(QStringLiteral("Z 值"), zSpin);
|
||||||
|
form->setRowVisible(zSpin, false); // 默认非自定义→隐藏整行
|
||||||
|
root->addWidget(new QLabel(QStringLiteral("2D视图")));
|
||||||
|
root->addLayout(form);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 数据集列表(可勾选)
|
||||||
|
list_ = new QTreeWidget();
|
||||||
|
list_->setHeaderHidden(true);
|
||||||
|
list_->setRootIsDecorated(true);
|
||||||
|
applyDatasetCardDelegate(list_);
|
||||||
|
connect(list_, &QTreeWidget::itemChanged, this, [this](QTreeWidgetItem*, int) {
|
||||||
|
QStringList ids;
|
||||||
|
for (QTreeWidgetItemIterator it(list_); *it; ++it) {
|
||||||
|
if ((*it)->checkState(0) == Qt::Checked)
|
||||||
|
ids << (*it)->data(0, kDsIdRole).toString();
|
||||||
|
}
|
||||||
|
emit checkedDatasetsChanged(ids);
|
||||||
|
});
|
||||||
|
root->addWidget(list_, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Column2DDataset::setDatasets(const std::vector<geopro::data::DsRow>& rows) {
|
||||||
|
{
|
||||||
|
QSignalBlocker blocker(list_);
|
||||||
|
populateDatasetList(list_, rows, /*append=*/false);
|
||||||
|
for (QTreeWidgetItemIterator it(list_); *it; ++it) {
|
||||||
|
(*it)->setFlags((*it)->flags() | Qt::ItemIsUserCheckable);
|
||||||
|
(*it)->setCheckState(0, Qt::Unchecked);
|
||||||
|
}
|
||||||
|
} // blocker released here
|
||||||
|
// 填充后统一发一次(新载入必为空选):清掉上一次的渲染勾选
|
||||||
|
QStringList ids;
|
||||||
|
for (QTreeWidgetItemIterator it(list_); *it; ++it)
|
||||||
|
if ((*it)->checkState(0) == Qt::Checked)
|
||||||
|
ids << (*it)->data(0, kDsIdRole).toString();
|
||||||
|
emit checkedDatasetsChanged(ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace geopro::app
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
#pragma once
|
||||||
|
#include <QWidget>
|
||||||
|
#include <QStringList>
|
||||||
|
#include <vector>
|
||||||
|
#include "repo/RepoTypes.hpp"
|
||||||
|
|
||||||
|
class QTreeWidget;
|
||||||
|
|
||||||
|
namespace geopro::app {
|
||||||
|
|
||||||
|
// 二维数据集栏:地图 + 2D视图(含自定义 Z) + 2D 数据集列表。
|
||||||
|
class Column2DDataset : public QWidget {
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
explicit Column2DDataset(QWidget* parent = nullptr);
|
||||||
|
void setDatasets(const std::vector<geopro::data::DsRow>& rows);
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void basemapChanged(int index); // 0 天地图 / 1 Google / 2 隐藏
|
||||||
|
void view2DModeChanged(int index); // 0 关闭 /1 Z=0 /2 顶部 /3 底部 /4 自定义
|
||||||
|
void customZChanged(double z); // 世界绝对高程(米),向上为正
|
||||||
|
void checkedDatasetsChanged(const QStringList& dsIds);
|
||||||
|
|
||||||
|
private:
|
||||||
|
QTreeWidget* list_ = nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace geopro::app
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
#include "panels/columns/Column3DAnalysis.hpp"
|
||||||
|
|
||||||
|
#include <QMenu>
|
||||||
|
#include <QSignalBlocker>
|
||||||
|
#include <QTreeWidget>
|
||||||
|
#include <QTreeWidgetItem>
|
||||||
|
#include <QTreeWidgetItemIterator>
|
||||||
|
#include <QVBoxLayout>
|
||||||
|
|
||||||
|
#include "Theme.hpp"
|
||||||
|
#include "panels/DatasetListPanel.hpp"
|
||||||
|
|
||||||
|
namespace geopro::app {
|
||||||
|
|
||||||
|
Column3DAnalysis::Column3DAnalysis(QWidget* parent) : QWidget(parent) {
|
||||||
|
auto* root = new QVBoxLayout(this);
|
||||||
|
root->setContentsMargins(space::kMd, space::kMd, space::kMd, space::kMd);
|
||||||
|
root->setSpacing(space::kMd);
|
||||||
|
|
||||||
|
tree_ = new QTreeWidget();
|
||||||
|
tree_->setHeaderHidden(true);
|
||||||
|
tree_->setRootIsDecorated(true);
|
||||||
|
applyDatasetCardDelegate(tree_);
|
||||||
|
tree_->setContextMenuPolicy(Qt::CustomContextMenu);
|
||||||
|
|
||||||
|
connect(tree_, &QTreeWidget::customContextMenuRequested, this, &Column3DAnalysis::onContextMenu);
|
||||||
|
|
||||||
|
connect(tree_, &QTreeWidget::itemChanged, this, [this](QTreeWidgetItem*, int) {
|
||||||
|
QStringList ids;
|
||||||
|
for (QTreeWidgetItemIterator it(tree_); *it; ++it) {
|
||||||
|
if ((*it)->checkState(0) == Qt::Checked)
|
||||||
|
ids << (*it)->data(0, kDsIdRole).toString();
|
||||||
|
}
|
||||||
|
emit checkedItemsChanged(ids);
|
||||||
|
});
|
||||||
|
|
||||||
|
root->addWidget(tree_, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Column3DAnalysis::setDatasets(const std::vector<geopro::data::DsRow>& rows) {
|
||||||
|
{
|
||||||
|
QSignalBlocker blocker(tree_);
|
||||||
|
populateDatasetList(tree_, rows, /*append=*/false);
|
||||||
|
for (QTreeWidgetItemIterator it(tree_); *it; ++it) {
|
||||||
|
(*it)->setFlags((*it)->flags() | Qt::ItemIsUserCheckable);
|
||||||
|
(*it)->setCheckState(0, Qt::Unchecked);
|
||||||
|
}
|
||||||
|
} // blocker released here
|
||||||
|
// 填充后统一发一次(新载入必为空选):清掉上一次的渲染勾选
|
||||||
|
QStringList ids;
|
||||||
|
for (QTreeWidgetItemIterator it(tree_); *it; ++it)
|
||||||
|
if ((*it)->checkState(0) == Qt::Checked)
|
||||||
|
ids << (*it)->data(0, kDsIdRole).toString();
|
||||||
|
emit checkedItemsChanged(ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Column3DAnalysis::onContextMenu(const QPoint& pos) {
|
||||||
|
QTreeWidgetItem* it = tree_->itemAt(pos);
|
||||||
|
if (!it) return;
|
||||||
|
|
||||||
|
const QString dsId = it->data(0, kDsIdRole).toString();
|
||||||
|
const QString ddCode = it->data(0, kDsDdCodeRole).toString();
|
||||||
|
const QString name = it->data(0, kDsNameRole).toString();
|
||||||
|
const bool isSlice = (ddCode == QStringLiteral("dd_slice"));
|
||||||
|
|
||||||
|
QMenu menu(this);
|
||||||
|
if (!isSlice) {
|
||||||
|
// 三维体数据集:切片▸(上下/前后/左右/任意) / 色阶 / 显示·隐藏 / 数据详情
|
||||||
|
QMenu* sub = menu.addMenu(QStringLiteral("切片"));
|
||||||
|
using SA = geopro::render::interact::SliceAxis;
|
||||||
|
sub->addAction(QStringLiteral("上下"), this, [this]{ emit sliceRequested(SA::UpDown); });
|
||||||
|
sub->addAction(QStringLiteral("前后"), this, [this]{ emit sliceRequested(SA::FrontBack); });
|
||||||
|
sub->addAction(QStringLiteral("左右"), this, [this]{ emit sliceRequested(SA::LeftRight); });
|
||||||
|
sub->addAction(QStringLiteral("任意"), this, [this]{ emit sliceRequested(SA::Oblique); });
|
||||||
|
menu.addAction(QStringLiteral("色阶"), this, [this, dsId]{ emit colorScaleRequested(dsId); });
|
||||||
|
menu.addAction(QStringLiteral("显示 / 隐藏"), this, [this, dsId]{ emit visibilityToggled(dsId); });
|
||||||
|
menu.addAction(QStringLiteral("数据详情"), this, [this, dsId, ddCode, name]{ emit detailRequested(dsId, ddCode, name); });
|
||||||
|
} else {
|
||||||
|
// 切片数据集:保存/保存为/导出/删除 — 色阶/显示·隐藏/数据详情
|
||||||
|
menu.addAction(QStringLiteral("保存"), this, [this, dsId]{ emit sliceSaveRequested(dsId); });
|
||||||
|
menu.addAction(QStringLiteral("保存为"), this, [this, dsId]{ emit sliceSaveAsRequested(dsId); });
|
||||||
|
menu.addAction(QStringLiteral("导出"), this, [this, dsId]{ emit sliceExportRequested(dsId); });
|
||||||
|
menu.addAction(QStringLiteral("删除"), this, [this, dsId]{ emit sliceDeleteRequested(dsId); });
|
||||||
|
menu.addSeparator();
|
||||||
|
menu.addAction(QStringLiteral("色阶"), this, [this, dsId]{ emit colorScaleRequested(dsId); });
|
||||||
|
menu.addAction(QStringLiteral("显示 / 隐藏"), this, [this, dsId]{ emit visibilityToggled(dsId); });
|
||||||
|
menu.addAction(QStringLiteral("数据详情"), this, [this, dsId, ddCode, name]{ emit detailRequested(dsId, ddCode, name); });
|
||||||
|
}
|
||||||
|
|
||||||
|
menu.exec(tree_->viewport()->mapToGlobal(pos));
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace geopro::app
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
#pragma once
|
||||||
|
#include <QWidget>
|
||||||
|
#include <QStringList>
|
||||||
|
#include <vector>
|
||||||
|
#include "repo/RepoTypes.hpp"
|
||||||
|
#include "interact/SlicePlaneMath.hpp" // SliceAxis
|
||||||
|
|
||||||
|
class QTreeWidget;
|
||||||
|
class QPoint;
|
||||||
|
|
||||||
|
namespace geopro::app {
|
||||||
|
|
||||||
|
// 三维分析栏:对象→三维体模型→切片 树 + 三维体/切片两套右键菜单。
|
||||||
|
class Column3DAnalysis : public QWidget {
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
explicit Column3DAnalysis(QWidget* parent = nullptr);
|
||||||
|
// 本期:按 ds parentId 建树(切片挂源数据下);完整 对象→三维体→切片 三级树待后端数据(P4)。
|
||||||
|
void setDatasets(const std::vector<geopro::data::DsRow>& rows); // Analysis 维度(三维体/切片)
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void sliceRequested(geopro::render::interact::SliceAxis axis); // 三维体右键 切片▸(上下/前后/左右/任意)
|
||||||
|
void colorScaleRequested(const QString& dsId);
|
||||||
|
void visibilityToggled(const QString& dsId);
|
||||||
|
void detailRequested(const QString& dsId, const QString& ddCode, const QString& name);
|
||||||
|
void sliceSaveRequested(const QString& dsId);
|
||||||
|
void sliceSaveAsRequested(const QString& dsId);
|
||||||
|
void sliceExportRequested(const QString& dsId);
|
||||||
|
void sliceDeleteRequested(const QString& dsId);
|
||||||
|
void checkedItemsChanged(const QStringList& dsIds);
|
||||||
|
|
||||||
|
private:
|
||||||
|
void onContextMenu(const QPoint& pos);
|
||||||
|
QTreeWidget* tree_ = nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace geopro::app
|
||||||
|
|
@ -0,0 +1,154 @@
|
||||||
|
#include "panels/columns/Column3DDataset.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
#include <QComboBox>
|
||||||
|
#include <QFormLayout>
|
||||||
|
#include <QHBoxLayout>
|
||||||
|
#include <QLabel>
|
||||||
|
#include <QPushButton>
|
||||||
|
#include <QSignalBlocker>
|
||||||
|
#include <QSlider>
|
||||||
|
#include <QTreeWidget>
|
||||||
|
#include <QTreeWidgetItemIterator>
|
||||||
|
#include <QVBoxLayout>
|
||||||
|
|
||||||
|
#include "Theme.hpp"
|
||||||
|
#include "panels/DatasetListPanel.hpp"
|
||||||
|
|
||||||
|
using geopro::controller::AxesMode;
|
||||||
|
using geopro::controller::AxesUnit;
|
||||||
|
using geopro::controller::ViewDir;
|
||||||
|
|
||||||
|
namespace geopro::app {
|
||||||
|
|
||||||
|
Column3DDataset::Column3DDataset(QWidget* parent) : QWidget(parent) {
|
||||||
|
auto* root = new QVBoxLayout(this);
|
||||||
|
root->setContentsMargins(space::kMd, space::kMd, space::kMd, space::kMd);
|
||||||
|
root->setSpacing(space::kMd);
|
||||||
|
|
||||||
|
// 坐标轴设置
|
||||||
|
{
|
||||||
|
auto* form = new QFormLayout();
|
||||||
|
auto* mode = new QComboBox();
|
||||||
|
mode->addItem(QStringLiteral("标准"), static_cast<int>(AxesMode::Standard));
|
||||||
|
mode->addItem(QStringLiteral("三维立体"), static_cast<int>(AxesMode::Stereo));
|
||||||
|
mode->addItem(QStringLiteral("不显示"), static_cast<int>(AxesMode::None));
|
||||||
|
connect(mode, qOverload<int>(&QComboBox::currentIndexChanged), this,
|
||||||
|
[this, mode](int) { emit axesModeChanged(static_cast<AxesMode>(mode->currentData().toInt())); });
|
||||||
|
auto* oPoint = new QPushButton(QStringLiteral("设置…"));
|
||||||
|
connect(oPoint, &QPushButton::clicked, this, &Column3DDataset::oPointClicked);
|
||||||
|
auto* unit = new QComboBox();
|
||||||
|
unit->addItem(QStringLiteral("无刻度"), static_cast<int>(AxesUnit::None));
|
||||||
|
unit->addItem(QStringLiteral("米"), static_cast<int>(AxesUnit::Meter));
|
||||||
|
unit->addItem(QStringLiteral("英尺"), static_cast<int>(AxesUnit::Feet));
|
||||||
|
unit->addItem(QStringLiteral("经纬度"), static_cast<int>(AxesUnit::LatLon));
|
||||||
|
unit->setCurrentIndex(1);
|
||||||
|
connect(unit, qOverload<int>(&QComboBox::currentIndexChanged), this,
|
||||||
|
[this, unit](int) { emit axesUnitChanged(static_cast<AxesUnit>(unit->currentData().toInt())); });
|
||||||
|
auto* font = new QPushButton(QStringLiteral("设置…"));
|
||||||
|
connect(font, &QPushButton::clicked, this, &Column3DDataset::fontClicked);
|
||||||
|
form->addRow(QStringLiteral("显示方式"), mode);
|
||||||
|
form->addRow(QStringLiteral("O点位置"), oPoint);
|
||||||
|
form->addRow(QStringLiteral("刻度"), unit);
|
||||||
|
form->addRow(QStringLiteral("字体"), font);
|
||||||
|
root->addWidget(new QLabel(QStringLiteral("坐标轴设置")));
|
||||||
|
root->addLayout(form);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 水平/垂直比例(单滑块 + 数值)。初值为中性 1×(无夸张);实际配置默认由组合根 setVerticalExaggeration 下发。
|
||||||
|
{
|
||||||
|
auto* row = new QHBoxLayout();
|
||||||
|
veSlider_ = new QSlider(Qt::Horizontal);
|
||||||
|
veSlider_->setMinimum(1);
|
||||||
|
veSlider_->setMaximum(10);
|
||||||
|
veSlider_->setValue(1);
|
||||||
|
veLabel_ = new QLabel(QStringLiteral("1.0×"));
|
||||||
|
connect(veSlider_, &QSlider::valueChanged, this, [this](int v) {
|
||||||
|
veLabel_->setText(QStringLiteral("%1.0×").arg(v));
|
||||||
|
emit verticalExaggerationChanged(static_cast<double>(v));
|
||||||
|
});
|
||||||
|
row->addWidget(veSlider_, 1);
|
||||||
|
row->addWidget(veLabel_);
|
||||||
|
root->addWidget(new QLabel(QStringLiteral("水平/垂直比例")));
|
||||||
|
root->addLayout(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 快捷视图
|
||||||
|
{
|
||||||
|
auto* row = new QHBoxLayout();
|
||||||
|
struct V {
|
||||||
|
const char* t;
|
||||||
|
ViewDir d;
|
||||||
|
};
|
||||||
|
const V views[] = {
|
||||||
|
{"前", ViewDir::Front}, {"后", ViewDir::Back}, {"左", ViewDir::Left},
|
||||||
|
{"右", ViewDir::Right}, {"上", ViewDir::Top}, {"下", ViewDir::Bottom},
|
||||||
|
};
|
||||||
|
for (const V& v : views) {
|
||||||
|
auto* b = new QPushButton(QString::fromUtf8(v.t));
|
||||||
|
ViewDir d = v.d;
|
||||||
|
connect(b, &QPushButton::clicked, this, [this, d] { emit viewRequested(d); });
|
||||||
|
row->addWidget(b);
|
||||||
|
}
|
||||||
|
root->addWidget(new QLabel(QStringLiteral("快捷视图")));
|
||||||
|
root->addLayout(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 缩放
|
||||||
|
{
|
||||||
|
auto* row = new QHBoxLayout();
|
||||||
|
auto* in = new QPushButton(QStringLiteral("放大"));
|
||||||
|
auto* out = new QPushButton(QStringLiteral("缩小"));
|
||||||
|
auto* fit = new QPushButton(QStringLiteral("适配"));
|
||||||
|
connect(in, &QPushButton::clicked, this, &Column3DDataset::zoomInRequested);
|
||||||
|
connect(out, &QPushButton::clicked, this, &Column3DDataset::zoomOutRequested);
|
||||||
|
connect(fit, &QPushButton::clicked, this, &Column3DDataset::fitRequested);
|
||||||
|
row->addWidget(in);
|
||||||
|
row->addWidget(out);
|
||||||
|
row->addWidget(fit);
|
||||||
|
root->addWidget(new QLabel(QStringLiteral("缩放")));
|
||||||
|
root->addLayout(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 数据集列表(可勾选)
|
||||||
|
list_ = new QTreeWidget();
|
||||||
|
list_->setHeaderHidden(true);
|
||||||
|
list_->setRootIsDecorated(true);
|
||||||
|
applyDatasetCardDelegate(list_);
|
||||||
|
connect(list_, &QTreeWidget::itemChanged, this, [this](QTreeWidgetItem*, int) {
|
||||||
|
QStringList ids;
|
||||||
|
for (QTreeWidgetItemIterator it(list_); *it; ++it) {
|
||||||
|
if ((*it)->checkState(0) == Qt::Checked)
|
||||||
|
ids << (*it)->data(0, kDsIdRole).toString();
|
||||||
|
}
|
||||||
|
emit checkedDatasetsChanged(ids);
|
||||||
|
});
|
||||||
|
root->addWidget(list_, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Column3DDataset::setVerticalExaggeration(double ve) {
|
||||||
|
const int v = std::max(1, static_cast<int>(ve + 0.5));
|
||||||
|
QSignalBlocker block(veSlider_); // 仅同步 UI 显示;传播由组合根分发,避免重复发信号
|
||||||
|
veSlider_->setValue(v);
|
||||||
|
veLabel_->setText(QStringLiteral("%1.0×").arg(v));
|
||||||
|
}
|
||||||
|
|
||||||
|
void Column3DDataset::setDatasets(const std::vector<geopro::data::DsRow>& rows) {
|
||||||
|
{
|
||||||
|
QSignalBlocker blocker(list_);
|
||||||
|
populateDatasetList(list_, rows, /*append=*/false);
|
||||||
|
for (QTreeWidgetItemIterator it(list_); *it; ++it) {
|
||||||
|
(*it)->setFlags((*it)->flags() | Qt::ItemIsUserCheckable);
|
||||||
|
(*it)->setCheckState(0, Qt::Unchecked);
|
||||||
|
}
|
||||||
|
} // blocker released here
|
||||||
|
// 填充后统一发一次(新载入必为空选):清掉上一次的渲染勾选
|
||||||
|
QStringList ids;
|
||||||
|
for (QTreeWidgetItemIterator it(list_); *it; ++it)
|
||||||
|
if ((*it)->checkState(0) == Qt::Checked)
|
||||||
|
ids << (*it)->data(0, kDsIdRole).toString();
|
||||||
|
emit checkedDatasetsChanged(ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace geopro::app
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
#pragma once
|
||||||
|
#include <QWidget>
|
||||||
|
#include <QStringList>
|
||||||
|
#include <vector>
|
||||||
|
#include "I3dSceneView.hpp" // AxesMode/AxesUnit/ViewDir
|
||||||
|
#include "repo/RepoTypes.hpp"
|
||||||
|
|
||||||
|
class QTreeWidget;
|
||||||
|
class QSlider;
|
||||||
|
class QLabel;
|
||||||
|
|
||||||
|
namespace geopro::app {
|
||||||
|
|
||||||
|
// 三维数据集栏:坐标轴设置 + 水平/垂直比例 + 快捷视图 + 缩放 + 3D 数据集列表。
|
||||||
|
class Column3DDataset : public QWidget {
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
explicit Column3DDataset(QWidget* parent = nullptr);
|
||||||
|
void setDatasets(const std::vector<geopro::data::DsRow>& rows);
|
||||||
|
void setVerticalExaggeration(double ve); // 组合根下发配置默认值(仅同步UI显示,不重复发信号)
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void axesModeChanged(geopro::controller::AxesMode mode);
|
||||||
|
void axesUnitChanged(geopro::controller::AxesUnit unit);
|
||||||
|
void verticalExaggerationChanged(double ve);
|
||||||
|
void viewRequested(geopro::controller::ViewDir dir);
|
||||||
|
void zoomInRequested();
|
||||||
|
void zoomOutRequested();
|
||||||
|
void fitRequested();
|
||||||
|
void oPointClicked();
|
||||||
|
void fontClicked();
|
||||||
|
void checkedDatasetsChanged(const QStringList& dsIds);
|
||||||
|
|
||||||
|
private:
|
||||||
|
QTreeWidget* list_ = nullptr;
|
||||||
|
QSlider* veSlider_ = nullptr; // 水平/垂直比例滑块
|
||||||
|
QLabel* veLabel_ = nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace geopro::app
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
#include "panels/columns/ColumnDrawer.hpp"
|
||||||
|
#include "panels/columns/Column3DDataset.hpp"
|
||||||
|
#include "panels/columns/Column2DDataset.hpp"
|
||||||
|
#include "panels/columns/Column3DAnalysis.hpp"
|
||||||
|
#include "Glyphs.hpp"
|
||||||
|
#include "Theme.hpp"
|
||||||
|
#include <QHBoxLayout>
|
||||||
|
#include <QPushButton>
|
||||||
|
#include <QTabWidget>
|
||||||
|
|
||||||
|
namespace geopro::app {
|
||||||
|
|
||||||
|
ColumnDrawer::ColumnDrawer(QWidget* parent)
|
||||||
|
: QWidget(parent)
|
||||||
|
{
|
||||||
|
// 创建三个栏
|
||||||
|
col3D_ = new Column3DDataset(this);
|
||||||
|
col2D_ = new Column2DDataset(this);
|
||||||
|
colAnalysis_ = new Column3DAnalysis(this);
|
||||||
|
|
||||||
|
// Tab 容器(body_)
|
||||||
|
auto* tabs = new QTabWidget(this);
|
||||||
|
body_ = tabs;
|
||||||
|
tabs->addTab(col3D_, QStringLiteral("三维数据集"));
|
||||||
|
tabs->addTab(col2D_, QStringLiteral("二维数据集"));
|
||||||
|
tabs->addTab(colAnalysis_, QStringLiteral("三维分析"));
|
||||||
|
|
||||||
|
// 折叠按钮:固定宽 18px,垂直拉伸。
|
||||||
|
// 用 SVG 图标(makeGlyph)而非 ◀/▶ 文字——三角符(U+25C0/25B6)不在 YaHei,作按钮文字会触发
|
||||||
|
// Qt 字体回退,本机 DirectWrite 在回退枚举时崩(0xc0000005)。SVG 图标走 QIcon 不做字体整形,规避之。
|
||||||
|
toggleBtn_ = new QPushButton(this);
|
||||||
|
toggleBtn_->setIcon(geopro::app::makeGlyph(Glyph::ChevronLeft,
|
||||||
|
geopro::app::tokenColor("text/secondary"), 14));
|
||||||
|
toggleBtn_->setFixedWidth(18);
|
||||||
|
toggleBtn_->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Expanding);
|
||||||
|
toggleBtn_->setCursor(Qt::PointingHandCursor);
|
||||||
|
toggleBtn_->setToolTip(QStringLiteral("折叠 / 展开"));
|
||||||
|
geopro::app::applyTokenizedStyleSheet(toggleBtn_,
|
||||||
|
QStringLiteral("QPushButton{background:{{bg/panel-subtle}};color:{{text/secondary}};"
|
||||||
|
"border:none;border-left:1px solid {{border/default}};font-size:12px;}"
|
||||||
|
"QPushButton:hover{background:{{bg/hover}};color:{{accent/primary}};}"));
|
||||||
|
connect(toggleBtn_, &QPushButton::clicked, this, &ColumnDrawer::toggleCollapsed);
|
||||||
|
|
||||||
|
// 根布局:[body_ | toggleBtn_],无边距
|
||||||
|
auto* root = new QHBoxLayout(this);
|
||||||
|
root->setContentsMargins(0, 0, 0, 0);
|
||||||
|
root->setSpacing(0);
|
||||||
|
root->addWidget(body_, 1);
|
||||||
|
root->addWidget(toggleBtn_, 0);
|
||||||
|
|
||||||
|
// 展开时限宽 ~318px (300 body + 18 btn)
|
||||||
|
setMaximumWidth(318);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ColumnDrawer::toggleCollapsed()
|
||||||
|
{
|
||||||
|
collapsed_ = !collapsed_;
|
||||||
|
body_->setVisible(!collapsed_);
|
||||||
|
toggleBtn_->setIcon(geopro::app::makeGlyph(collapsed_ ? Glyph::ChevronRight : Glyph::ChevronLeft,
|
||||||
|
geopro::app::tokenColor("text/secondary"), 14));
|
||||||
|
// 折叠后只保留按钮宽度;展开恢复上限
|
||||||
|
setMaximumWidth(collapsed_ ? 18 : 318);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ColumnDrawer::expand()
|
||||||
|
{
|
||||||
|
if (collapsed_) toggleCollapsed(); // 仅在折叠时展开
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace geopro::app
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
#pragma once
|
||||||
|
#include <QWidget>
|
||||||
|
|
||||||
|
class QPushButton;
|
||||||
|
|
||||||
|
namespace geopro::app {
|
||||||
|
|
||||||
|
class Column3DDataset;
|
||||||
|
class Column2DDataset;
|
||||||
|
class Column3DAnalysis;
|
||||||
|
|
||||||
|
// VTK视图左侧内嵌抽屉:三 tab(三维数据集/二维数据集/三维分析) + 折叠开关。
|
||||||
|
class ColumnDrawer : public QWidget {
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
explicit ColumnDrawer(QWidget* parent = nullptr);
|
||||||
|
|
||||||
|
Column3DDataset* col3D() const { return col3D_; }
|
||||||
|
Column2DDataset* col2D() const { return col2D_; }
|
||||||
|
Column3DAnalysis* colAnalysis() const { return colAnalysis_; }
|
||||||
|
|
||||||
|
public slots:
|
||||||
|
void toggleCollapsed();
|
||||||
|
void expand(); // 强制展开(进入全屏时确保三栏可见)
|
||||||
|
|
||||||
|
private:
|
||||||
|
Column3DDataset* col3D_ = nullptr;
|
||||||
|
Column2DDataset* col2D_ = nullptr;
|
||||||
|
Column3DAnalysis* colAnalysis_ = nullptr;
|
||||||
|
QWidget* body_ = nullptr; // QTabWidget,折叠时隐藏
|
||||||
|
QPushButton* toggleBtn_ = nullptr;
|
||||||
|
bool collapsed_ = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace geopro::app
|
||||||
|
|
@ -1,10 +1,19 @@
|
||||||
#pragma once
|
#pragma once
|
||||||
|
#include <string>
|
||||||
|
|
||||||
#include "model/ColorScale.hpp"
|
#include "model/ColorScale.hpp"
|
||||||
#include "model/Field.hpp"
|
#include "model/Field.hpp"
|
||||||
#include "repo/I3dSceneRepository.hpp"
|
#include "repo/I3dSceneRepository.hpp"
|
||||||
|
|
||||||
namespace geopro::controller {
|
namespace geopro::controller {
|
||||||
|
|
||||||
|
// 坐标轴显示方式(spec §4 C3–I3):标准 / 三维立体 / 不显示。
|
||||||
|
enum class AxesMode { Standard, Stereo, None };
|
||||||
|
// 坐标轴刻度单位(spec §4 D5–I5):无 / 米 / 英尺 / 经纬度。
|
||||||
|
enum class AxesUnit { None, Meter, Feet, LatLon };
|
||||||
|
// 快捷视图方向(spec §4 C7):前/后/左/右/上/下。
|
||||||
|
enum class ViewDir { Front, Back, Left, Right, Top, Bottom };
|
||||||
|
|
||||||
// 三维场景视图抽象(编排层与 VTK 渲染解耦的缝):
|
// 三维场景视图抽象(编排层与 VTK 渲染解耦的缝):
|
||||||
// VtkSceneController 只发出"清场 / 加某类图元 / 提交渲染"指令,不认 vtkActor/vtkVolume;
|
// VtkSceneController 只发出"清场 / 加某类图元 / 提交渲染"指令,不认 vtkActor/vtkVolume;
|
||||||
// 真实实现(VtkSceneView)调 render actor + Scene;测试用 fake 记录调用断言编排。
|
// 真实实现(VtkSceneView)调 render actor + Scene;测试用 fake 记录调用断言编排。
|
||||||
|
|
@ -18,17 +27,32 @@ public:
|
||||||
|
|
||||||
// 2D:俯视测线红线(z=0)。
|
// 2D:俯视测线红线(z=0)。
|
||||||
virtual void addSurveyLine(const geopro::core::Grid& grid) = 0;
|
virtual void addSurveyLine(const geopro::core::Grid& grid) = 0;
|
||||||
// 3D:竖直帘面(grid + colorScale 着色)。
|
// 3D:竖直帘面(grid + colorScale 着色);按 dsId 跟踪以支持增量移除。
|
||||||
virtual void addCurtain(const geopro::core::Grid& grid,
|
virtual void addCurtain(const std::string& dsId, const geopro::core::Grid& grid,
|
||||||
const geopro::core::ColorScale& cs) = 0;
|
const geopro::core::ColorScale& cs) = 0;
|
||||||
// 3D:体绘制(IDW 体素 + colorScale)。
|
// 3D:体绘制(IDW 体素 + colorScale);按 dsId 跟踪。
|
||||||
virtual void addVolume(const geopro::data::VolumeGrid& vol,
|
virtual void addVolume(const std::string& dsId, const geopro::data::VolumeGrid& vol,
|
||||||
const geopro::core::ColorScale& cs) = 0;
|
const geopro::core::ColorScale& cs) = 0;
|
||||||
// 3D:DEM 地形 + 影像纹理。
|
// 3D:DEM 地形 + 影像纹理。
|
||||||
virtual void addTerrain(const geopro::data::TerrainPaths& paths) = 0;
|
virtual void addTerrain(const geopro::data::TerrainPaths& paths) = 0;
|
||||||
|
// 增量移除某数据集的全部图元(取消勾选时调,不影响其余 ds 与底图)。
|
||||||
|
virtual void removeDataset(const std::string& dsId) = 0;
|
||||||
|
|
||||||
// 应用相机预设(2D 俯视 / 3D 自由)并提交渲染。
|
// 坐标轴设置(P2):显示方式 + 刻度单位 + 字号。视图据当前场景包围盒重建坐标轴 prop。
|
||||||
|
// None 模式 = 移除坐标轴;rebuild 时由控制器在 clear 后重新下发当前坐标轴设置。
|
||||||
|
virtual void setAxes(AxesMode mode, AxesUnit unit, int fontSize) = 0;
|
||||||
|
|
||||||
|
// 快捷视图(P2):应用 6 向相机预设并提交渲染。
|
||||||
|
virtual void applyCameraView(ViewDir dir) = 0;
|
||||||
|
// 缩放(P2):factor>1 放大、<1 缩小,提交渲染。
|
||||||
|
virtual void zoom(double factor) = 0;
|
||||||
|
// 适配全览(P2):ResetCamera 并提交渲染。
|
||||||
|
virtual void fitView() = 0;
|
||||||
|
|
||||||
|
// 应用相机预设(2D 俯视 / 3D 自由)并提交渲染(全量重建用,会 ResetCamera)。
|
||||||
virtual void render(bool is2D) = 0;
|
virtual void render(bool is2D) = 0;
|
||||||
|
// 增量提交渲染:重建坐标轴并刷新,但不动相机(勾选/取消单个 ds 时视角不跳)。
|
||||||
|
virtual void renderIncremental() = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace geopro::controller
|
} // namespace geopro::controller
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
#include "VtkSceneController.hpp"
|
#include "VtkSceneController.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <set>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
|
|
||||||
#include <QPointer>
|
#include <QPointer>
|
||||||
|
|
@ -15,10 +17,85 @@ VtkSceneController::VtkSceneController(data::IDatasetRepository& dsRepo,
|
||||||
: QObject(parent), dsRepo_(dsRepo), sceneRepo_(sceneRepo), view_(view) {}
|
: QObject(parent), dsRepo_(dsRepo), sceneRepo_(sceneRepo), view_(view) {}
|
||||||
|
|
||||||
void VtkSceneController::setCheckedDatasets(const QStringList& dsIds) {
|
void VtkSceneController::setCheckedDatasets(const QStringList& dsIds) {
|
||||||
checkedDs_.clear();
|
std::vector<std::string> newDs;
|
||||||
checkedDs_.reserve(static_cast<std::size_t>(dsIds.size()));
|
newDs.reserve(static_cast<std::size_t>(dsIds.size()));
|
||||||
for (const QString& id : dsIds) checkedDs_.push_back(id.toStdString());
|
for (const QString& id : dsIds) newDs.push_back(id.toStdString());
|
||||||
rebuildInternal();
|
|
||||||
|
// 2D 俯视测线:保持全量重建(测线非按 ds 跟踪移除)。
|
||||||
|
if (mode_ == ViewMode::Map2D) {
|
||||||
|
checkedDs_ = std::move(newDs);
|
||||||
|
rebuildInternal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3D:增量 diff —— 只处理新增/移除,不全量重建(底图、其余 ds、相机均不动)。
|
||||||
|
const std::set<std::string> oldSet(checkedDs_.begin(), checkedDs_.end());
|
||||||
|
const std::set<std::string> newSet(newDs.begin(), newDs.end());
|
||||||
|
const bool wasEmpty = checkedDs_.empty();
|
||||||
|
|
||||||
|
for (const auto& id : checkedDs_)
|
||||||
|
if (!newSet.count(id)) view_.removeDataset(id); // 移除:旧有新无 → 仅删该 ds 图元
|
||||||
|
|
||||||
|
checkedDs_ = std::move(newDs);
|
||||||
|
fitOnArrival_ = wasEmpty; // 仅从空开始时让到场数据自动取景;增量追加保持当前相机不跳
|
||||||
|
|
||||||
|
const unsigned long long gen = rebuildGeneration_; // 不自增:并发增量互不作废
|
||||||
|
for (const auto& id : checkedDs_)
|
||||||
|
if (!oldSet.count(id)) addDatasetAsync(id, gen); // 新增:新有旧无 → 异步取数增量入场
|
||||||
|
|
||||||
|
view_.renderIncremental(); // 立即反映移除 / 触发坐标轴重算
|
||||||
|
}
|
||||||
|
|
||||||
|
void VtkSceneController::addDatasetAsync(const std::string& dsId, unsigned long long gen) {
|
||||||
|
if (loadingDs_.count(dsId)) return; // 已在加载(重复勾选竞态)→ 不重复请求
|
||||||
|
QPointer<VtkSceneController> self(this);
|
||||||
|
if (showCurtain_) {
|
||||||
|
loadingDs_.insert(dsId);
|
||||||
|
sceneRepo_.loadSection(
|
||||||
|
dsId,
|
||||||
|
[self, gen, dsId](data::SectionData s) {
|
||||||
|
if (!self) return;
|
||||||
|
self->loadingDs_.erase(dsId);
|
||||||
|
if (gen != self->rebuildGeneration_ || !self->isChecked(dsId)) return; // 作废/已取消
|
||||||
|
self->view_.addCurtain(dsId, s.grid, s.scale);
|
||||||
|
self->onDatasetArrived();
|
||||||
|
},
|
||||||
|
[self, gen, dsId](const std::string& m) {
|
||||||
|
if (!self) return;
|
||||||
|
self->loadingDs_.erase(dsId);
|
||||||
|
if (gen != self->rebuildGeneration_) return;
|
||||||
|
emit self->loadFailed(QString::fromStdString(m));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (showVoxel_) {
|
||||||
|
auto cached = volumeCache_.find(dsId);
|
||||||
|
if (cached != volumeCache_.end()) {
|
||||||
|
view_.addVolume(dsId, cached->second, colorScale(dsId));
|
||||||
|
onDatasetArrived();
|
||||||
|
} else {
|
||||||
|
sceneRepo_.loadVolume(
|
||||||
|
dsId,
|
||||||
|
[self, gen, dsId](data::VolumeGrid g) {
|
||||||
|
if (!self || gen != self->rebuildGeneration_ || !self->isChecked(dsId)) return;
|
||||||
|
auto it = self->volumeCache_.emplace(dsId, std::move(g)).first;
|
||||||
|
self->view_.addVolume(dsId, it->second, self->colorScale(dsId));
|
||||||
|
self->onDatasetArrived();
|
||||||
|
},
|
||||||
|
[self, gen](const std::string& m) {
|
||||||
|
if (!self || gen != self->rebuildGeneration_) return;
|
||||||
|
emit self->loadFailed(QString::fromStdString(m));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void VtkSceneController::onDatasetArrived() {
|
||||||
|
view_.renderIncremental();
|
||||||
|
if (fitOnArrival_) view_.fitView(); // 全量重建/首批数据 → 自动取景;增量追加保持相机
|
||||||
|
}
|
||||||
|
|
||||||
|
bool VtkSceneController::isChecked(const std::string& dsId) const {
|
||||||
|
return std::find(checkedDs_.begin(), checkedDs_.end(), dsId) != checkedDs_.end();
|
||||||
}
|
}
|
||||||
|
|
||||||
void VtkSceneController::setViewMode(ViewMode mode) {
|
void VtkSceneController::setViewMode(ViewMode mode) {
|
||||||
|
|
@ -42,6 +119,22 @@ void VtkSceneController::setVerticalExaggeration(double ve) {
|
||||||
|
|
||||||
void VtkSceneController::rebuild() { rebuildInternal(); }
|
void VtkSceneController::rebuild() { rebuildInternal(); }
|
||||||
|
|
||||||
|
void VtkSceneController::setAxesMode(AxesMode mode) {
|
||||||
|
axesMode_ = mode;
|
||||||
|
rebuildInternal(); // 坐标轴随场景重建(clear 会移除旧坐标轴 prop)
|
||||||
|
}
|
||||||
|
|
||||||
|
void VtkSceneController::setAxesUnit(AxesUnit unit) {
|
||||||
|
axesUnit_ = unit;
|
||||||
|
rebuildInternal();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 快捷视图 / 缩放:仅改相机,不重建场景(无须取数/重装图元)。
|
||||||
|
void VtkSceneController::applyView(ViewDir dir) { view_.applyCameraView(dir); }
|
||||||
|
void VtkSceneController::zoomIn() { view_.zoom(1.2); }
|
||||||
|
void VtkSceneController::zoomOut() { view_.zoom(1.0 / 1.2); }
|
||||||
|
void VtkSceneController::fit() { view_.fitView(); }
|
||||||
|
|
||||||
const geopro::core::Grid& VtkSceneController::grid(const std::string& dsId) {
|
const geopro::core::Grid& VtkSceneController::grid(const std::string& dsId) {
|
||||||
auto it = gridCache_.find(dsId);
|
auto it = gridCache_.find(dsId);
|
||||||
if (it == gridCache_.end()) it = gridCache_.emplace(dsId, dsRepo_.loadGrid(dsId)).first;
|
if (it == gridCache_.end()) it = gridCache_.emplace(dsId, dsRepo_.loadGrid(dsId)).first;
|
||||||
|
|
@ -56,66 +149,42 @@ const geopro::core::ColorScale& VtkSceneController::colorScale(const std::string
|
||||||
}
|
}
|
||||||
|
|
||||||
void VtkSceneController::rebuildInternal() {
|
void VtkSceneController::rebuildInternal() {
|
||||||
const unsigned long long gen = ++rebuildGeneration_;
|
const unsigned long long gen = ++rebuildGeneration_; // 自增:作废此前所有在途增量回调
|
||||||
const bool is2D = (mode_ == ViewMode::Map2D);
|
const bool is2D = (mode_ == ViewMode::Map2D);
|
||||||
|
|
||||||
view_.clear();
|
view_.clear(); // 移除全部数据图元(保留底图);frame 重锚标志复位
|
||||||
|
loadingDs_.clear(); // 旧在途加载随之作废(回调按 gen 丢弃)
|
||||||
view_.setVerticalExaggeration(verticalExaggeration_);
|
view_.setVerticalExaggeration(verticalExaggeration_);
|
||||||
|
// 坐标轴设置在 clear 后下发:render 末尾据当前场景包围盒重建坐标轴 prop。
|
||||||
|
view_.setAxes(axesMode_, axesUnit_, kAxesFontSize);
|
||||||
|
fitOnArrival_ = true; // 全量重建:到场数据自动取景
|
||||||
|
|
||||||
inRebuild_ = true;
|
// 坏 dsId(loadGrid/loadColorScale 抛异常)= best-effort 跳过:emit loadFailed 但不中断。
|
||||||
// 坏 dsId(loadGrid/loadColorScale 抛异常)= best-effort 跳过:emit loadFailed 但不中断,
|
|
||||||
// 其余勾选数据集照常渲染(非 fail-fast)。
|
|
||||||
try {
|
try {
|
||||||
if (is2D) {
|
if (is2D) {
|
||||||
for (const auto& dsId : checkedDs_) view_.addSurveyLine(grid(dsId));
|
for (const auto& dsId : checkedDs_) view_.addSurveyLine(grid(dsId));
|
||||||
} else {
|
} else {
|
||||||
// 回调用 QPointer<self> 守对象存活(控制器是 QObject)+ gen 守数据新鲜:
|
// 回调用 QPointer<self> 守对象存活 + gen 守数据新鲜:迟到回调若已析构/作废则丢弃。
|
||||||
// 将来 Api 实现在网络线程迟到回调时,self 已析构则直接丢弃,不触 dangling。
|
|
||||||
QPointer<VtkSceneController> self(this);
|
QPointer<VtkSceneController> self(this);
|
||||||
if (showTerrain_) {
|
if (showTerrain_) {
|
||||||
sceneRepo_.loadTerrainPaths(
|
sceneRepo_.loadTerrainPaths(
|
||||||
[self, gen](data::TerrainPaths p) {
|
[self, gen](data::TerrainPaths p) {
|
||||||
if (!self || gen != self->rebuildGeneration_) return; // 已析构/迟到:丢弃
|
if (!self || gen != self->rebuildGeneration_) return; // 已析构/迟到:丢弃
|
||||||
self->view_.addTerrain(std::move(p));
|
self->view_.addTerrain(std::move(p));
|
||||||
if (!self->inRebuild_) self->view_.render(false); // 同步路径由末尾统一 render
|
self->onDatasetArrived();
|
||||||
},
|
},
|
||||||
[self, gen](const std::string& m) {
|
[self, gen](const std::string& m) {
|
||||||
if (!self || gen != self->rebuildGeneration_) return;
|
if (!self || gen != self->rebuildGeneration_) return;
|
||||||
emit self->loadFailed(QString::fromStdString(m));
|
emit self->loadFailed(QString::fromStdString(m));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (showCurtain_) {
|
for (const auto& dsId : checkedDs_) addDatasetAsync(dsId, gen);
|
||||||
for (const auto& dsId : checkedDs_) view_.addCurtain(grid(dsId), colorScale(dsId));
|
|
||||||
}
|
|
||||||
if (showVoxel_) {
|
|
||||||
for (const auto& dsId : checkedDs_) {
|
|
||||||
auto cached = volumeCache_.find(dsId);
|
|
||||||
if (cached != volumeCache_.end()) {
|
|
||||||
view_.addVolume(cached->second, colorScale(dsId));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
sceneRepo_.loadVolume(
|
|
||||||
dsId,
|
|
||||||
[self, gen, dsId](data::VolumeGrid g) {
|
|
||||||
if (!self) return; // 控制器已析构:丢弃
|
|
||||||
if (gen != self->rebuildGeneration_) return; // 迟到回灌:丢弃
|
|
||||||
auto it = self->volumeCache_.emplace(dsId, std::move(g)).first;
|
|
||||||
self->view_.addVolume(it->second, self->colorScale(dsId));
|
|
||||||
if (!self->inRebuild_) self->view_.render(false); // 同步路径由末尾统一 render
|
|
||||||
},
|
|
||||||
[self, gen](const std::string& m) {
|
|
||||||
if (!self || gen != self->rebuildGeneration_) return;
|
|
||||||
emit self->loadFailed(QString::fromStdString(m));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (const std::exception& e) {
|
} catch (const std::exception& e) {
|
||||||
emit loadFailed(QString::fromStdString(e.what()));
|
emit loadFailed(QString::fromStdString(e.what()));
|
||||||
}
|
}
|
||||||
|
|
||||||
inRebuild_ = false;
|
view_.render(is2D); // 设背景/相机预设/坐标轴 + ResetCamera(数据到场再由 onDatasetArrived 取景)
|
||||||
view_.render(is2D);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace geopro::controller
|
} // namespace geopro::controller
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,10 @@
|
||||||
#include <QStringList>
|
#include <QStringList>
|
||||||
#include <map>
|
#include <map>
|
||||||
#include <optional>
|
#include <optional>
|
||||||
|
#include <set>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
|
#include "I3dSceneView.hpp"
|
||||||
#include "model/ColorScale.hpp"
|
#include "model/ColorScale.hpp"
|
||||||
#include "model/Field.hpp"
|
#include "model/Field.hpp"
|
||||||
#include "repo/I3dSceneRepository.hpp"
|
#include "repo/I3dSceneRepository.hpp"
|
||||||
|
|
@ -16,8 +18,6 @@ class IDatasetRepository;
|
||||||
|
|
||||||
namespace geopro::controller {
|
namespace geopro::controller {
|
||||||
|
|
||||||
class I3dSceneView;
|
|
||||||
|
|
||||||
// 中央视图模式:二维地图(俯视测线)/ 三维视图(帘面/体素/地形)。
|
// 中央视图模式:二维地图(俯视测线)/ 三维视图(帘面/体素/地形)。
|
||||||
enum class ViewMode { Map2D, View3D };
|
enum class ViewMode { Map2D, View3D };
|
||||||
|
|
||||||
|
|
@ -42,11 +42,23 @@ public slots:
|
||||||
void setVerticalExaggeration(double ve);
|
void setVerticalExaggeration(double ve);
|
||||||
void rebuild(); // 主题切换等外部触发的重渲染
|
void rebuild(); // 主题切换等外部触发的重渲染
|
||||||
|
|
||||||
|
// ── P2 三维数据集栏 ──
|
||||||
|
void setAxesMode(AxesMode mode);
|
||||||
|
void setAxesUnit(AxesUnit unit);
|
||||||
|
void applyView(ViewDir dir); // 6 向快捷视图
|
||||||
|
void zoomIn(); // Zoom In (×1.2)
|
||||||
|
void zoomOut(); // Zoom Out (×1/1.2)
|
||||||
|
void fit(); // Fit (ResetCamera)
|
||||||
|
|
||||||
signals:
|
signals:
|
||||||
void loadFailed(const QString& message);
|
void loadFailed(const QString& message);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
void rebuildInternal();
|
void rebuildInternal();
|
||||||
|
// 增量加入单个 ds(帘面/体素,按图层开关);回调按 gen + 仍勾选 守护,落地后增量渲染。
|
||||||
|
void addDatasetAsync(const std::string& dsId, unsigned long long gen);
|
||||||
|
void onDatasetArrived(); // 单个 ds 落地后:增量渲染 + 首批数据自动取景
|
||||||
|
bool isChecked(const std::string& dsId) const;
|
||||||
|
|
||||||
data::IDatasetRepository& dsRepo_;
|
data::IDatasetRepository& dsRepo_;
|
||||||
data::I3dSceneRepository& sceneRepo_;
|
data::I3dSceneRepository& sceneRepo_;
|
||||||
|
|
@ -57,19 +69,25 @@ private:
|
||||||
bool showCurtain_ = true;
|
bool showCurtain_ = true;
|
||||||
bool showVoxel_ = false;
|
bool showVoxel_ = false;
|
||||||
bool showTerrain_ = false;
|
bool showTerrain_ = false;
|
||||||
double verticalExaggeration_ = 2.0;
|
double verticalExaggeration_ = 1.0;
|
||||||
|
|
||||||
|
// 坐标轴设置(P2):默认标准 + 米;字号固定 12(字体设置待 1.0 确认)。
|
||||||
|
AxesMode axesMode_ = AxesMode::Standard;
|
||||||
|
AxesUnit axesUnit_ = AxesUnit::Meter;
|
||||||
|
static constexpr int kAxesFontSize = 12;
|
||||||
|
|
||||||
// 缓存(按 dsId):避免重复读盘/插值。
|
// 缓存(按 dsId):避免重复读盘/插值。
|
||||||
std::map<std::string, geopro::core::Grid> gridCache_;
|
std::map<std::string, geopro::core::Grid> gridCache_;
|
||||||
std::map<std::string, geopro::core::ColorScale> colorScaleCache_;
|
std::map<std::string, geopro::core::ColorScale> colorScaleCache_;
|
||||||
std::map<std::string, data::VolumeGrid> volumeCache_;
|
std::map<std::string, data::VolumeGrid> volumeCache_;
|
||||||
|
|
||||||
// 异步回灌防护:每次 rebuild 自增,回调比对丢弃迟到结果。
|
// 异步回灌防护:每次全量 rebuild 自增,回调比对丢弃迟到结果。
|
||||||
unsigned long long rebuildGeneration_ = 0;
|
unsigned long long rebuildGeneration_ = 0;
|
||||||
// rebuild 进行中标志:同步回调(LocalSample)在 rebuild 内立即触发时跳过自身 render,
|
|
||||||
// 由 rebuildInternal 末尾统一 render 覆盖(避免双重 ResetCamera/Render);
|
// 增量渲染状态:本批数据到场是否自动取景(全量重建/从空开始=true;增量追加=false,保持相机)。
|
||||||
// 真异步回调迟到时 inRebuild_ 已 false → 自行 render 追加。
|
bool fitOnArrival_ = true;
|
||||||
bool inRebuild_ = false;
|
// 正在加载的 ds:防重复勾选竞态重复请求;全量重建时清空。
|
||||||
|
std::set<std::string> loadingDs_;
|
||||||
|
|
||||||
const geopro::core::Grid& grid(const std::string& dsId);
|
const geopro::core::Grid& grid(const std::string& dsId);
|
||||||
const geopro::core::ColorScale& colorScale(const std::string& dsId);
|
const geopro::core::ColorScale& colorScale(const std::string& dsId);
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,19 @@ GeoLocalFrame::GeoLocalFrame(double lat0, double lon0)
|
||||||
mPerDegLon_(kMetersPerDegLonEquator * std::cos(lat0 * kPi / 180.0)),
|
mPerDegLon_(kMetersPerDegLonEquator * std::cos(lat0 * kPi / 180.0)),
|
||||||
mPerDegLat_(kMetersPerDegLat) {}
|
mPerDegLat_(kMetersPerDegLat) {}
|
||||||
|
|
||||||
|
void GeoLocalFrame::reanchor(double lat0, double lon0) {
|
||||||
|
lat0_ = lat0;
|
||||||
|
lon0_ = lon0;
|
||||||
|
mPerDegLon_ = kMetersPerDegLonEquator * std::cos(lat0 * kPi / 180.0);
|
||||||
|
// mPerDegLat_ 为常数,无需更新。
|
||||||
|
}
|
||||||
|
|
||||||
LocalXY GeoLocalFrame::toLocal(double lat, double lon) const {
|
LocalXY GeoLocalFrame::toLocal(double lat, double lon) const {
|
||||||
return LocalXY{(lon - lon0_) * mPerDegLon_, (lat - lat0_) * mPerDegLat_};
|
return LocalXY{(lon - lon0_) * mPerDegLon_, (lat - lat0_) * mPerDegLat_};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LatLon GeoLocalFrame::toLatLon(double x, double y) const {
|
||||||
|
return LatLon{lat0_ + y / mPerDegLat_, lon0_ + x / mPerDegLon_};
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace geopro::core
|
} // namespace geopro::core
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,19 @@
|
||||||
namespace geopro::core {
|
namespace geopro::core {
|
||||||
|
|
||||||
struct LocalXY { double x, y; };
|
struct LocalXY { double x, y; };
|
||||||
|
struct LatLon { double lat, lon; };
|
||||||
|
|
||||||
// 等距圆柱(equirectangular)近似:把经纬度投到以(lat0,lon0)为原点的局部米平面。
|
// 等距圆柱(equirectangular)近似:把经纬度投到以(lat0,lon0)为原点的局部米平面。
|
||||||
// 小范围测区足够;x=East、y=North(米)。
|
// 小范围测区足够;x=East、y=North(米)。
|
||||||
class GeoLocalFrame {
|
class GeoLocalFrame {
|
||||||
public:
|
public:
|
||||||
GeoLocalFrame(double lat0, double lon0);
|
GeoLocalFrame(double lat0, double lon0);
|
||||||
|
// 就地改原点(不换对象):所有持有此共享 frame 的渲染层(帘面/底图/坐标轴)随即一致重定位。
|
||||||
|
void reanchor(double lat0, double lon0);
|
||||||
LocalXY toLocal(double lat, double lon) const; // -> (x East m, y North m)
|
LocalXY toLocal(double lat, double lon) const; // -> (x East m, y North m)
|
||||||
|
// toLocal 的反算:局部米 (x East, y North) -> 经纬度。
|
||||||
|
// lon = lon0 + x/mPerDegLon,lat = lat0 + y/mPerDegLat(坐标轴经纬度刻度用)。
|
||||||
|
LatLon toLatLon(double x, double y) const;
|
||||||
private:
|
private:
|
||||||
double lat0_, lon0_, mPerDegLon_, mPerDegLat_;
|
double lat0_, lon0_, mPerDegLon_, mPerDegLat_;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ add_library(geopro_data STATIC
|
||||||
dto/GridDto.cpp
|
dto/GridDto.cpp
|
||||||
api/ApiProjectRepository.cpp
|
api/ApiProjectRepository.cpp
|
||||||
api/ApiDatasetRepository.cpp
|
api/ApiDatasetRepository.cpp
|
||||||
|
api/Api3dRepository.cpp
|
||||||
api/DatasetLoadHandles.cpp
|
api/DatasetLoadHandles.cpp
|
||||||
api/NavRequest.cpp)
|
api/NavRequest.cpp)
|
||||||
target_include_directories(geopro_data PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
|
target_include_directories(geopro_data PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,120 @@
|
||||||
|
#include "api/Api3dRepository.hpp"
|
||||||
|
|
||||||
|
#include <QObject>
|
||||||
|
#include <QString>
|
||||||
|
#include <QVariant>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
#include "api/DatasetLoadHandles.hpp"
|
||||||
|
#include "model/detail/DetailPayloads.hpp"
|
||||||
|
#include "repo/IAsyncDatasetRepository.hpp"
|
||||||
|
|
||||||
|
namespace geopro::data {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
constexpr const char* kNotReady = "后端三维端点未就绪";
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
Api3dRepository::Api3dRepository(IAsyncDatasetRepository& dsRepo) : dsRepo_(dsRepo) {}
|
||||||
|
|
||||||
|
DsDimension Api3dRepository::dimensionOf(const DsRow& ds) const {
|
||||||
|
// 与 LocalSample3dRepository::dimensionOf 同口径(spec §6.1 ddCode→维度)。
|
||||||
|
// TODO(P3): 与 LocalSample3dRepository 重复,宜提取共享映射(后续清理)。
|
||||||
|
const std::string& c = ds.ddCode;
|
||||||
|
if (c == "dd_voxel" || c == "dd_Structual3D" || c == "dd_Property3D" || c == "dd_section" ||
|
||||||
|
c == "dd_inversion_data") {
|
||||||
|
return DsDimension::Dim3D;
|
||||||
|
}
|
||||||
|
if (c == "dd_slice") return DsDimension::Analysis3D;
|
||||||
|
if (c == "dd_trajectory_data") return DsDimension::Dim2D;
|
||||||
|
return DsDimension::Other;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Api3dRepository::loadSection(const std::string& dsId, std::function<void(SectionData)> onOk,
|
||||||
|
OnError onErr) {
|
||||||
|
// 真实帘面:复用 ApiDatasetRepository 的 ERT 反演网格端点(loaderKey="inversion.grid")。
|
||||||
|
// 命中载荷 = core::ContourPayload{grid, scale, anomalies};取 grid+scale 填 SectionData。
|
||||||
|
DetailLoad* load = dsRepo_.loadAsync("inversion.grid", dsId);
|
||||||
|
if (load == nullptr) {
|
||||||
|
onErr("Api3dRepository::loadSection: loadAsync 返回空句柄");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 以 load 为连接上下文 → 它 deleteLater 时自动断开;单线程下创建后立即连接安全。
|
||||||
|
QObject::connect(load, &DetailLoad::done, load,
|
||||||
|
[onOk = std::move(onOk)](const QVariant& payload) {
|
||||||
|
const auto cp = qvariant_cast<core::ContourPayload>(payload);
|
||||||
|
SectionData s;
|
||||||
|
s.grid = cp.grid;
|
||||||
|
s.scale = cp.scale;
|
||||||
|
onOk(std::move(s));
|
||||||
|
});
|
||||||
|
QObject::connect(load, &DetailLoad::failed, load,
|
||||||
|
[onErr = std::move(onErr)](const QString& message) {
|
||||||
|
onErr(message.toStdString());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void Api3dRepository::loadVolume(const std::string& /*dsId*/,
|
||||||
|
std::function<void(VolumeGrid)> /*onOk*/, OnError onErr) {
|
||||||
|
onErr(kNotReady); // 后端三维体端点未就绪
|
||||||
|
}
|
||||||
|
|
||||||
|
void Api3dRepository::loadTerrainPaths(std::function<void(TerrainPaths)> /*onOk*/, OnError onErr) {
|
||||||
|
onErr(kNotReady); // 后端地形 DEM/影像端点未就绪
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 切片 CRUD(后端未就绪 → 变更走 onErr,给用户明确"未实现")──────────────
|
||||||
|
|
||||||
|
void Api3dRepository::createSlice(const SliceSpec& /*spec*/, const std::string& /*name*/,
|
||||||
|
std::function<void(std::string)> /*onOk*/, OnError onErr) {
|
||||||
|
onErr(kNotReady);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Api3dRepository::saveSlice(const std::string& /*dsId*/, const SliceSpec& /*spec*/,
|
||||||
|
std::function<void()> /*onOk*/, OnError onErr) {
|
||||||
|
onErr(kNotReady);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Api3dRepository::deleteSlice(const std::string& /*dsId*/, std::function<void()> /*onOk*/,
|
||||||
|
OnError onErr) {
|
||||||
|
onErr(kNotReady);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 异常 / 异常体(load 回空树避免 UI 崩;变更走 onErr)─────────────────────
|
||||||
|
|
||||||
|
void Api3dRepository::loadAnomalyTree(const std::string& /*objectId*/,
|
||||||
|
std::function<void(AnomalyTree)> onOk, OnError /*onErr*/) {
|
||||||
|
onOk(AnomalyTree{}); // 后端未就绪 → 空树
|
||||||
|
}
|
||||||
|
|
||||||
|
void Api3dRepository::saveAnomaly(const geopro::core::Anomaly& /*a*/,
|
||||||
|
const std::string& /*screenshotPngPath*/,
|
||||||
|
std::function<void(std::string)> /*onOk*/, OnError onErr) {
|
||||||
|
onErr(kNotReady);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Api3dRepository::deleteAnomaly(const std::string& /*anomalyId*/,
|
||||||
|
std::function<void()> /*onOk*/, OnError onErr) {
|
||||||
|
onErr(kNotReady);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Api3dRepository::deleteAnomalyGroup(const std::string& /*bodyId*/,
|
||||||
|
std::function<void()> /*onOk*/, OnError onErr) {
|
||||||
|
onErr(kNotReady);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 任务管理(load 回空列表避免 UI 崩)──────────────────────────────────────
|
||||||
|
|
||||||
|
void Api3dRepository::loadTaskRecords(const std::string& /*dsId*/,
|
||||||
|
std::function<void(std::vector<TaskRecord>)> onOk,
|
||||||
|
OnError /*onErr*/) {
|
||||||
|
onOk({}); // 后端未就绪 → 空记录
|
||||||
|
}
|
||||||
|
|
||||||
|
void Api3dRepository::loadUsableTasks(const std::string& /*ddCode*/,
|
||||||
|
std::function<void(std::vector<UsableTask>)> onOk,
|
||||||
|
OnError /*onErr*/) {
|
||||||
|
onOk({}); // 后端未就绪 → 空列表
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace geopro::data
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
#pragma once
|
||||||
|
#include <functional>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "repo/I3dSceneRepository.hpp"
|
||||||
|
|
||||||
|
namespace geopro::data {
|
||||||
|
|
||||||
|
class IAsyncDatasetRepository;
|
||||||
|
|
||||||
|
// 真实后端实现 I3dSceneRepository:
|
||||||
|
// loadSection(帘面) 走真实 ERT 反演端点 —— 复用 ApiDatasetRepository(loaderKey="inversion.grid"),
|
||||||
|
// 不重复网络层;命中 core::ContourPayload{grid, scale, anomalies},取 grid+scale 填 SectionData。
|
||||||
|
// dimensionOf 同步纯函数(ddCode→维度,同 LocalSample3dRepository 映射)。
|
||||||
|
// 三维体/地形/切片/异常/任务端点后端尚未就绪 → 暂 stub:
|
||||||
|
// - load 类(loadTree/loadRecords/loadTasks) 回调空,避免 UI 崩;
|
||||||
|
// - loadVolume/loadTerrainPaths 及一切 create/save/delete 变更 → 走 onErr("后端未就绪"),
|
||||||
|
// 给用户明确"未实现"而非假成功。
|
||||||
|
class Api3dRepository : public I3dSceneRepository {
|
||||||
|
public:
|
||||||
|
explicit Api3dRepository(IAsyncDatasetRepository& dsRepo);
|
||||||
|
|
||||||
|
DsDimension dimensionOf(const DsRow& ds) const override;
|
||||||
|
|
||||||
|
void loadVolume(const std::string& dsId, std::function<void(VolumeGrid)> onOk,
|
||||||
|
OnError onErr) override;
|
||||||
|
void loadSection(const std::string& dsId, std::function<void(SectionData)> onOk,
|
||||||
|
OnError onErr) override;
|
||||||
|
void loadTerrainPaths(std::function<void(TerrainPaths)> onOk, OnError onErr) override;
|
||||||
|
|
||||||
|
// 切片 CRUD(后端未就绪 → 变更走 onErr)
|
||||||
|
void createSlice(const SliceSpec& spec, const std::string& name,
|
||||||
|
std::function<void(std::string)> onOk, OnError onErr) override;
|
||||||
|
void saveSlice(const std::string& dsId, const SliceSpec& spec,
|
||||||
|
std::function<void()> onOk, OnError onErr) override;
|
||||||
|
void deleteSlice(const std::string& dsId,
|
||||||
|
std::function<void()> onOk, OnError onErr) override;
|
||||||
|
|
||||||
|
// 异常 / 异常体(后端未就绪 → load 回空树,变更走 onErr)
|
||||||
|
void loadAnomalyTree(const std::string& objectId,
|
||||||
|
std::function<void(AnomalyTree)> onOk, OnError onErr) override;
|
||||||
|
void saveAnomaly(const geopro::core::Anomaly& a, const std::string& screenshotPngPath,
|
||||||
|
std::function<void(std::string)> onOk, OnError onErr) override;
|
||||||
|
void deleteAnomaly(const std::string& anomalyId,
|
||||||
|
std::function<void()> onOk, OnError onErr) override;
|
||||||
|
void deleteAnomalyGroup(const std::string& bodyId,
|
||||||
|
std::function<void()> onOk, OnError onErr) override;
|
||||||
|
|
||||||
|
// 任务管理(后端未就绪 → load 回空列表)
|
||||||
|
void loadTaskRecords(const std::string& dsId,
|
||||||
|
std::function<void(std::vector<TaskRecord>)> onOk,
|
||||||
|
OnError onErr) override;
|
||||||
|
void loadUsableTasks(const std::string& ddCode,
|
||||||
|
std::function<void(std::vector<UsableTask>)> onOk,
|
||||||
|
OnError onErr) override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
IAsyncDatasetRepository& dsRepo_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace geopro::data
|
||||||
|
|
@ -19,6 +19,10 @@ Grid parseInversionGrid(const QJsonObject& data) {
|
||||||
Grid g(nx < 1 ? 1 : nx, ny < 1 ? 1 : ny);
|
Grid g(nx < 1 ? 1 : nx, ny < 1 ? 1 : ny);
|
||||||
g.x.clear(); for (auto e : x) g.x.push_back(num(e));
|
g.x.clear(); for (auto e : x) g.x.push_back(num(e));
|
||||||
g.y.clear(); for (auto e : y) g.y.push_back(num(e));
|
g.y.clear(); for (auto e : y) g.y.push_back(num(e));
|
||||||
|
// 经纬度(每列 [nx]):供 CurtainActor 经 GeoLocalFrame 把每列摆到真实测线位置——
|
||||||
|
// 弯曲测线 → 曲面帘面(不解析则 lat/lon 空、退化成 y=0 平面)。API 字段 lat/lon。
|
||||||
|
g.lat.clear(); for (auto e : data.value("lat").toArray()) g.lat.push_back(num(e));
|
||||||
|
g.lon.clear(); for (auto e : data.value("lon").toArray()) g.lon.push_back(num(e));
|
||||||
if (v.size() != ny) // 服务端 v 行数与 y 不符:下方越界处填 NaN,记录便于排查(非静默)。
|
if (v.size() != ny) // 服务端 v 行数与 y 不符:下方越界处填 NaN,记录便于排查(非静默)。
|
||||||
qWarning("parseInversionGrid: v rows=%d != ny=%d (缺失行将填 NaN)", v.size(), ny);
|
qWarning("parseInversionGrid: v rows=%d != ny=%d (缺失行将填 NaN)", v.size(), ny);
|
||||||
for (int j = 0; j < ny; ++j) {
|
for (int j = 0; j < ny; ++j) {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
#pragma once
|
#pragma once
|
||||||
#include <array>
|
#include <array>
|
||||||
#include <functional>
|
#include <functional>
|
||||||
|
#include <map>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "model/Anomaly.hpp"
|
||||||
|
#include "model/ColorScale.hpp"
|
||||||
#include "model/Field.hpp"
|
#include "model/Field.hpp"
|
||||||
#include "repo/RepoTypes.hpp"
|
#include "repo/RepoTypes.hpp"
|
||||||
|
|
||||||
|
|
@ -25,13 +29,19 @@ struct TerrainPaths {
|
||||||
std::string demPath, imagePath;
|
std::string demPath, imagePath;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 切面/剖面着色数据:帘面渲染输入(Grid + 色阶)。spec §6.2 帘面入 3D。
|
||||||
|
struct SectionData {
|
||||||
|
geopro::core::Grid grid{0, 0}; // Grid 无默认构造,给 0×0 占位(加载后填)
|
||||||
|
geopro::core::ColorScale scale;
|
||||||
|
};
|
||||||
|
|
||||||
// 三维场景仓储抽象(异步,spec §6 评审 HIGH)。
|
// 三维场景仓储抽象(异步,spec §6 评审 HIGH)。
|
||||||
// 取数方法走回调 std::function(LocalSample 本地数据同步算好后直接回调;
|
// 取数方法走回调 std::function(LocalSample 本地数据同步算好后直接回调;
|
||||||
// 将来 Api3dRepository 在网络完成时回调,上层不变)。
|
// 将来 Api3dRepository 在网络完成时回调,上层不变)。
|
||||||
// **契约:onOk/onErr 必须在主(GUI)线程调用**——上层(VtkSceneController)回调内直接操作
|
// **契约:onOk/onErr 必须在主(GUI)线程调用**——上层(VtkSceneController)回调内直接操作
|
||||||
// 场景/发 Qt 信号,依赖主线程亲和;Api 实现若在工作线程完成须 post 回主线程再回调。
|
// 场景/发 Qt 信号,依赖主线程亲和;Api 实现若在工作线程完成须 post 回主线程再回调。
|
||||||
// dimensionOf 是同步纯函数(无 I/O,只做类型→维度映射)。
|
// dimensionOf 是同步纯函数(无 I/O,只做类型→维度映射)。
|
||||||
// 切片/异常/任务等签名本期不在接口内(留 P3/P4)。
|
// 切片/异常/任务接口已按 spec §6.3–6.5 完整设计(见下方),LocalSample 内存态 stub,将来 Api3dRepository 换实现。
|
||||||
class I3dSceneRepository {
|
class I3dSceneRepository {
|
||||||
public:
|
public:
|
||||||
using OnError = std::function<void(const std::string& message)>;
|
using OnError = std::function<void(const std::string& message)>;
|
||||||
|
|
@ -45,8 +55,87 @@ public:
|
||||||
virtual void loadVolume(const std::string& dsId,
|
virtual void loadVolume(const std::string& dsId,
|
||||||
std::function<void(VolumeGrid)> onOk, OnError onErr) = 0;
|
std::function<void(VolumeGrid)> onOk, OnError onErr) = 0;
|
||||||
|
|
||||||
|
// 异步:加载剖面(帘面)着色数据(Grid+色阶)。本地样本同步回调;Api 实现走 ERT 反演端点异步回调。
|
||||||
|
virtual void loadSection(const std::string& dsId,
|
||||||
|
std::function<void(SectionData)> onOk, OnError onErr) = 0;
|
||||||
|
|
||||||
// 异步:加载地形 DEM/影像路径。
|
// 异步:加载地形 DEM/影像路径。
|
||||||
virtual void loadTerrainPaths(std::function<void(TerrainPaths)> onOk, OnError onErr) = 0;
|
virtual void loadTerrainPaths(std::function<void(TerrainPaths)> onOk, OnError onErr) = 0;
|
||||||
|
|
||||||
|
// ── 切片数据集 CRUD(spec §6.3)──────────────────────────────────────────
|
||||||
|
// 切面位姿(原点 + 法向,用 std::array 去裸 double[])。
|
||||||
|
struct SliceSpec {
|
||||||
|
std::string volumeDsId; // 所属三维体 dsId
|
||||||
|
std::array<double, 3> origin{{0, 0, 0}}; // 切面上一点(世界米)
|
||||||
|
std::array<double, 3> normal{{0, 0, 1}}; // 切面法向(单位向量)
|
||||||
|
std::string colorScaleId;
|
||||||
|
};
|
||||||
|
// 切片数据集(持久化态):dsId/名字 + 位姿 + 采样网格。
|
||||||
|
struct SliceDataset {
|
||||||
|
std::string dsId, name;
|
||||||
|
SliceSpec spec;
|
||||||
|
geopro::core::Grid section{0, 0}; // 切面着色网格(保存后才填)
|
||||||
|
};
|
||||||
|
|
||||||
|
// 保存为新切片数据集 → onOk(newDsId)。
|
||||||
|
virtual void createSlice(const SliceSpec& spec, const std::string& name,
|
||||||
|
std::function<void(std::string /*newDsId*/)> onOk,
|
||||||
|
OnError onErr) = 0;
|
||||||
|
// 覆盖已有切片位姿。
|
||||||
|
virtual void saveSlice(const std::string& dsId, const SliceSpec& spec,
|
||||||
|
std::function<void()> onOk, OnError onErr) = 0;
|
||||||
|
// 删除切片数据集。
|
||||||
|
virtual void deleteSlice(const std::string& dsId,
|
||||||
|
std::function<void()> onOk, OnError onErr) = 0;
|
||||||
|
|
||||||
|
// ── 异常 / 异常体(spec §6.4)────────────────────────────────────────────
|
||||||
|
// 异常体(树中间层):含该体下的多个 Anomaly。
|
||||||
|
struct AnomalyBody {
|
||||||
|
std::string id, name, typeName;
|
||||||
|
std::vector<geopro::core::Anomaly> members;
|
||||||
|
};
|
||||||
|
// 异常树:对象 → 异常体分组 → 异常,以及未分组异常。
|
||||||
|
struct AnomalyTree {
|
||||||
|
std::vector<AnomalyBody> bodies;
|
||||||
|
std::vector<geopro::core::Anomaly> loose; // 未分组异常
|
||||||
|
};
|
||||||
|
|
||||||
|
// 加载对象的完整异常树。
|
||||||
|
virtual void loadAnomalyTree(const std::string& objectId,
|
||||||
|
std::function<void(AnomalyTree)> onOk, OnError onErr) = 0;
|
||||||
|
// 保存/新建异常(含截图属性)→ onOk(anomalyId)。
|
||||||
|
virtual void saveAnomaly(const geopro::core::Anomaly& a,
|
||||||
|
const std::string& screenshotPngPath,
|
||||||
|
std::function<void(std::string /*anomalyId*/)> onOk,
|
||||||
|
OnError onErr) = 0;
|
||||||
|
// 删除单个异常。
|
||||||
|
virtual void deleteAnomaly(const std::string& anomalyId,
|
||||||
|
std::function<void()> onOk, OnError onErr) = 0;
|
||||||
|
// 删除异常体分组(及其下异常)。
|
||||||
|
virtual void deleteAnomalyGroup(const std::string& bodyId,
|
||||||
|
std::function<void()> onOk, OnError onErr) = 0;
|
||||||
|
|
||||||
|
// ── 任务管理(spec §6.5)─────────────────────────────────────────────────
|
||||||
|
// 任务调用记录(当前数据集历史)。
|
||||||
|
struct TaskRecord {
|
||||||
|
std::string id, taskName, status, createTime;
|
||||||
|
};
|
||||||
|
// 可使用任务(与 ds 类型 ddCode 相符的任务插件;复用 model/list 语义)。
|
||||||
|
struct UsableTask {
|
||||||
|
std::string scriptId, scriptCode, name;
|
||||||
|
int opType = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 加载当前数据集的任务调用记录。
|
||||||
|
virtual void loadTaskRecords(const std::string& dsId,
|
||||||
|
std::function<void(std::vector<TaskRecord>)> onOk,
|
||||||
|
OnError onErr) = 0;
|
||||||
|
// 加载与 ddCode 匹配的可用任务插件列表。
|
||||||
|
virtual void loadUsableTasks(const std::string& ddCode,
|
||||||
|
std::function<void(std::vector<UsableTask>)> onOk,
|
||||||
|
OnError onErr) = 0;
|
||||||
};
|
};
|
||||||
|
// 注:以上切片/异常/任务接口已按 spec §6.3–6.5 完整设计;
|
||||||
|
// LocalSample3dRepository 提供内存态 stub;真实后端 = 将来 Api3dRepository(上层不变)。
|
||||||
|
|
||||||
} // namespace geopro::data
|
} // namespace geopro::data
|
||||||
|
|
|
||||||
|
|
@ -146,6 +146,19 @@ void LocalSample3dRepository::loadVolume(const std::string& /*dsId*/,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void LocalSample3dRepository::loadSection(const std::string& /*dsId*/,
|
||||||
|
std::function<void(SectionData)> onOk, OnError onErr) {
|
||||||
|
// P1 样本:忽略入参 dsId(本地仅一份样本 grid1);真实 Api 实现走 ERT 反演端点按 dsId 取。
|
||||||
|
try {
|
||||||
|
SectionData s;
|
||||||
|
s.grid = base_.loadGrid("grid1"); // 样本 dd_section 网格
|
||||||
|
s.scale = base_.loadColorScale("grid1"); // 对应色阶
|
||||||
|
onOk(std::move(s)); // 本地同步回调
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
onErr(std::string("LocalSample3dRepository::loadSection: ") + e.what());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void LocalSample3dRepository::loadTerrainPaths(std::function<void(TerrainPaths)> onOk,
|
void LocalSample3dRepository::loadTerrainPaths(std::function<void(TerrainPaths)> onOk,
|
||||||
OnError onErr) {
|
OnError onErr) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -156,4 +169,69 @@ void LocalSample3dRepository::loadTerrainPaths(std::function<void(TerrainPaths)>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 切片 CRUD(spec §6.3 内存态 stub)───────────────────────────────────────
|
||||||
|
|
||||||
|
void LocalSample3dRepository::createSlice(const SliceSpec& spec, const std::string& /*name*/,
|
||||||
|
std::function<void(std::string)> onOk,
|
||||||
|
OnError /*onErr*/) {
|
||||||
|
std::string newId = "slice-" + std::to_string(++sliceCounter_);
|
||||||
|
slices_[newId] = spec;
|
||||||
|
onOk(std::move(newId));
|
||||||
|
}
|
||||||
|
|
||||||
|
void LocalSample3dRepository::saveSlice(const std::string& dsId, const SliceSpec& spec,
|
||||||
|
std::function<void()> onOk, OnError /*onErr*/) {
|
||||||
|
slices_[dsId] = spec;
|
||||||
|
onOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
void LocalSample3dRepository::deleteSlice(const std::string& dsId,
|
||||||
|
std::function<void()> onOk, OnError /*onErr*/) {
|
||||||
|
slices_.erase(dsId);
|
||||||
|
onOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 异常 / 异常体(spec §6.4 内存态 stub)──────────────────────────────────
|
||||||
|
|
||||||
|
void LocalSample3dRepository::loadAnomalyTree(const std::string& /*objectId*/,
|
||||||
|
std::function<void(AnomalyTree)> onOk,
|
||||||
|
OnError /*onErr*/) {
|
||||||
|
onOk(AnomalyTree{}); // stub: 空树(无样本异常)
|
||||||
|
}
|
||||||
|
|
||||||
|
void LocalSample3dRepository::saveAnomaly(const geopro::core::Anomaly& a,
|
||||||
|
const std::string& /*screenshotPngPath*/,
|
||||||
|
std::function<void(std::string)> onOk,
|
||||||
|
OnError /*onErr*/) {
|
||||||
|
std::string newId = "anomaly-" + std::to_string(++anomalyCounter_);
|
||||||
|
anomalies_[newId] = a;
|
||||||
|
onOk(std::move(newId));
|
||||||
|
}
|
||||||
|
|
||||||
|
void LocalSample3dRepository::deleteAnomaly(const std::string& anomalyId,
|
||||||
|
std::function<void()> onOk, OnError /*onErr*/) {
|
||||||
|
anomalies_.erase(anomalyId);
|
||||||
|
onOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
void LocalSample3dRepository::deleteAnomalyGroup(const std::string& /*bodyId*/,
|
||||||
|
std::function<void()> onOk, OnError /*onErr*/) {
|
||||||
|
// stub: 内存态无 AnomalyBody 存储(bodyId 仅逻辑分组,真实 backend 实现时才有)
|
||||||
|
onOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 任务管理(spec §6.5 内存态 stub)────────────────────────────────────────
|
||||||
|
|
||||||
|
void LocalSample3dRepository::loadTaskRecords(const std::string& /*dsId*/,
|
||||||
|
std::function<void(std::vector<TaskRecord>)> onOk,
|
||||||
|
OnError /*onErr*/) {
|
||||||
|
onOk({}); // stub: 无样本任务记录
|
||||||
|
}
|
||||||
|
|
||||||
|
void LocalSample3dRepository::loadUsableTasks(const std::string& /*ddCode*/,
|
||||||
|
std::function<void(std::vector<UsableTask>)> onOk,
|
||||||
|
OnError /*onErr*/) {
|
||||||
|
onOk({}); // stub: 空列表(真实实现按 ddCode 过滤 model/list 返回)
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace geopro::data
|
} // namespace geopro::data
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
#pragma once
|
#pragma once
|
||||||
#include <functional>
|
#include <functional>
|
||||||
|
#include <map>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
#include "repo/I3dSceneRepository.hpp"
|
#include "repo/I3dSceneRepository.hpp"
|
||||||
|
|
||||||
|
|
@ -23,12 +25,47 @@ public:
|
||||||
DsDimension dimensionOf(const DsRow& ds) const override;
|
DsDimension dimensionOf(const DsRow& ds) const override;
|
||||||
void loadVolume(const std::string& dsId, std::function<void(VolumeGrid)> onOk,
|
void loadVolume(const std::string& dsId, std::function<void(VolumeGrid)> onOk,
|
||||||
OnError onErr) override;
|
OnError onErr) override;
|
||||||
|
void loadSection(const std::string& dsId, std::function<void(SectionData)> onOk,
|
||||||
|
OnError onErr) override;
|
||||||
void loadTerrainPaths(std::function<void(TerrainPaths)> onOk, OnError onErr) override;
|
void loadTerrainPaths(std::function<void(TerrainPaths)> onOk, OnError onErr) override;
|
||||||
|
|
||||||
|
// 切片 CRUD(spec §6.3 内存态 stub)
|
||||||
|
void createSlice(const SliceSpec& spec, const std::string& name,
|
||||||
|
std::function<void(std::string)> onOk, OnError onErr) override;
|
||||||
|
void saveSlice(const std::string& dsId, const SliceSpec& spec,
|
||||||
|
std::function<void()> onOk, OnError onErr) override;
|
||||||
|
void deleteSlice(const std::string& dsId,
|
||||||
|
std::function<void()> onOk, OnError onErr) override;
|
||||||
|
|
||||||
|
// 异常 / 异常体(spec §6.4 内存态 stub)
|
||||||
|
void loadAnomalyTree(const std::string& objectId,
|
||||||
|
std::function<void(AnomalyTree)> onOk, OnError onErr) override;
|
||||||
|
void saveAnomaly(const geopro::core::Anomaly& a, const std::string& screenshotPngPath,
|
||||||
|
std::function<void(std::string)> onOk, OnError onErr) override;
|
||||||
|
void deleteAnomaly(const std::string& anomalyId,
|
||||||
|
std::function<void()> onOk, OnError onErr) override;
|
||||||
|
void deleteAnomalyGroup(const std::string& bodyId,
|
||||||
|
std::function<void()> onOk, OnError onErr) override;
|
||||||
|
|
||||||
|
// 任务管理(spec §6.5 内存态 stub)
|
||||||
|
void loadTaskRecords(const std::string& dsId,
|
||||||
|
std::function<void(std::vector<TaskRecord>)> onOk,
|
||||||
|
OnError onErr) override;
|
||||||
|
void loadUsableTasks(const std::string& ddCode,
|
||||||
|
std::function<void(std::vector<UsableTask>)> onOk,
|
||||||
|
OnError onErr) override;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
LocalSampleRepository& base_;
|
LocalSampleRepository& base_;
|
||||||
std::string projectCrs_;
|
std::string projectCrs_;
|
||||||
double baseLat_, baseLon_;
|
double baseLat_, baseLon_;
|
||||||
|
|
||||||
|
// 内存态存储(stub,无持久化;重启清空)
|
||||||
|
int sliceCounter_ = 0;
|
||||||
|
std::map<std::string, SliceSpec> slices_; // dsId → spec
|
||||||
|
|
||||||
|
int anomalyCounter_ = 0;
|
||||||
|
std::map<std::string, geopro::core::Anomaly> anomalies_; // anomalyId → Anomaly
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace geopro::data
|
} // namespace geopro::data
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
find_package(VTK REQUIRED COMPONENTS CommonCore CommonDataModel FiltersGeometry FiltersModeling RenderingCore RenderingOpenGL2 RenderingVolumeOpenGL2 InteractionStyle InteractionWidgets IOImage)
|
find_package(VTK REQUIRED COMPONENTS CommonCore CommonDataModel FiltersGeometry FiltersModeling RenderingCore RenderingOpenGL2 RenderingVolumeOpenGL2 RenderingAnnotation InteractionStyle InteractionWidgets ImagingCore IOImage)
|
||||||
find_package(GDAL CONFIG REQUIRED)
|
find_package(GDAL CONFIG REQUIRED)
|
||||||
add_library(geopro_render STATIC
|
add_library(geopro_render STATIC
|
||||||
Scene.cpp ColorLutBuilder.cpp CameraPreset.cpp VoxelFromScatters.cpp ContourBands.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)
|
Scene.cpp ColorLutBuilder.cpp CameraPreset.cpp VoxelFromScatters.cpp ContourBands.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 actors/AxesActor.cpp
|
||||||
|
interact/SlicePlaneMath.cpp interact/SliceTool.cpp interact/PickInteractorStyle.cpp interact/InteractionManager.cpp
|
||||||
|
ground/TileMath.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} GDAL::GDAL)
|
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)
|
||||||
|
|
|
||||||
|
|
@ -37,4 +37,54 @@ void applyFree3D(vtkRenderer* r)
|
||||||
r->ResetCamera();
|
r->ResetCamera();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void applyView(vtkRenderer* r, ViewDir dir)
|
||||||
|
{
|
||||||
|
if (!r) return;
|
||||||
|
auto* c = r->GetActiveCamera();
|
||||||
|
// 6 向均为正交快捷视图。焦点先置原点,ResetCamera 再按场景重定位相机距离;
|
||||||
|
// 方向由 (position-focalPoint) 与 viewUp 决定(世界系 x=East,y=North,z=-depth)。
|
||||||
|
c->SetFocalPoint(0, 0, 0);
|
||||||
|
switch (dir) {
|
||||||
|
case ViewDir::Top: // 俯视:相机在 +Z 向下看,北(+Y)朝上
|
||||||
|
c->SetPosition(0, 0, 1);
|
||||||
|
c->SetViewUp(0, 1, 0);
|
||||||
|
break;
|
||||||
|
case ViewDir::Bottom: // 仰视:相机在 -Z 向上看
|
||||||
|
c->SetPosition(0, 0, -1);
|
||||||
|
c->SetViewUp(0, 1, 0);
|
||||||
|
break;
|
||||||
|
case ViewDir::Front: // 北望:相机在 -Y 看向 +Y,上(+Z)朝上
|
||||||
|
c->SetPosition(0, -1, 0);
|
||||||
|
c->SetViewUp(0, 0, 1);
|
||||||
|
break;
|
||||||
|
case ViewDir::Back: // 南望:相机在 +Y 看向 -Y
|
||||||
|
c->SetPosition(0, 1, 0);
|
||||||
|
c->SetViewUp(0, 0, 1);
|
||||||
|
break;
|
||||||
|
case ViewDir::Left: // 东望:相机在 -X 看向 +X
|
||||||
|
c->SetPosition(-1, 0, 0);
|
||||||
|
c->SetViewUp(0, 0, 1);
|
||||||
|
break;
|
||||||
|
case ViewDir::Right: // 西望:相机在 +X 看向 -X
|
||||||
|
c->SetPosition(1, 0, 0);
|
||||||
|
c->SetViewUp(0, 0, 1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
c->OrthogonalizeViewUp();
|
||||||
|
r->ResetCamera();
|
||||||
|
}
|
||||||
|
|
||||||
|
void zoomBy(vtkRenderer* r, double factor)
|
||||||
|
{
|
||||||
|
if (!r || factor <= 0.0) return;
|
||||||
|
// vtkCamera::Zoom 同时覆盖透视(改视角)与正交(改 parallelScale):factor>1 放大。
|
||||||
|
r->GetActiveCamera()->Zoom(factor);
|
||||||
|
}
|
||||||
|
|
||||||
|
void fitView(vtkRenderer* r)
|
||||||
|
{
|
||||||
|
if (!r) return;
|
||||||
|
r->ResetCamera();
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace geopro::render
|
} // namespace geopro::render
|
||||||
|
|
|
||||||
|
|
@ -8,4 +8,20 @@ void applyTop2D(vtkRenderer* r);
|
||||||
// 自由三维:透视投影,斜视方位看到剖面立体。
|
// 自由三维:透视投影,斜视方位看到剖面立体。
|
||||||
void applyFree3D(vtkRenderer* r);
|
void applyFree3D(vtkRenderer* r);
|
||||||
|
|
||||||
|
// 快捷视图方向(世界系 x=East,y=North,z=-depth)。
|
||||||
|
// Top 俯视 (相机在 +Z 向下看)
|
||||||
|
// Bottom 仰视 (相机在 -Z 向上看)
|
||||||
|
// Front 从 -Y 看向 +Y (北望),Back 反向
|
||||||
|
// Left 从 -X 看向 +X (东望),Right 反向
|
||||||
|
enum class ViewDir { Front, Back, Left, Right, Top, Bottom };
|
||||||
|
|
||||||
|
// 应用 6 向正交快捷视图:设 position/focalPoint/viewUp 后 ResetCamera。
|
||||||
|
void applyView(vtkRenderer* r, ViewDir dir);
|
||||||
|
|
||||||
|
// 相机缩放:factor>1 拉近(放大),factor<1 推远(缩小)。透视下改距离、正交下改 parallelScale。
|
||||||
|
void zoomBy(vtkRenderer* r, double factor);
|
||||||
|
|
||||||
|
// 适配场景:ResetCamera(全览)。
|
||||||
|
void fitView(vtkRenderer* r);
|
||||||
|
|
||||||
} // namespace geopro::render
|
} // namespace geopro::render
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
- `actors/` — ScatterActor, GridContourActor, VoxelVolumeActor, AnomalyActor, TerrainActor
|
- `actors/` — ScatterActor, GridContourActor, VoxelVolumeActor, AnomalyActor, TerrainActor
|
||||||
- `color/` — ColorLutBuilder(colorBar → 离散 vtkLookupTable), ScalarBar
|
- `color/` — ColorLutBuilder(colorBar → 离散 vtkLookupTable), ScalarBar
|
||||||
- `camera/` — CameraPreset(Top2D / Free3D)
|
- `camera/` — CameraPreset(Top2D / Free3D)
|
||||||
- `interact/` — InteractionManager + InteractionTool(Measure/Slice/PickSelect);切片用 vtkResliceCursorWidget
|
- `interact/` — SlicePlaneMath(纯几何,可测)+ SliceTool(vtkImagePlaneWidget:轴向 + 任意 45° reslice 着色剖面)+ PickInteractorStyle(拾取/双击正视/滚轮)+ InteractionManager(持切片/选中态/分发)。切片走 vtkImageReslice 路线(vtkImagePlaneWidget 内部 reslice + 纹理),非 vtkCutter(spec §9.1)
|
||||||
- `ground/` — IGroundLayer + DemImageGroundLayer(M1);TileGroundLayer(M1.5)
|
- `ground/` — IGroundLayer + DemImageGroundLayer(M1);TileGroundLayer(M1.5)
|
||||||
|
|
||||||
网格管线:`vtkImageData(+vtkWarpScalar) → vtkDataSetSurfaceFilter → vtkBandedPolyDataContourFilter(GenerateContourEdgesOn)`(设计 §4.3)。
|
网格管线:`vtkImageData(+vtkWarpScalar) → vtkDataSetSurfaceFilter → vtkBandedPolyDataContourFilter(GenerateContourEdgesOn)`(设计 §4.3)。
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
#include "actors/AxesActor.hpp"
|
||||||
|
|
||||||
|
#include <vtkCubeAxesActor.h>
|
||||||
|
#include <vtkRenderer.h>
|
||||||
|
#include <vtkTextProperty.h>
|
||||||
|
|
||||||
|
namespace geopro::render {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
constexpr double kFeetPerMeter = 3.28084;
|
||||||
|
|
||||||
|
// 包围盒退化判定:任一轴 min>max,或六值全 0(无内容)。
|
||||||
|
bool boundsDegenerate(const double b[6]) {
|
||||||
|
if (b[0] > b[1] || b[2] > b[3] || b[4] > b[5]) return true;
|
||||||
|
for (int i = 0; i < 6; ++i)
|
||||||
|
if (b[i] != 0.0) return false;
|
||||||
|
return true; // 全 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设三轴标题字号/标签字号(待 1.0 字体确认,先统一 fontSize)。
|
||||||
|
void applyFont(vtkCubeAxesActor* ax, int fontSize) {
|
||||||
|
for (int i = 0; i < 3; ++i) {
|
||||||
|
if (auto* t = ax->GetTitleTextProperty(i)) t->SetFontSize(fontSize);
|
||||||
|
if (auto* l = ax->GetLabelTextProperty(i)) l->SetFontSize(fontSize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
double unitScaleFactor(AxesUnit unit) {
|
||||||
|
switch (unit) {
|
||||||
|
case AxesUnit::Meter: return 1.0;
|
||||||
|
case AxesUnit::Feet: return kFeetPerMeter;
|
||||||
|
case AxesUnit::None:
|
||||||
|
case AxesUnit::LatLon: return 1.0; // None 隐藏标签;LatLon 非线性,单独处理
|
||||||
|
}
|
||||||
|
return 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
vtkSmartPointer<vtkCubeAxesActor> buildAxes(const double bounds[6], const AxesOptions& opts,
|
||||||
|
vtkRenderer* renderer) {
|
||||||
|
if (opts.mode == AxesMode::None) return nullptr;
|
||||||
|
if (!bounds || boundsDegenerate(bounds)) return nullptr;
|
||||||
|
|
||||||
|
auto ax = vtkSmartPointer<vtkCubeAxesActor>::New();
|
||||||
|
double b[6];
|
||||||
|
for (int i = 0; i < 6; ++i) b[i] = bounds[i];
|
||||||
|
ax->SetBounds(b);
|
||||||
|
if (renderer) ax->SetCamera(renderer->GetActiveCamera());
|
||||||
|
|
||||||
|
// 显示模式:标准=外侧最近边;三维立体=静态边(四周更完整闭合,近似立方)+ 网格线。
|
||||||
|
if (opts.mode == AxesMode::Stereo) {
|
||||||
|
ax->SetFlyModeToStaticEdges();
|
||||||
|
ax->DrawXGridlinesOn();
|
||||||
|
ax->DrawYGridlinesOn();
|
||||||
|
ax->DrawZGridlinesOn();
|
||||||
|
} else { // Standard
|
||||||
|
ax->SetFlyModeToOuterEdges();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 刻度标签:None 隐藏;其余按单位换算「显示值范围」(几何 bounds 不变,仅标签数值变)。
|
||||||
|
if (opts.unit == AxesUnit::None) {
|
||||||
|
ax->SetXAxisLabelVisibility(false);
|
||||||
|
ax->SetYAxisLabelVisibility(false);
|
||||||
|
ax->SetZAxisLabelVisibility(false);
|
||||||
|
} else if (opts.unit == AxesUnit::LatLon && opts.frame) {
|
||||||
|
// 经纬度:X→经度、Y→纬度(用 frame 反算 bounds 端点);Z 退化为米深度。
|
||||||
|
// bounds 布局 {xmin,xmax,ymin,ymax,zmin,zmax}:(b[0],b[2])=西南角、(b[1],b[3])=东北角。
|
||||||
|
// 等距圆柱投影单调 → 角点经纬度即为各轴显示范围端点。
|
||||||
|
auto ll0 = opts.frame->toLatLon(b[0], b[2]);
|
||||||
|
auto ll1 = opts.frame->toLatLon(b[1], b[3]);
|
||||||
|
ax->SetXAxisRange(ll0.lon, ll1.lon);
|
||||||
|
ax->SetYAxisRange(ll0.lat, ll1.lat);
|
||||||
|
ax->SetZAxisRange(b[4], b[5]);
|
||||||
|
ax->SetXTitle("Lon");
|
||||||
|
ax->SetYTitle("Lat");
|
||||||
|
ax->SetZTitle("Depth(m)");
|
||||||
|
ax->SetXLabelFormat("%.5f");
|
||||||
|
ax->SetYLabelFormat("%.5f");
|
||||||
|
} else {
|
||||||
|
// 米 / 英尺:显示范围 = 几何范围 × 系数。
|
||||||
|
const double s = unitScaleFactor(opts.unit);
|
||||||
|
ax->SetXAxisRange(b[0] * s, b[1] * s);
|
||||||
|
ax->SetYAxisRange(b[2] * s, b[3] * s);
|
||||||
|
ax->SetZAxisRange(b[4] * s, b[5] * s);
|
||||||
|
const char* u = (opts.unit == AxesUnit::Feet) ? "ft" : "m";
|
||||||
|
ax->SetXTitle("X");
|
||||||
|
ax->SetYTitle("Y");
|
||||||
|
ax->SetZTitle("Z");
|
||||||
|
ax->SetXUnits(u);
|
||||||
|
ax->SetYUnits(u);
|
||||||
|
ax->SetZUnits(u);
|
||||||
|
}
|
||||||
|
|
||||||
|
applyFont(ax, opts.fontSize);
|
||||||
|
return ax;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace geopro::render
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
#pragma once
|
||||||
|
#include <vtkSmartPointer.h>
|
||||||
|
|
||||||
|
#include "geo/GeoLocalFrame.hpp"
|
||||||
|
|
||||||
|
class vtkCubeAxesActor;
|
||||||
|
class vtkRenderer;
|
||||||
|
|
||||||
|
namespace geopro::render {
|
||||||
|
|
||||||
|
// 坐标轴显示方式(spec §4 C3–I3)。
|
||||||
|
// Standard 标准 = vtkCubeAxesActor 包围盒 + 刻度(外侧最近轴显示刻度)。
|
||||||
|
// Stereo 三维立体 = vtkCubeAxesActor 闭合立方(四周/网格更完整)。语义待 1.0 确认,先合理近似。
|
||||||
|
// None 不显示 = 不构建(返回 nullptr)。
|
||||||
|
enum class AxesMode { Standard, Stereo, None };
|
||||||
|
|
||||||
|
// 刻度单位(spec §4 D5–I5)。
|
||||||
|
// None 无刻度 = 隐藏刻度标签。
|
||||||
|
// Meter 米 = 原值(世界系本就是米)。
|
||||||
|
// Feet 英尺 = ×3.28084。
|
||||||
|
// LatLon 经纬度 = 经 GeoLocalFrame 反算 X→经度、Y→纬度(Z 退化为米深度)。
|
||||||
|
enum class AxesUnit { None, Meter, Feet, LatLon };
|
||||||
|
|
||||||
|
// 坐标轴构建参数。
|
||||||
|
struct AxesOptions {
|
||||||
|
AxesMode mode = AxesMode::Standard;
|
||||||
|
AxesUnit unit = AxesUnit::Meter;
|
||||||
|
int fontSize = 12; // 标题/标签字号
|
||||||
|
// 经纬度刻度需 frame 反算;为空则 LatLon 退化为米。
|
||||||
|
const geopro::core::GeoLocalFrame* frame = nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 由数据包围盒 bounds[6]={xmin,xmax,ymin,ymax,zmin,zmax} + 选项构建坐标轴 prop。
|
||||||
|
// O 点 = 数据包围盒角(待 1.0 确认;spec §13 倾向"数据包围盒角")。
|
||||||
|
// bounds 退化(min>max 或全 0)或 mode==None → 返回 nullptr。
|
||||||
|
// camera:vtkCubeAxesActor 需绑定相机(决定外侧刻度轴);可空(测试场景)。
|
||||||
|
vtkSmartPointer<vtkCubeAxesActor> buildAxes(const double bounds[6], const AxesOptions& opts,
|
||||||
|
vtkRenderer* renderer);
|
||||||
|
|
||||||
|
// 单位换算系数(米→目标单位)。LatLon 不是线性系数(X/Y 分别反算),此处仅供米/英尺;
|
||||||
|
// 暴露为可测纯函数。
|
||||||
|
double unitScaleFactor(AxesUnit unit);
|
||||||
|
|
||||||
|
} // namespace geopro::render
|
||||||
|
|
@ -9,6 +9,8 @@
|
||||||
#include <vtkPolyDataMapper.h>
|
#include <vtkPolyDataMapper.h>
|
||||||
#include <vtkStructuredGrid.h>
|
#include <vtkStructuredGrid.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
|
|
@ -47,6 +49,10 @@ vtkSmartPointer<vtkActor> buildCurtain(const geopro::core::Grid& g,
|
||||||
sc->SetName("v");
|
sc->SetName("v");
|
||||||
sc->SetNumberOfTuples(static_cast<vtkIdType>(nx) * ny);
|
sc->SetNumberOfTuples(static_cast<vtkIdType>(nx) * ny);
|
||||||
|
|
||||||
|
// 无数据格(v 为 null → Grid 存 NaN,反演不规则区大量如此)记录待消隐:NaN 标量喂给
|
||||||
|
// vtkBandedPolyDataContourFilter 的裁剪运算会崩(0xc0000005)。消隐后这些格不入表面/色带,
|
||||||
|
// 既不崩、又把空洞正确显示为透明。标量同时填 0(有限值)以防任何读取路径再触 NaN。
|
||||||
|
std::vector<vtkIdType> blanks;
|
||||||
for (int j = 0; j < ny; ++j) {
|
for (int j = 0; j < ny; ++j) {
|
||||||
for (int i = 0; i < nx; ++i) {
|
for (int i = 0; i < nx; ++i) {
|
||||||
double px, py;
|
double px, py;
|
||||||
|
|
@ -60,17 +66,38 @@ vtkSmartPointer<vtkActor> buildCurtain(const geopro::core::Grid& g,
|
||||||
py = 0.0;
|
py = 0.0;
|
||||||
}
|
}
|
||||||
const vtkIdType id = static_cast<vtkIdType>(j) * nx + i;
|
const vtkIdType id = static_cast<vtkIdType>(j) * nx + i;
|
||||||
// g.y 是深度(越大越深);VTK Z 向上 → 取负,使深部在下、浅部在上(剖面不倒置)。
|
// g.y 是真实高程(米,越大越高,与原版 web data.y 同义):直接作世界 Z,使剖面落在
|
||||||
points->SetPoint(id, px, py, -g.y[j]);
|
// 真实海拔上,与同样按真实高程渲染的地形对齐(剖面顶≈地表→露出地面,复刻原版)。
|
||||||
sc->SetValue(id, g.valueAt(i, j));
|
points->SetPoint(id, px, py, g.y[j]);
|
||||||
|
const double val = g.valueAt(i, j);
|
||||||
|
if (std::isfinite(val)) {
|
||||||
|
sc->SetValue(id, val);
|
||||||
|
} else {
|
||||||
|
sc->SetValue(id, 0.0);
|
||||||
|
blanks.push_back(id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sgrid->SetPoints(points);
|
sgrid->SetPoints(points);
|
||||||
sgrid->GetPointData()->SetScalars(sc);
|
sgrid->GetPointData()->SetScalars(sc);
|
||||||
|
// 消隐无数据点:vtkStructuredGrid 消隐(ghost)→ 含该点的 cell 不被 vtkDataSetSurfaceFilter 输出,
|
||||||
|
// 故 NaN 永不进入 banded contour。282/1900 有效的不规则反演区→渲染出正确的梯形帘面。
|
||||||
|
for (vtkIdType id : blanks) sgrid->BlankPoint(id);
|
||||||
|
|
||||||
// 用 colorBar 真实分段值做色带(与数据详情#18一致的清晰色带,而非连续插值的糊色)。
|
// 用 colorBar 真实分段值做色带(与数据详情#18一致的清晰色带,而非连续插值的糊色)。
|
||||||
const std::vector<double> stops = cs.stopValues();
|
// 清洗等值线值:vtkBandedPolyDataContourFilter 要求值严格升序且有限——真实色阶可能含重复值
|
||||||
|
// (addStop 排序不去重)或非有限值,未清洗会在 Update() 时崩(0xc0000005)。去非有限 + 去重保序。
|
||||||
|
std::vector<double> stops;
|
||||||
|
{
|
||||||
|
std::vector<double> raw = cs.stopValues();
|
||||||
|
raw.erase(std::remove_if(raw.begin(), raw.end(),
|
||||||
|
[](double v) { return !std::isfinite(v); }),
|
||||||
|
raw.end());
|
||||||
|
std::sort(raw.begin(), raw.end());
|
||||||
|
raw.erase(std::unique(raw.begin(), raw.end()), raw.end());
|
||||||
|
stops = std::move(raw);
|
||||||
|
}
|
||||||
double vmin, vmax;
|
double vmin, vmax;
|
||||||
if (stops.size() >= 2) { vmin = stops.front(); vmax = stops.back(); }
|
if (stops.size() >= 2) { vmin = stops.front(); vmax = stops.back(); }
|
||||||
else {
|
else {
|
||||||
|
|
@ -85,7 +112,7 @@ vtkSmartPointer<vtkActor> buildCurtain(const geopro::core::Grid& g,
|
||||||
|
|
||||||
auto lut = buildLut(cs, vmin, vmax, kLutLevels);
|
auto lut = buildLut(cs, vmin, vmax, kLutLevels);
|
||||||
|
|
||||||
// structuredGrid → 表面 polydata → banded contour(分段色带)
|
// structuredGrid → 表面 polydata(消隐格已剔除) → banded contour(分段色带,色带#18)。
|
||||||
vtkNew<vtkDataSetSurfaceFilter> surf;
|
vtkNew<vtkDataSetSurfaceFilter> surf;
|
||||||
surf->SetInputData(sgrid);
|
surf->SetInputData(sgrid);
|
||||||
vtkNew<vtkBandedPolyDataContourFilter> banded;
|
vtkNew<vtkBandedPolyDataContourFilter> banded;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
#include "ground/TileMath.hpp"
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
|
namespace geopro::render {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
constexpr double kPi = 3.14159265358979323846;
|
||||||
|
int clampInt(int v, int lo, int hi) { return v < lo ? lo : (v > hi ? hi : v); }
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
TileXY lonLatToTile(double lonDeg, double latDeg, int z) {
|
||||||
|
if (z < 0) z = 0;
|
||||||
|
const double n = std::pow(2.0, z);
|
||||||
|
const double latR = latDeg * kPi / 180.0;
|
||||||
|
int x = static_cast<int>(std::floor((lonDeg + 180.0) / 360.0 * n));
|
||||||
|
int y = static_cast<int>(std::floor((1.0 - std::asinh(std::tan(latR)) / kPi) / 2.0 * n));
|
||||||
|
const int hi = static_cast<int>(n) - 1;
|
||||||
|
return TileXY{z, clampInt(x, 0, hi), clampInt(y, 0, hi)};
|
||||||
|
}
|
||||||
|
|
||||||
|
LonLatBox tileBounds(int z, int x, int y) {
|
||||||
|
if (z < 0) z = 0;
|
||||||
|
const double n = std::pow(2.0, z);
|
||||||
|
const double west = x / n * 360.0 - 180.0;
|
||||||
|
const double east = (x + 1) / n * 360.0 - 180.0;
|
||||||
|
auto latAt = [&](double yy) {
|
||||||
|
return std::atan(std::sinh(kPi * (1.0 - 2.0 * yy / n))) * 180.0 / kPi;
|
||||||
|
};
|
||||||
|
const double north = latAt(static_cast<double>(y));
|
||||||
|
const double south = latAt(static_cast<double>(y + 1));
|
||||||
|
return LonLatBox{west, south, east, north};
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace geopro::render
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
// Web Mercator(EPSG:3857) 瓦片坐标数学:天地图/XYZ 底图瓦片定位用(纯函数,无 VTK/Qt 依赖)。
|
||||||
|
// 标准 slippy-map 公式:n=2^z;x=(lon+180)/360*n;y 用墨卡托纬度映射。
|
||||||
|
namespace geopro::render {
|
||||||
|
|
||||||
|
struct TileXY {
|
||||||
|
int z = 0, x = 0, y = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 瓦片地理边界(度):west/east 经度,south/north 纬度。
|
||||||
|
struct LonLatBox {
|
||||||
|
double west = 0, south = 0, east = 0, north = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 经纬度(度) → 指定 zoom 的瓦片行列(x/y 夹紧到 [0, 2^z-1])。
|
||||||
|
TileXY lonLatToTile(double lonDeg, double latDeg, int z);
|
||||||
|
|
||||||
|
// 瓦片 (z,x,y) → 其覆盖的地理边界(度)。
|
||||||
|
LonLatBox tileBounds(int z, int x, int y);
|
||||||
|
|
||||||
|
} // namespace geopro::render
|
||||||
|
|
@ -0,0 +1,214 @@
|
||||||
|
#include "interact/InteractionManager.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <chrono>
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstddef>
|
||||||
|
|
||||||
|
#include <vtkCamera.h>
|
||||||
|
#include <vtkImageData.h>
|
||||||
|
#include <vtkRenderWindow.h>
|
||||||
|
#include <vtkRenderWindowInteractor.h>
|
||||||
|
#include <vtkRenderer.h>
|
||||||
|
|
||||||
|
#include "interact/PickInteractorStyle.hpp"
|
||||||
|
|
||||||
|
namespace geopro::render::interact {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
std::array<double, 6> imageBounds(vtkImageData* img) {
|
||||||
|
std::array<double, 6> b{{0, 0, 0, 0, 0, 0}};
|
||||||
|
if (img) img->GetBounds(b.data());
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
InteractionManager::InteractionManager(vtkRenderWindowInteractor* interactor,
|
||||||
|
vtkRenderWindow* renderWindow, vtkRenderer* renderer)
|
||||||
|
: interactor_(interactor), renderWindow_(renderWindow), renderer_(renderer) {
|
||||||
|
installStyle();
|
||||||
|
}
|
||||||
|
|
||||||
|
InteractionManager::~InteractionManager() {
|
||||||
|
destroying_ = true; // closeAll 跳过 Render(Qt 拆台时窗口可能已半析构)
|
||||||
|
closeAll();
|
||||||
|
uninstallStyle();
|
||||||
|
}
|
||||||
|
|
||||||
|
void InteractionManager::installStyle() {
|
||||||
|
if (!interactor_ || style_) return;
|
||||||
|
style_ = vtkSmartPointer<PickInteractorStyle>::New();
|
||||||
|
style_->onPick = [this](const Vec3& w) { onPicked(w); };
|
||||||
|
style_->onDoubleClick = [this](const Vec3& w) { onDoubleClicked(w); };
|
||||||
|
style_->onWheelStep = [this](int dir) { return onWheel(dir); };
|
||||||
|
// D39: 提供旋转中心 = 选中切片中心(有选中→true)。style 在按下拖动时据此绕选中切片旋转。
|
||||||
|
style_->getRotateCenter = [this](Vec3& c) {
|
||||||
|
if (selected_ < 0 || selected_ >= static_cast<int>(slices_.size())) return false;
|
||||||
|
c = slices_[static_cast<std::size_t>(selected_)]->center();
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
interactor_->SetInteractorStyle(style_);
|
||||||
|
}
|
||||||
|
|
||||||
|
void InteractionManager::uninstallStyle() {
|
||||||
|
if (style_) {
|
||||||
|
// 断开回调(this 即将析构),避免迟到事件回调悬垂。
|
||||||
|
style_->onPick = nullptr;
|
||||||
|
style_->onDoubleClick = nullptr;
|
||||||
|
style_->onWheelStep = nullptr;
|
||||||
|
style_->getRotateCenter = nullptr;
|
||||||
|
}
|
||||||
|
// 从 interactor 上彻底摘除自定义 style,避免 interactor 仍持空回调 style(评审 H2)。
|
||||||
|
if (interactor_) interactor_->SetInteractorStyle(nullptr);
|
||||||
|
style_ = nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
void InteractionManager::safeRender() {
|
||||||
|
if (renderWindow_ && !destroying_) renderWindow_->Render();
|
||||||
|
}
|
||||||
|
|
||||||
|
void InteractionManager::updateSelectionVisual() {
|
||||||
|
for (std::size_t i = 0; i < slices_.size(); ++i)
|
||||||
|
slices_[i]->setSelected(static_cast<int>(i) == selected_);
|
||||||
|
}
|
||||||
|
|
||||||
|
void InteractionManager::setVolumeImage(vtkImageData* image, const geopro::core::ColorScale& cs,
|
||||||
|
double vmin, double vmax) {
|
||||||
|
// 体素重建/变更:先释放旧切片(旧 image 即将失效),再附着新 image。
|
||||||
|
closeAll();
|
||||||
|
image_ = image;
|
||||||
|
colorScale_ = cs;
|
||||||
|
vmin_ = vmin;
|
||||||
|
vmax_ = vmax;
|
||||||
|
}
|
||||||
|
|
||||||
|
void InteractionManager::addSlice(SliceAxis axis) {
|
||||||
|
if (!image_ || !interactor_) return;
|
||||||
|
auto tool = std::make_unique<SliceTool>(image_, interactor_, axis, colorScale_, vmin_, vmax_);
|
||||||
|
// 触碰本切片(拖动/点击切面) → 设为选中(widget 开启交互后独占切面事件,选中靠此回调)。
|
||||||
|
SliceTool* tp = tool.get();
|
||||||
|
tool->onInteract = [this, tp]() { selectByTool(tp); };
|
||||||
|
slices_.push_back(std::move(tool));
|
||||||
|
selected_ = static_cast<int>(slices_.size()) - 1; // 新切片选中
|
||||||
|
updateSelectionVisual();
|
||||||
|
safeRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
void InteractionManager::selectByTool(const SliceTool* tool) {
|
||||||
|
int idx = -1;
|
||||||
|
for (std::size_t i = 0; i < slices_.size(); ++i)
|
||||||
|
if (slices_[i].get() == tool) { idx = static_cast<int>(i); break; }
|
||||||
|
if (idx < 0) return;
|
||||||
|
selected_ = idx;
|
||||||
|
updateSelectionVisual();
|
||||||
|
|
||||||
|
// 双击切片正视(D40):同一切片在 350ms 内两次交互 → 视为双击 → 正视。
|
||||||
|
const double now = std::chrono::duration<double, std::milli>(
|
||||||
|
std::chrono::steady_clock::now().time_since_epoch())
|
||||||
|
.count();
|
||||||
|
const bool dbl = (tool == lastInteractTool_) && lastInteractMs_ >= 0.0 &&
|
||||||
|
(now - lastInteractMs_) < 350.0;
|
||||||
|
lastInteractMs_ = now;
|
||||||
|
lastInteractTool_ = tool;
|
||||||
|
if (dbl) {
|
||||||
|
lastInteractMs_ = -1.0; // 重置避免三连判
|
||||||
|
faceSlice(idx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
safeRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
void InteractionManager::closeSelected() {
|
||||||
|
if (selected_ < 0 || selected_ >= static_cast<int>(slices_.size())) return;
|
||||||
|
slices_[static_cast<std::size_t>(selected_)]->close();
|
||||||
|
slices_.erase(slices_.begin() + selected_);
|
||||||
|
// 选中停在原位就近(删后该位变成下一张;删的是末张则退一张),不跳回 0(评审 M2)。
|
||||||
|
selected_ = slices_.empty() ? -1
|
||||||
|
: std::min(selected_, static_cast<int>(slices_.size()) - 1);
|
||||||
|
updateSelectionVisual();
|
||||||
|
safeRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
void InteractionManager::closeAll() {
|
||||||
|
for (auto& s : slices_) s->close(); // 显式 Off + 解绑(析构亦会,双保险幂等)
|
||||||
|
slices_.clear();
|
||||||
|
selected_ = -1;
|
||||||
|
safeRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
void InteractionManager::flipView() {
|
||||||
|
if (!renderer_) return;
|
||||||
|
auto* cam = renderer_->GetActiveCamera();
|
||||||
|
if (!cam) return;
|
||||||
|
cam->Azimuth(180.0); // 水平旋转 180°(E55)
|
||||||
|
cam->OrthogonalizeViewUp();
|
||||||
|
safeRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
int InteractionManager::nearestSlice(const Vec3& worldPoint) const {
|
||||||
|
if (slices_.empty()) return -1;
|
||||||
|
std::vector<Vec3> centers, normals;
|
||||||
|
centers.reserve(slices_.size());
|
||||||
|
normals.reserve(slices_.size());
|
||||||
|
for (const auto& s : slices_) {
|
||||||
|
centers.push_back(s->center());
|
||||||
|
normals.push_back(s->normal());
|
||||||
|
}
|
||||||
|
const int idx = nearestPlane(centers, normals, worldPoint);
|
||||||
|
if (idx < 0) return -1;
|
||||||
|
// 阈值:命中点离最近切面太远(> 体对角线 5%)视为"没点在切片上",不改选中(评审 M2)。
|
||||||
|
const std::array<double, 6> b = imageBounds(image_);
|
||||||
|
const double dx = b[1] - b[0], dy = b[3] - b[2], dz = b[5] - b[4];
|
||||||
|
const double diag = std::sqrt(dx * dx + dy * dy + dz * dz);
|
||||||
|
const double dist = slices_[static_cast<std::size_t>(idx)]->distanceToPlane(worldPoint);
|
||||||
|
if (diag > 0.0 && dist > diag * 0.05) return -1;
|
||||||
|
return idx;
|
||||||
|
}
|
||||||
|
|
||||||
|
void InteractionManager::onPicked(const Vec3& worldPoint) {
|
||||||
|
// 单击 = 仅选中命中切片 + 高亮,**不动相机** → 切换切片永不跳。
|
||||||
|
// 拖动旋转交给默认 TrackballCamera(绕场景/体中心,稳定)。曾试"按切片中心移焦点"以实现
|
||||||
|
// spec C38'以切片为中心',但切片中心≈体中心→与默认视觉等价、却引入切换跳动,得不偿失,故去除。
|
||||||
|
const int idx = nearestSlice(worldPoint);
|
||||||
|
if (idx >= 0) {
|
||||||
|
selected_ = idx;
|
||||||
|
updateSelectionVisual();
|
||||||
|
}
|
||||||
|
safeRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
void InteractionManager::onDoubleClicked(const Vec3& worldPoint) {
|
||||||
|
// 双击命中切片 → 正视(widget 开启交互后双击多被其吞,正视主入口改工具条按钮 faceSelected)。
|
||||||
|
const int idx = nearestSlice(worldPoint);
|
||||||
|
if (idx < 0) return;
|
||||||
|
selected_ = idx;
|
||||||
|
updateSelectionVisual();
|
||||||
|
faceSlice(idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
void InteractionManager::faceSlice(int idx) {
|
||||||
|
if (idx < 0 || idx >= static_cast<int>(slices_.size()) || !renderer_) return;
|
||||||
|
auto* cam = renderer_->GetActiveCamera();
|
||||||
|
if (!cam) return;
|
||||||
|
const Vec3 focal = slices_[static_cast<std::size_t>(idx)]->center();
|
||||||
|
const Vec3 normal = slices_[static_cast<std::size_t>(idx)]->normal();
|
||||||
|
const double dist = cam->GetDistance(); // 保持当前观察距离
|
||||||
|
const FaceOnCamera face = faceOnCamera(focal, normal, dist);
|
||||||
|
cam->SetFocalPoint(focal[0], focal[1], focal[2]);
|
||||||
|
cam->SetPosition(face.position[0], face.position[1], face.position[2]);
|
||||||
|
cam->SetViewUp(face.viewUp[0], face.viewUp[1], face.viewUp[2]);
|
||||||
|
cam->OrthogonalizeViewUp();
|
||||||
|
renderer_->ResetCameraClippingRange();
|
||||||
|
safeRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool InteractionManager::onWheel(int dir) {
|
||||||
|
if (selected_ < 0 || selected_ >= static_cast<int>(slices_.size())) return false;
|
||||||
|
const double step = wheelStep(imageBounds(image_), dir);
|
||||||
|
slices_[static_cast<std::size_t>(selected_)]->advance(step);
|
||||||
|
safeRender();
|
||||||
|
return true; // 消费滚轮(不缩放)
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace geopro::render::interact
|
||||||
|
|
@ -0,0 +1,104 @@
|
||||||
|
#pragma once
|
||||||
|
#include <memory>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <vtkSmartPointer.h>
|
||||||
|
|
||||||
|
#include "interact/SlicePlaneMath.hpp"
|
||||||
|
#include "interact/SliceTool.hpp"
|
||||||
|
#include "model/ColorScale.hpp"
|
||||||
|
|
||||||
|
class vtkImageData;
|
||||||
|
class vtkRenderWindow;
|
||||||
|
class vtkRenderWindowInteractor;
|
||||||
|
class vtkRenderer;
|
||||||
|
|
||||||
|
namespace geopro::render::interact {
|
||||||
|
|
||||||
|
class PickInteractorStyle;
|
||||||
|
|
||||||
|
// 三维切片交互编排(spec §9):持 interactor + 活动切片列表 + 选中态。
|
||||||
|
// · 创建/关闭切片(轴向/任意),附着到当前体素 image(含 VE 烤入的几何)。
|
||||||
|
// · 安装自定义 PickInteractorStyle:拾取选中→绕命中点旋转;双击切片→正视;滚轮→沿法向推进选中切片。
|
||||||
|
// · 视图翻转(水平 Azimuth 180°,E55)。
|
||||||
|
// · 切到二维 / 体素重建 / 清场:closeAll 安全释放所有切片(Off + 解绑,防悬挂观察者崩溃)。
|
||||||
|
//
|
||||||
|
// render 层:只碰 VTK widget/相机,不认仓储;产物经回调/上层处理(本期切片仅在视图内交互)。
|
||||||
|
class InteractionManager {
|
||||||
|
public:
|
||||||
|
// interactor:QVTK 提供的活 interactor(renderWindow->GetInteractor())。
|
||||||
|
// renderWindow:用于推进/翻转后重绘。
|
||||||
|
InteractionManager(vtkRenderWindowInteractor* interactor, vtkRenderWindow* renderWindow,
|
||||||
|
vtkRenderer* renderer);
|
||||||
|
~InteractionManager();
|
||||||
|
|
||||||
|
InteractionManager(const InteractionManager&) = delete;
|
||||||
|
InteractionManager& operator=(const InteractionManager&) = delete;
|
||||||
|
|
||||||
|
// 设置当前体素 image + 色阶(体素重建后调;image 变更先 closeAll 再附着新 image)。
|
||||||
|
// image=nullptr → 清空附着,切片创建无效。
|
||||||
|
void setVolumeImage(vtkImageData* image, const geopro::core::ColorScale& cs, double vmin,
|
||||||
|
double vmax);
|
||||||
|
|
||||||
|
// 创建一张切片(轴向/任意)。无体素 image 则忽略。新切片自动设为选中。
|
||||||
|
void addSlice(SliceAxis axis);
|
||||||
|
|
||||||
|
// 关闭选中切片(E56)。无选中则忽略。
|
||||||
|
void closeSelected();
|
||||||
|
// 关闭并释放所有切片(切到二维 / 清场 / 体素重建前调)。
|
||||||
|
void closeAll();
|
||||||
|
|
||||||
|
bool hasVolume() const { return image_ != nullptr; }
|
||||||
|
bool hasSlices() const { return !slices_.empty(); }
|
||||||
|
int sliceCount() const { return static_cast<int>(slices_.size()); }
|
||||||
|
|
||||||
|
// 视图翻转:水平旋转 180°(E55)。
|
||||||
|
void flipView();
|
||||||
|
|
||||||
|
// 安装/卸载自定义交互样式(构造时安装;析构卸载恢复原样式)。
|
||||||
|
void installStyle();
|
||||||
|
void uninstallStyle();
|
||||||
|
|
||||||
|
private:
|
||||||
|
// 拾取回调实现(PickInteractorStyle 注入)。
|
||||||
|
void onPicked(const Vec3& worldPoint); // 选中所在切片 + 焦点
|
||||||
|
void onDoubleClicked(const Vec3& worldPoint); // 正视所在切片
|
||||||
|
bool onWheel(int dir); // 推进选中切片;无选中返回 false
|
||||||
|
|
||||||
|
// 找离世界点最近的切片索引;无切片返回 -1。
|
||||||
|
int nearestSlice(const Vec3& worldPoint) const;
|
||||||
|
// 按 SliceTool 指针设为选中(widget 交互回调用:触碰即选中)。
|
||||||
|
void selectByTool(const SliceTool* tool);
|
||||||
|
// 相机正视给定切面(focal=center, 沿 normal 退 dist)。
|
||||||
|
void faceSlice(int idx);
|
||||||
|
|
||||||
|
// 统一重绘:析构进行中(destroying_)跳过,避免 Qt 拆台时对半析构窗口 Render 崩溃(评审 H3)。
|
||||||
|
void safeRender();
|
||||||
|
|
||||||
|
// 按 selected_ 刷新各切片高亮(选中亮黄、其余暗淡)。
|
||||||
|
void updateSelectionVisual();
|
||||||
|
|
||||||
|
vtkRenderWindowInteractor* interactor_;
|
||||||
|
vtkRenderWindow* renderWindow_;
|
||||||
|
vtkRenderer* renderer_;
|
||||||
|
|
||||||
|
vtkImageData* image_ = nullptr; // 非拥有;当前体素 image
|
||||||
|
geopro::core::ColorScale colorScale_;
|
||||||
|
double vmin_ = 0.0, vmax_ = 0.0;
|
||||||
|
|
||||||
|
std::vector<std::unique_ptr<SliceTool>> slices_;
|
||||||
|
int selected_ = -1; // 选中切片索引(-1=无)
|
||||||
|
|
||||||
|
vtkSmartPointer<PickInteractorStyle> style_;
|
||||||
|
|
||||||
|
// 析构进行中:closeAll() 跳过 renderWindow_->Render()(Qt 拆台时窗口可能已半析构,
|
||||||
|
// 析构期再 Render 易崩,评审 M3)。
|
||||||
|
bool destroying_ = false;
|
||||||
|
|
||||||
|
// 双击切片正视(D40)检测:同一切片在阈值内两次交互(StartInteractionEvent)视为双击 → 正视。
|
||||||
|
// 因 widget 开启交互后独占切面事件,双击靠监听 widget 交互判定,而非 InteractorStyle。
|
||||||
|
double lastInteractMs_ = -1.0;
|
||||||
|
const SliceTool* lastInteractTool_ = nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace geopro::render::interact
|
||||||
|
|
@ -0,0 +1,139 @@
|
||||||
|
#include "interact/PickInteractorStyle.hpp"
|
||||||
|
|
||||||
|
#include <chrono>
|
||||||
|
|
||||||
|
#include <vtkCamera.h>
|
||||||
|
#include <vtkCellPicker.h>
|
||||||
|
#include <vtkMath.h>
|
||||||
|
#include <vtkNew.h>
|
||||||
|
#include <vtkObjectFactory.h>
|
||||||
|
#include <vtkRenderWindow.h>
|
||||||
|
#include <vtkRenderWindowInteractor.h>
|
||||||
|
#include <vtkRenderer.h>
|
||||||
|
#include <vtkTransform.h>
|
||||||
|
|
||||||
|
namespace geopro::render::interact {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
constexpr double kDoubleClickMs = 350.0; // 两次左键按下间隔阈值
|
||||||
|
constexpr int kClickSlopPx2 = 36; // 位置相近阈值平方(6px)
|
||||||
|
|
||||||
|
// 当前单调时钟(毫秒)。用 std::chrono 避免依赖 VTK::CommonSystem(vtkTimerLog)。
|
||||||
|
double nowMs() {
|
||||||
|
return std::chrono::duration<double, std::milli>(
|
||||||
|
std::chrono::steady_clock::now().time_since_epoch())
|
||||||
|
.count();
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
vtkStandardNewMacro(PickInteractorStyle);
|
||||||
|
|
||||||
|
bool PickInteractorStyle::pickWorld(Vec3& out) {
|
||||||
|
auto* iren = this->GetInteractor();
|
||||||
|
if (!iren) return false;
|
||||||
|
const int* pos = iren->GetEventPosition();
|
||||||
|
// 用交互器解析被点中的 renderer(基类 FindPokedRenderer 仅设 CurrentRenderer、返回 void)。
|
||||||
|
auto* ren = iren->FindPokedRenderer(pos[0], pos[1]);
|
||||||
|
if (!ren) return false;
|
||||||
|
// CellPicker:返回表面交点世界坐标(命中切片纹理面/帘面等)。
|
||||||
|
vtkNew<vtkCellPicker> picker;
|
||||||
|
picker->SetTolerance(0.005);
|
||||||
|
if (!picker->Pick(pos[0], pos[1], 0.0, ren)) return false;
|
||||||
|
double w[3];
|
||||||
|
picker->GetPickPosition(w);
|
||||||
|
out = {w[0], w[1], w[2]};
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void PickInteractorStyle::OnLeftButtonDown() {
|
||||||
|
auto* iren = this->GetInteractor();
|
||||||
|
Vec3 world;
|
||||||
|
const bool hit = pickWorld(world);
|
||||||
|
|
||||||
|
// 手动双击判定(GetRepeatCount 在 QVTK+Windows 不可靠,评审 M5):
|
||||||
|
// 两次左键按下间隔 < 阈值且屏幕位置相近 → 双击。
|
||||||
|
const double now = nowMs();
|
||||||
|
const int* pos = iren ? iren->GetEventPosition() : nullptr;
|
||||||
|
bool isDouble = false;
|
||||||
|
if (hit && pos && lastDownTime_ >= 0.0) {
|
||||||
|
const double dtMs = now - lastDownTime_;
|
||||||
|
const int dx = pos[0] - lastDownPos_[0];
|
||||||
|
const int dy = pos[1] - lastDownPos_[1];
|
||||||
|
if (dtMs < kDoubleClickMs && (dx * dx + dy * dy) <= kClickSlopPx2) isDouble = true;
|
||||||
|
}
|
||||||
|
if (pos) {
|
||||||
|
lastDownPos_[0] = pos[0];
|
||||||
|
lastDownPos_[1] = pos[1];
|
||||||
|
}
|
||||||
|
lastDownTime_ = now;
|
||||||
|
|
||||||
|
if (isDouble) {
|
||||||
|
// 双击命中 → 正视所在切片(manager 找最近切片 + 算相机)。
|
||||||
|
if (onDoubleClick) onDoubleClick(world);
|
||||||
|
lastDownTime_ = -1.0; // 重置,避免三击连判
|
||||||
|
return; // 不进入拖动旋转
|
||||||
|
}
|
||||||
|
if (hit) {
|
||||||
|
// 单击命中 → 选中所在切片(onPick 内仅选中, 不动相机)。
|
||||||
|
if (onPick) onPick(world);
|
||||||
|
}
|
||||||
|
// 不在按下时动相机(动相机=跳);绕选中物旋转在 Rotate() 内做(增量绕支点,不跳)。
|
||||||
|
Superclass::OnLeftButtonDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
void PickInteractorStyle::Rotate() {
|
||||||
|
Vec3 c;
|
||||||
|
if (!this->CurrentRenderer || !getRotateCenter || !getRotateCenter(c)) {
|
||||||
|
Superclass::Rotate(); // 无选中物 → 默认绕焦点旋转
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
auto* rwi = this->Interactor;
|
||||||
|
auto* cam = this->CurrentRenderer->GetActiveCamera();
|
||||||
|
if (!rwi || !cam) return;
|
||||||
|
const int dx = rwi->GetEventPosition()[0] - rwi->GetLastEventPosition()[0];
|
||||||
|
const int dy = rwi->GetEventPosition()[1] - rwi->GetLastEventPosition()[1];
|
||||||
|
const int* size = this->CurrentRenderer->GetRenderWindow()->GetSize();
|
||||||
|
if (size[0] <= 0 || size[1] <= 0) return;
|
||||||
|
// 与 TrackballCamera 同口径的角度映射。
|
||||||
|
const double azimuth = dx * (-20.0 / size[0]) * this->MotionFactor;
|
||||||
|
const double elevation = dy * (-20.0 / size[1]) * this->MotionFactor;
|
||||||
|
|
||||||
|
double up[3], dop[3], right[3];
|
||||||
|
cam->GetViewUp(up);
|
||||||
|
cam->GetDirectionOfProjection(dop); // 归一化的 (focal-pos)
|
||||||
|
vtkMath::Cross(dop, up, right); // 屏幕"右"轴
|
||||||
|
vtkMath::Normalize(right);
|
||||||
|
|
||||||
|
// 绕中心 c 的支点:T(c)·R(up,azimuth)·R(right,elevation)·T(-c),作用于 position/focal;up 只转不平移。
|
||||||
|
vtkNew<vtkTransform> t;
|
||||||
|
t->Identity();
|
||||||
|
t->Translate(c[0], c[1], c[2]);
|
||||||
|
t->RotateWXYZ(azimuth, up[0], up[1], up[2]);
|
||||||
|
t->RotateWXYZ(elevation, right[0], right[1], right[2]);
|
||||||
|
t->Translate(-c[0], -c[1], -c[2]);
|
||||||
|
|
||||||
|
double pos[3], foc[3], npos[3], nfoc[3], nup[3];
|
||||||
|
cam->GetPosition(pos);
|
||||||
|
cam->GetFocalPoint(foc);
|
||||||
|
t->TransformPoint(pos, npos);
|
||||||
|
t->TransformPoint(foc, nfoc);
|
||||||
|
t->TransformVector(up, nup); // 仅旋转部分作用于向量
|
||||||
|
cam->SetPosition(npos);
|
||||||
|
cam->SetFocalPoint(nfoc);
|
||||||
|
cam->SetViewUp(nup);
|
||||||
|
cam->OrthogonalizeViewUp();
|
||||||
|
if (this->AutoAdjustCameraClippingRange) this->CurrentRenderer->ResetCameraClippingRange();
|
||||||
|
rwi->Render();
|
||||||
|
}
|
||||||
|
|
||||||
|
void PickInteractorStyle::OnMouseWheelForward() {
|
||||||
|
if (onWheelStep && onWheelStep(+1)) return; // 有选中切片 → 推进,消费滚轮
|
||||||
|
Superclass::OnMouseWheelForward(); // 否则默认缩放
|
||||||
|
}
|
||||||
|
|
||||||
|
void PickInteractorStyle::OnMouseWheelBackward() {
|
||||||
|
if (onWheelStep && onWheelStep(-1)) return;
|
||||||
|
Superclass::OnMouseWheelBackward();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace geopro::render::interact
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
#pragma once
|
||||||
|
#include <functional>
|
||||||
|
|
||||||
|
#include <vtkInteractorStyleTrackballCamera.h>
|
||||||
|
|
||||||
|
#include "interact/SlicePlaneMath.hpp"
|
||||||
|
|
||||||
|
namespace geopro::render::interact {
|
||||||
|
|
||||||
|
// 自定义交互样式:在 TrackballCamera 基础上加拾取与切片交互(spec §9.3)。
|
||||||
|
// 左键按下 → vtkPropPicker 拾取 → 命中则相机 focalPoint=命中点(拖动绕其旋转),
|
||||||
|
// 并把命中世界点回调出去(InteractionManager 据此选中所在切片)。
|
||||||
|
// 左键双击 → 回调双击世界点(InteractionManager 找最近切片 → 相机正视其法向)。
|
||||||
|
// 滚轮前/后 → 回调步进方向(±1),由 manager 推进选中切片;无选中则回退默认缩放。
|
||||||
|
// 保留 TrackballCamera 的相机拖动/缩放等基础交互(仅在命中/有选中切片时改写行为)。
|
||||||
|
//
|
||||||
|
// 回调由 InteractionManager 注入(render 层不认业务,只发"命中点/双击/滚轮"事件)。
|
||||||
|
class PickInteractorStyle : public vtkInteractorStyleTrackballCamera {
|
||||||
|
public:
|
||||||
|
static PickInteractorStyle* New();
|
||||||
|
vtkTypeMacro(PickInteractorStyle, vtkInteractorStyleTrackballCamera);
|
||||||
|
|
||||||
|
// 单击命中世界点(已命中某 prop)。用于设焦点+选中切片。
|
||||||
|
std::function<void(const Vec3& worldPoint)> onPick;
|
||||||
|
// 双击世界点。用于正视所在切片。
|
||||||
|
std::function<void(const Vec3& worldPoint)> onDoubleClick;
|
||||||
|
// 滚轮步进:dir=+1 前/-1 后。返回 true 表示已被消费(有选中切片推进),
|
||||||
|
// false 则执行默认相机缩放。
|
||||||
|
std::function<bool(int dir)> onWheelStep;
|
||||||
|
// 取当前旋转中心(D39):有选中三维体/切片→填其中心、返回 true;否则 false(绕默认焦点)。
|
||||||
|
// 在"按下开始拖动"时调用一次,把焦点设到该中心(位置同步补偿,画面不变)→ 之后绕它旋转、不跳。
|
||||||
|
std::function<bool(Vec3& center)> getRotateCenter;
|
||||||
|
|
||||||
|
void OnLeftButtonDown() override;
|
||||||
|
void OnMouseWheelForward() override;
|
||||||
|
void OnMouseWheelBackward() override;
|
||||||
|
// 绕选中物中心旋转(D39):有 getRotateCenter 时, 绕该中心增量旋转整个相机(位置+焦点+up),
|
||||||
|
// 中心在世界/屏幕都不动→不跳; 否则回退默认(绕焦点)。
|
||||||
|
void Rotate() override;
|
||||||
|
|
||||||
|
protected:
|
||||||
|
PickInteractorStyle() = default;
|
||||||
|
|
||||||
|
private:
|
||||||
|
// 在当前鼠标位置拾取世界点;命中返回 true 并填 out。
|
||||||
|
bool pickWorld(Vec3& out);
|
||||||
|
|
||||||
|
// 手动双击判定:QVTK+Windows 下 vtkRenderWindowInteractor::GetRepeatCount() 不可靠(评审 M5)。
|
||||||
|
// 记上次左键按下时刻+屏幕位置,两次按下间隔 < kDoubleClickMs 且位置相近视为双击。
|
||||||
|
double lastDownTime_ = -1.0; // 单调时钟(毫秒),-1=无
|
||||||
|
int lastDownPos_[2] = {0, 0};
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace geopro::render::interact
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
#include "interact/SlicePlaneMath.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstddef>
|
||||||
|
|
||||||
|
namespace geopro::render::interact {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
// 法向接近竖直(±Z)时 viewUp 不能再取"向上",退备用 up。
|
||||||
|
constexpr double kVerticalThreshold = 0.999;
|
||||||
|
constexpr double kSqrt2Inv = 0.70710678118654752440; // sin/cos 45°
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
double dot(const Vec3& a, const Vec3& b) { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; }
|
||||||
|
|
||||||
|
double norm(const Vec3& a) { return std::sqrt(dot(a, a)); }
|
||||||
|
|
||||||
|
Vec3 normalize(const Vec3& a) {
|
||||||
|
const double n = norm(a);
|
||||||
|
if (n <= 0.0) return {0.0, 0.0, 1.0}; // 零向量兜底
|
||||||
|
return {a[0] / n, a[1] / n, a[2] / n};
|
||||||
|
}
|
||||||
|
|
||||||
|
Vec3 cross(const Vec3& a, const Vec3& b) {
|
||||||
|
return {a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]};
|
||||||
|
}
|
||||||
|
|
||||||
|
Vec3 axisNormal(SliceAxis axis) {
|
||||||
|
switch (axis) {
|
||||||
|
case SliceAxis::UpDown: return {0.0, 0.0, 1.0};
|
||||||
|
case SliceAxis::FrontBack: return {0.0, 1.0, 0.0};
|
||||||
|
case SliceAxis::LeftRight: return {1.0, 0.0, 0.0};
|
||||||
|
case SliceAxis::Oblique: return {kSqrt2Inv, 0.0, kSqrt2Inv};
|
||||||
|
}
|
||||||
|
return {0.0, 0.0, 1.0};
|
||||||
|
}
|
||||||
|
|
||||||
|
Vec3 boundsCenter(const std::array<double, 6>& b) {
|
||||||
|
return {0.5 * (b[0] + b[1]), 0.5 * (b[2] + b[3]), 0.5 * (b[4] + b[5])};
|
||||||
|
}
|
||||||
|
|
||||||
|
Vec3 advanceOrigin(const Vec3& origin, const Vec3& normal, double step) {
|
||||||
|
const Vec3 n = normalize(normal);
|
||||||
|
return {origin[0] + n[0] * step, origin[1] + n[1] * step, origin[2] + n[2] * step};
|
||||||
|
}
|
||||||
|
|
||||||
|
Vec3 clampToBounds(const Vec3& origin, const std::array<double, 6>& b) {
|
||||||
|
auto clamp1 = [](double v, double lo, double hi) {
|
||||||
|
if (lo > hi) std::swap(lo, hi); // 容错:bounds 反序
|
||||||
|
if (v < lo) return lo;
|
||||||
|
if (v > hi) return hi;
|
||||||
|
return v;
|
||||||
|
};
|
||||||
|
return {clamp1(origin[0], b[0], b[1]), clamp1(origin[1], b[2], b[3]),
|
||||||
|
clamp1(origin[2], b[4], b[5])};
|
||||||
|
}
|
||||||
|
|
||||||
|
double wheelStep(const std::array<double, 6>& b, int dir) {
|
||||||
|
const double dx = b[1] - b[0], dy = b[3] - b[2], dz = b[5] - b[4];
|
||||||
|
const double diag = std::sqrt(dx * dx + dy * dy + dz * dz);
|
||||||
|
const double mag = diag * 0.02; // 一次滚轮 ≈ 1/50 对角线
|
||||||
|
return (dir >= 0 ? mag : -mag);
|
||||||
|
}
|
||||||
|
|
||||||
|
int nearestPlane(const std::vector<Vec3>& centers, const std::vector<Vec3>& normals,
|
||||||
|
const Vec3& p) {
|
||||||
|
int best = -1;
|
||||||
|
double bestDist = 0.0;
|
||||||
|
for (std::size_t i = 0; i < centers.size() && i < normals.size(); ++i) {
|
||||||
|
const Vec3 n = normalize(normals[i]);
|
||||||
|
const Vec3 d{p[0] - centers[i][0], p[1] - centers[i][1], p[2] - centers[i][2]};
|
||||||
|
const double dist = std::abs(dot(d, n));
|
||||||
|
if (best < 0 || dist < bestDist) {
|
||||||
|
best = static_cast<int>(i);
|
||||||
|
bestDist = dist;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
FaceOnCamera faceOnCamera(const Vec3& focal, const Vec3& normal, double dist) {
|
||||||
|
const Vec3 n = normalize(normal);
|
||||||
|
// 相机沿法向退 dist:视线 = focal - position = -n(正对切面)。
|
||||||
|
const Vec3 position{focal[0] + n[0] * dist, focal[1] + n[1] * dist, focal[2] + n[2] * dist};
|
||||||
|
|
||||||
|
// viewUp:取与法向正交、尽量指向 +Z 的向量。
|
||||||
|
// worldUp×n 得右向量,再 n×right 得位于切面内且偏上的 up。
|
||||||
|
// 法向接近竖直(±Z)时 worldUp 与 n 共线 → 退备用 up=+Y。
|
||||||
|
Vec3 worldUp = (std::abs(n[2]) > kVerticalThreshold) ? Vec3{0.0, 1.0, 0.0} : Vec3{0.0, 0.0, 1.0};
|
||||||
|
const Vec3 right = normalize(cross(worldUp, n));
|
||||||
|
const Vec3 up = normalize(cross(n, right));
|
||||||
|
return {position, up};
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace geopro::render::interact
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
#pragma once
|
||||||
|
#include <array>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace geopro::render::interact {
|
||||||
|
|
||||||
|
// 三维向量别名(世界系;x=East,y=North,z=-depth*VE)。
|
||||||
|
using Vec3 = std::array<double, 3>;
|
||||||
|
|
||||||
|
// 轴向切片方向(spec §4 F22–F24):
|
||||||
|
// UpDown 上下 = 水平面,法向沿 Z((0,0,1))—— 切出"水平剖面"。
|
||||||
|
// FrontBack 前后 = 法向沿 Y((0,1,0))。
|
||||||
|
// LeftRight 左右 = 法向沿 X((1,0,0))。
|
||||||
|
// Oblique 任意(F25)= 初始 45°,可旋转。
|
||||||
|
enum class SliceAxis { UpDown, FrontBack, LeftRight, Oblique };
|
||||||
|
|
||||||
|
// ── 纯几何函数(无 VTK 依赖,可单测)────────────────────────────────────
|
||||||
|
|
||||||
|
// 轴向/任意切片的初始法向(单位向量)。
|
||||||
|
// UpDown→(0,0,1);FrontBack→(0,1,0);LeftRight→(1,0,0);
|
||||||
|
// Oblique→ XZ 平面内 45°((sin45,0,cos45)),即斜插体的对角面。
|
||||||
|
Vec3 axisNormal(SliceAxis axis);
|
||||||
|
|
||||||
|
// 包围盒 [xmin,xmax,ymin,ymax,zmin,zmax] 的中心点。
|
||||||
|
Vec3 boundsCenter(const std::array<double, 6>& bounds);
|
||||||
|
|
||||||
|
// 滚轮推进:origin' = origin + normal * step(沿法向平移切面一点)。
|
||||||
|
// step>0 正向(沿法向),step<0 反向。
|
||||||
|
Vec3 advanceOrigin(const Vec3& origin, const Vec3& normal, double step);
|
||||||
|
|
||||||
|
// 把 origin 夹在包围盒内(沿法向推进时防切面跑出体外)。
|
||||||
|
// 逐分量 clamp 到 [min,max];退化轴(min==max)取该值。
|
||||||
|
Vec3 clampToBounds(const Vec3& origin, const std::array<double, 6>& bounds);
|
||||||
|
|
||||||
|
// 双击正视:给定切面中心 focal、法向 normal、相机到焦点距离 dist,
|
||||||
|
// 求相机 position 与 viewUp,使相机正对切面(视线 = -normal)。
|
||||||
|
// position = focal + normalize(normal) * dist。
|
||||||
|
// viewUp 取与法向正交的"尽量向上(+Z)"向量;当法向接近竖直(±Z)时
|
||||||
|
// 退到备用 up=+Y 兜底(避免 viewUp 与视线共线导致相机退化)。
|
||||||
|
struct FaceOnCamera {
|
||||||
|
Vec3 position;
|
||||||
|
Vec3 viewUp;
|
||||||
|
};
|
||||||
|
FaceOnCamera faceOnCamera(const Vec3& focal, const Vec3& normal, double dist);
|
||||||
|
|
||||||
|
// 滚轮推进步长:取包围盒对角线长度的固定比例 × 方向(±1)。
|
||||||
|
// 使一次滚轮在体内移动适中(约 1/50 对角线);dir>0 沿法向、dir<0 反向。
|
||||||
|
double wheelStep(const std::array<double, 6>& bounds, int dir);
|
||||||
|
|
||||||
|
// 在切片中心列表中找离世界点最近的索引(按到平面的距离最小)。
|
||||||
|
// centers/normals 等长;空列表返回 -1。worldPoint 在哪张切片上→该索引。
|
||||||
|
int nearestPlane(const std::vector<Vec3>& centers, const std::vector<Vec3>& normals,
|
||||||
|
const Vec3& worldPoint);
|
||||||
|
|
||||||
|
// 向量工具(暴露供测试/复用)。
|
||||||
|
double dot(const Vec3& a, const Vec3& b);
|
||||||
|
double norm(const Vec3& a);
|
||||||
|
Vec3 normalize(const Vec3& a); // 零向量返回 (0,0,1) 兜底
|
||||||
|
Vec3 cross(const Vec3& a, const Vec3& b);
|
||||||
|
|
||||||
|
} // namespace geopro::render::interact
|
||||||
|
|
@ -0,0 +1,176 @@
|
||||||
|
#include "interact/SliceTool.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
|
#include <vtkCallbackCommand.h>
|
||||||
|
#include <vtkCommand.h>
|
||||||
|
#include <vtkImageData.h>
|
||||||
|
#include <vtkImagePlaneWidget.h>
|
||||||
|
#include <vtkLookupTable.h>
|
||||||
|
#include <vtkProperty.h>
|
||||||
|
#include <vtkRenderWindowInteractor.h>
|
||||||
|
#include <vtkTrivialProducer.h>
|
||||||
|
|
||||||
|
#include "ColorLutBuilder.hpp"
|
||||||
|
|
||||||
|
namespace geopro::render::interact {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
// 任意切片初始法向(45°,XZ 面内);轴向用 SetPlaneOrientationTo*。
|
||||||
|
constexpr double kSqrt2Inv = 0.70710678118654752440;
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
SliceTool::SliceTool(vtkImageData* image, vtkRenderWindowInteractor* interactor, SliceAxis axis,
|
||||||
|
const geopro::core::ColorScale& cs, double vmin, double vmax)
|
||||||
|
: axis_(axis), image_(image), widget_(vtkSmartPointer<vtkImagePlaneWidget>::New()) {
|
||||||
|
// 经 trivial producer 把已存在的 vtkImageData 接入 widget(widget 只暴露 SetInputConnection)。
|
||||||
|
// producer_ 为成员,随 SliceTool 保活(局部变量会构造后即析构→管线断裂,评审 H1)。
|
||||||
|
producer_ = vtkSmartPointer<vtkTrivialProducer>::New();
|
||||||
|
producer_->SetOutput(image_);
|
||||||
|
widget_->SetInputConnection(producer_->GetOutputPort());
|
||||||
|
|
||||||
|
widget_->SetInteractor(interactor);
|
||||||
|
widget_->RestrictPlaneToVolumeOn(); // 切面限制在体内,滚轮推进不跑飞
|
||||||
|
widget_->SetResliceInterpolateToLinear(); // reslice 线性插值出连续剖面(非 cutter 交线)
|
||||||
|
widget_->TextureInterpolateOn();
|
||||||
|
widget_->DisplayTextOff();
|
||||||
|
|
||||||
|
// 色阶 LUT 套用:用户自管 LUT(不让 widget 用默认灰度窗位)。
|
||||||
|
auto lut = buildLut(cs, vmin, vmax);
|
||||||
|
widget_->SetLookupTable(lut);
|
||||||
|
|
||||||
|
// 轴向:固定到 X/Y/Z(角度不可调,符合 G22–G24)。
|
||||||
|
// 上下=水平面=Z 法向;前后=Y 法向;左右=X 法向。
|
||||||
|
switch (axis_) {
|
||||||
|
case SliceAxis::UpDown:
|
||||||
|
widget_->SetPlaneOrientationToZAxes();
|
||||||
|
break;
|
||||||
|
case SliceAxis::FrontBack:
|
||||||
|
widget_->SetPlaneOrientationToYAxes();
|
||||||
|
break;
|
||||||
|
case SliceAxis::LeftRight:
|
||||||
|
widget_->SetPlaneOrientationToXAxes();
|
||||||
|
break;
|
||||||
|
case SliceAxis::Oblique: {
|
||||||
|
// 任意 45°(F25):vtkImagePlaneWidget 用 Origin/Point1/Point2 三角点定义平面
|
||||||
|
// (无 SetNormal)。法向 = (Point1-Origin)×(Point2-Origin)。
|
||||||
|
// 取法向 (sin45,0,cos45):in-plane 轴1 = Y(0,1,0),轴2 = XZ 内与法向正交方向 (cos45,0,-sin45)。
|
||||||
|
// 以体中心为面心,沿两轴各展半个体范围,得一张斜插体的对角面(可继续交互旋转)。
|
||||||
|
const auto b = imageBounds();
|
||||||
|
const double cx = 0.5 * (b[0] + b[1]);
|
||||||
|
const double cy = 0.5 * (b[2] + b[3]);
|
||||||
|
const double cz = 0.5 * (b[4] + b[5]);
|
||||||
|
const double hy = 0.5 * (b[3] - b[2]);
|
||||||
|
// 轴2 半长取 X/Z 范围的较大者,保证面铺满体对角。
|
||||||
|
const double hxz = 0.5 * std::max(b[1] - b[0], b[5] - b[4]);
|
||||||
|
// 轴1 = +Y;轴2 = (cos45,0,-sin45)。
|
||||||
|
const double a2x = kSqrt2Inv, a2z = -kSqrt2Inv;
|
||||||
|
// Origin = center - 0.5*axis1 - 0.5*axis2(使 center 为面心)。
|
||||||
|
const double ox = cx - 0.0 - a2x * hxz;
|
||||||
|
const double oy = cy - hy - 0.0;
|
||||||
|
const double oz = cz - 0.0 - a2z * hxz;
|
||||||
|
widget_->SetOrigin(ox, oy, oz);
|
||||||
|
widget_->SetPoint1(ox + 0.0, oy + 2.0 * hy, oz + 0.0); // 沿 +Y
|
||||||
|
widget_->SetPoint2(ox + a2x * 2.0 * hxz, oy, oz + a2z * 2.0 * hxz); // 沿 (cos45,0,-sin45)
|
||||||
|
widget_->UpdatePlacement();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 左键拖动=移动切面(默认左键是窗位调整,无用);中键=取值光标。
|
||||||
|
widget_->SetLeftButtonAction(vtkImagePlaneWidget::VTK_SLICE_MOTION_ACTION);
|
||||||
|
widget_->SetMiddleButtonAction(vtkImagePlaneWidget::VTK_CURSOR_ACTION);
|
||||||
|
// 旋转只允许"任意切片"(F25 可任意调整);轴向(上下/前后/左右)角度固定(G22-24 角度不能再调整):
|
||||||
|
// 把切面边缘(margins, 旋转抓取区)设为 0 → 抓哪里都只移动、不旋转。
|
||||||
|
if (axis_ != SliceAxis::Oblique) {
|
||||||
|
widget_->SetMarginSizeX(0.0);
|
||||||
|
widget_->SetMarginSizeY(0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
widget_->On();
|
||||||
|
// 保持 widget 交互开启:任意切片可拖动调整角度/位置(F25 '可任意调整')。
|
||||||
|
// 监听其交互开始事件 → 触碰本切片即回调 onInteract(上层据此设为选中)。
|
||||||
|
interactObserver_ = vtkSmartPointer<vtkCallbackCommand>::New();
|
||||||
|
interactObserver_->SetClientData(this);
|
||||||
|
interactObserver_->SetCallback([](vtkObject*, unsigned long, void* client, void*) {
|
||||||
|
auto* self = static_cast<SliceTool*>(client);
|
||||||
|
if (self && self->onInteract) self->onInteract();
|
||||||
|
});
|
||||||
|
widget_->AddObserver(vtkCommand::StartInteractionEvent, interactObserver_);
|
||||||
|
}
|
||||||
|
|
||||||
|
SliceTool::~SliceTool() { close(); }
|
||||||
|
|
||||||
|
std::array<double, 6> SliceTool::imageBounds() const {
|
||||||
|
std::array<double, 6> b{{0, 0, 0, 0, 0, 0}};
|
||||||
|
if (image_) image_->GetBounds(b.data());
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
Vec3 SliceTool::normal() const {
|
||||||
|
double n[3] = {0, 0, 1};
|
||||||
|
if (widget_) widget_->GetNormal(n);
|
||||||
|
return normalize({n[0], n[1], n[2]});
|
||||||
|
}
|
||||||
|
|
||||||
|
Vec3 SliceTool::center() const {
|
||||||
|
double c[3] = {0, 0, 0};
|
||||||
|
if (widget_) widget_->GetCenter(c);
|
||||||
|
return {c[0], c[1], c[2]};
|
||||||
|
}
|
||||||
|
|
||||||
|
void SliceTool::advance(double step) {
|
||||||
|
if (!widget_) return;
|
||||||
|
// 沿法向刚性平移整张切面:origin/point1/point2 同步加 normal*step。只移 origin 会让
|
||||||
|
// 面内两端点不动→平面变形/脱轴(评审 M1)。RestrictPlaneToVolumeOn 负责夹在体内。
|
||||||
|
const Vec3 n = normal();
|
||||||
|
const double d[3] = {n[0] * step, n[1] * step, n[2] * step};
|
||||||
|
double o[3], p1[3], p2[3];
|
||||||
|
widget_->GetOrigin(o);
|
||||||
|
widget_->GetPoint1(p1);
|
||||||
|
widget_->GetPoint2(p2);
|
||||||
|
for (int i = 0; i < 3; ++i) {
|
||||||
|
o[i] += d[i];
|
||||||
|
p1[i] += d[i];
|
||||||
|
p2[i] += d[i];
|
||||||
|
}
|
||||||
|
widget_->SetOrigin(o);
|
||||||
|
widget_->SetPoint1(p1);
|
||||||
|
widget_->SetPoint2(p2);
|
||||||
|
widget_->UpdatePlacement();
|
||||||
|
}
|
||||||
|
|
||||||
|
double SliceTool::distanceToPlane(const Vec3& p) const {
|
||||||
|
const Vec3 c = center();
|
||||||
|
const Vec3 n = normal();
|
||||||
|
return std::abs(dot({p[0] - c[0], p[1] - c[1], p[2] - c[2]}, n));
|
||||||
|
}
|
||||||
|
|
||||||
|
void SliceTool::setSelected(bool sel) {
|
||||||
|
if (!widget_) return;
|
||||||
|
// 切片边框 = widget 的 PlaneProperty:选中→亮黄粗线,未选中→暗灰细线。
|
||||||
|
if (auto* prop = widget_->GetPlaneProperty()) {
|
||||||
|
if (sel) {
|
||||||
|
prop->SetColor(0.0, 0.95, 1.0); // 亮青:与未选的暗灰强对比
|
||||||
|
prop->SetLineWidth(3.5);
|
||||||
|
} else {
|
||||||
|
prop->SetColor(0.35, 0.35, 0.4); // 暗灰
|
||||||
|
prop->SetLineWidth(1.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void SliceTool::close() {
|
||||||
|
if (!widget_) return;
|
||||||
|
onInteract = nullptr; // 先断业务回调,避免 Off 期间触发到上层
|
||||||
|
if (interactObserver_) {
|
||||||
|
widget_->RemoveObserver(interactObserver_);
|
||||||
|
interactObserver_ = nullptr;
|
||||||
|
}
|
||||||
|
widget_->Off();
|
||||||
|
widget_->SetInteractor(nullptr); // 解除观察者,防悬挂崩溃
|
||||||
|
widget_ = nullptr; // 置空 → 二次 close()/析构真正幂等(不再 Off 已解绑 widget)
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace geopro::render::interact
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
#pragma once
|
||||||
|
#include <array>
|
||||||
|
#include <functional>
|
||||||
|
|
||||||
|
#include <vtkSmartPointer.h>
|
||||||
|
|
||||||
|
#include "interact/SlicePlaneMath.hpp"
|
||||||
|
#include "model/ColorScale.hpp"
|
||||||
|
|
||||||
|
class vtkImageData;
|
||||||
|
class vtkImagePlaneWidget;
|
||||||
|
class vtkCallbackCommand;
|
||||||
|
class vtkRenderWindowInteractor;
|
||||||
|
class vtkTrivialProducer;
|
||||||
|
|
||||||
|
namespace geopro::render::interact {
|
||||||
|
|
||||||
|
// 单个切片工具:封装 vtkImagePlaneWidget。
|
||||||
|
// 内部对体素 vtkImageData 做 reslice + 纹理着色(spec §9.1 钉死 reslice 路线,非 cutter)。
|
||||||
|
// 轴向(UpDown/FrontBack/LeftRight):SetPlaneOrientationToX/Y/Z,角度固定。
|
||||||
|
// 任意(Oblique):设初始 45° 法向,允许旋转。
|
||||||
|
// 套上调用方提供的色阶 LUT(ColorLutBuilder)。
|
||||||
|
//
|
||||||
|
// 生命周期:构造即 SetInteractor + On()(须传活的 interactor)。
|
||||||
|
// 析构(或 close())时 Off(),由 vtkSmartPointer 释放,避免悬挂观察者崩溃。
|
||||||
|
// 仅三维视图使用;切到二维由 InteractionManager 统一 close。
|
||||||
|
class SliceTool {
|
||||||
|
public:
|
||||||
|
// image:体素管线产物(含 VE 烤入的 origin/spacing)。interactor:QVTK 的活 interactor。
|
||||||
|
// axis:切面方向。vmin/vmax:色阶区间。
|
||||||
|
SliceTool(vtkImageData* image, vtkRenderWindowInteractor* interactor, SliceAxis axis,
|
||||||
|
const geopro::core::ColorScale& cs, double vmin, double vmax);
|
||||||
|
~SliceTool();
|
||||||
|
|
||||||
|
SliceTool(const SliceTool&) = delete;
|
||||||
|
SliceTool& operator=(const SliceTool&) = delete;
|
||||||
|
SliceTool(SliceTool&&) = delete; // 持 VTK widget 观察者,禁移动(仅经 unique_ptr 间接持有)
|
||||||
|
SliceTool& operator=(SliceTool&&) = delete;
|
||||||
|
|
||||||
|
SliceAxis axis() const { return axis_; }
|
||||||
|
|
||||||
|
// 当前切面法向(世界系单位向量)。
|
||||||
|
Vec3 normal() const;
|
||||||
|
// 当前切面中心(origin)。
|
||||||
|
Vec3 center() const;
|
||||||
|
|
||||||
|
// 沿法向推进切面(滚轮,D46):origin += normal*step,夹在 image 包围盒内。
|
||||||
|
void advance(double step);
|
||||||
|
|
||||||
|
// 选中视觉反馈:选中→高亮边框(亮黄+粗线),未选中→暗淡细线。
|
||||||
|
void setSelected(bool sel);
|
||||||
|
|
||||||
|
// 用户开始操作本切片(拖动/点击切面)时回调 → 上层据此把本切片设为选中。
|
||||||
|
// 因 widget 开启交互后独占切面鼠标事件,选中靠监听 widget 交互而非拾取。
|
||||||
|
std::function<void()> onInteract;
|
||||||
|
|
||||||
|
// 世界点到本切面(无限平面)的垂直距离绝对值。供 picker 命中判定"点在哪张切片上"。
|
||||||
|
double distanceToPlane(const Vec3& worldPoint) const;
|
||||||
|
|
||||||
|
// 关闭:Off() 并解除 interactor 绑定(幂等)。
|
||||||
|
void close();
|
||||||
|
|
||||||
|
private:
|
||||||
|
SliceAxis axis_;
|
||||||
|
vtkImageData* image_; // 非拥有;生命周期由调用方(VtkSceneView 的 currentVolumeImage_)保证
|
||||||
|
// 把已存在的 image 接入 widget 的 producer:须随 SliceTool 保活(否则构造后析构→管线断裂崩溃,评审 H1)。
|
||||||
|
vtkSmartPointer<vtkTrivialProducer> producer_;
|
||||||
|
vtkSmartPointer<vtkImagePlaneWidget> widget_;
|
||||||
|
vtkSmartPointer<vtkCallbackCommand> interactObserver_; // 监听 widget StartInteractionEvent → onInteract
|
||||||
|
|
||||||
|
std::array<double, 6> imageBounds() const;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace geopro::render::interact
|
||||||
|
|
@ -77,7 +77,7 @@ endif()
|
||||||
|
|
||||||
# render 层:ColorLutBuilder(core ColorScale -> vtkLookupTable)。
|
# render 层:ColorLutBuilder(core ColorScale -> vtkLookupTable)。
|
||||||
# 需 vtkLookupTable(VTK::CommonCore);geopro_render 已 PUBLIC 传递其余 VTK 组件。
|
# 需 vtkLookupTable(VTK::CommonCore);geopro_render 已 PUBLIC 传递其余 VTK 组件。
|
||||||
find_package(VTK REQUIRED COMPONENTS CommonCore CommonDataModel RenderingCore)
|
find_package(VTK REQUIRED COMPONENTS CommonCore CommonDataModel RenderingCore RenderingAnnotation FiltersSources)
|
||||||
# Scene:addActor/addViewProp 计数 + clear 清空(vtkVolume 经 addViewProp 进场)。
|
# Scene:addActor/addViewProp 计数 + clear 清空(vtkVolume 经 addViewProp 进场)。
|
||||||
target_sources(geopro_tests PRIVATE render/test_scene.cpp)
|
target_sources(geopro_tests PRIVATE render/test_scene.cpp)
|
||||||
target_sources(geopro_tests PRIVATE render/test_color_lut.cpp)
|
target_sources(geopro_tests PRIVATE render/test_color_lut.cpp)
|
||||||
|
|
@ -96,6 +96,14 @@ target_sources(geopro_tests PRIVATE render/test_anomaly.cpp)
|
||||||
target_sources(geopro_tests PRIVATE render/test_electrode.cpp)
|
target_sources(geopro_tests PRIVATE render/test_electrode.cpp)
|
||||||
# Terrain:buildTerrain(GDAL 读 dem/image + 重投影 → warp 面+纹理) 非空/缺文件安全(需 PROJ_DATA)。
|
# Terrain:buildTerrain(GDAL 读 dem/image + 重投影 → warp 面+纹理) 非空/缺文件安全(需 PROJ_DATA)。
|
||||||
target_sources(geopro_tests PRIVATE render/test_terrain.cpp)
|
target_sources(geopro_tests PRIVATE render/test_terrain.cpp)
|
||||||
|
# CameraPreset(P2):6 向快捷视图 position/focalPoint/viewUp 方向 + zoomBy 距离/parallelScale。
|
||||||
|
target_sources(geopro_tests PRIVATE render/test_camera_preset.cpp)
|
||||||
|
# AxesActor(P2):buildAxes(bounds+unit/mode→vtkCubeAxesActor) 单位换算(英尺/经纬度)/不显示返回空。
|
||||||
|
target_sources(geopro_tests PRIVATE render/test_axes.cpp)
|
||||||
|
# TileMath(P5):天地图底图 Web Mercator 瓦片坐标数学(经纬↔z/x/y、瓦片地理边界)——纯函数。
|
||||||
|
target_sources(geopro_tests PRIVATE render/test_tile_math.cpp)
|
||||||
|
# SlicePlaneMath(P3):切面法向/滚轮平移+夹限/双击正视相机(含竖直兜底)/滚轮步长/最近切片——纯几何。
|
||||||
|
target_sources(geopro_tests PRIVATE render/test_slice_plane_math.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})
|
||||||
|
|
||||||
|
|
@ -116,6 +124,11 @@ target_sources(geopro_tests PRIVATE
|
||||||
)
|
)
|
||||||
# 散点 hover 文本格式(inline 纯函数,无需链 qwt/实例化 ScatterHoverTip)。
|
# 散点 hover 文本格式(inline 纯函数,无需链 qwt/实例化 ScatterHoverTip)。
|
||||||
target_sources(geopro_tests PRIVATE app/test_scatter_hover.cpp)
|
target_sources(geopro_tests PRIVATE app/test_scatter_hover.cpp)
|
||||||
|
# 维度过滤纯函数(splitByDimension: ddCode -> 三维/二维/分析三栏,无 Qt/VTK 依赖)。
|
||||||
|
target_sources(geopro_tests PRIVATE
|
||||||
|
app/test_dataset_dimension.cpp
|
||||||
|
${CMAKE_SOURCE_DIR}/src/app/DatasetDimension.cpp
|
||||||
|
)
|
||||||
|
|
||||||
# controller 层:DatasetDetailController 编排集成测试(QSignalSpy 验证 datasetOpened/tabReady/loadFailed)。
|
# controller 层:DatasetDetailController 编排集成测试(QSignalSpy 验证 datasetOpened/tabReady/loadFailed)。
|
||||||
find_package(Qt6 COMPONENTS Test REQUIRED)
|
find_package(Qt6 COMPONENTS Test REQUIRED)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
#include <gtest/gtest.h>
|
||||||
|
#include "DatasetDimension.hpp"
|
||||||
|
#include "repo/RepoTypes.hpp"
|
||||||
|
|
||||||
|
using geopro::data::DsRow;
|
||||||
|
using geopro::app::splitByDimension;
|
||||||
|
using geopro::app::DimBuckets;
|
||||||
|
|
||||||
|
static DsRow row(const char* id, const char* ddCode) {
|
||||||
|
DsRow r; r.id = id; r.ddCode = ddCode; return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(DatasetDimension, SplitsByDdCode) {
|
||||||
|
std::vector<DsRow> in{
|
||||||
|
row("a", "dd_section"), // 3D
|
||||||
|
row("b", "dd_voxel"), // 3D
|
||||||
|
row("c", "dd_trajectory_data"), // 2D
|
||||||
|
row("d", "dd_slice"), // Analysis
|
||||||
|
row("e", "dd_unknownxyz"), // Other -> not in any bucket
|
||||||
|
};
|
||||||
|
DimBuckets b = splitByDimension(in);
|
||||||
|
ASSERT_EQ(b.dim3D.size(), 2u);
|
||||||
|
EXPECT_EQ(b.dim3D[0].id, "a");
|
||||||
|
EXPECT_EQ(b.dim3D[1].id, "b");
|
||||||
|
ASSERT_EQ(b.dim2D.size(), 1u);
|
||||||
|
EXPECT_EQ(b.dim2D[0].id, "c");
|
||||||
|
ASSERT_EQ(b.analysis.size(), 1u);
|
||||||
|
EXPECT_EQ(b.analysis[0].id, "d");
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(DatasetDimension, EmptyInput) {
|
||||||
|
DimBuckets b = splitByDimension({});
|
||||||
|
EXPECT_TRUE(b.dim3D.empty());
|
||||||
|
EXPECT_TRUE(b.dim2D.empty());
|
||||||
|
EXPECT_TRUE(b.analysis.empty());
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
#include <gtest/gtest.h>
|
#include <gtest/gtest.h>
|
||||||
|
|
||||||
#include <functional>
|
#include <functional>
|
||||||
|
#include <map>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <utility>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
#include "I3dSceneView.hpp"
|
#include "I3dSceneView.hpp"
|
||||||
|
|
@ -27,17 +29,54 @@ struct FakeView : I3dSceneView {
|
||||||
bool lastIs2D = false;
|
bool lastIs2D = false;
|
||||||
double ve = -1.0;
|
double ve = -1.0;
|
||||||
|
|
||||||
// clear 模型化"移除所有图元":图元计数归零(反映当前场景状态),clears 累加。
|
// P2 记录。
|
||||||
|
int setAxesCalls = 0;
|
||||||
|
AxesMode lastAxesMode = AxesMode::None;
|
||||||
|
AxesUnit lastAxesUnit = AxesUnit::None;
|
||||||
|
int lastAxesFont = -1;
|
||||||
|
int cameraViewCalls = 0;
|
||||||
|
ViewDir lastViewDir = ViewDir::Front;
|
||||||
|
int zoomCalls = 0;
|
||||||
|
double lastZoomFactor = 0.0;
|
||||||
|
int fitCalls = 0;
|
||||||
|
|
||||||
|
// 按 ds 记录图元数,使 removeDataset 能按量回退(反映增量场景状态)。
|
||||||
|
std::map<std::string, std::pair<int, int>> perDs; // dsId → (curtains, volumes)
|
||||||
|
|
||||||
|
// clear 模型化"移除所有数据图元":计数归零,clears 累加。
|
||||||
void clear() override {
|
void clear() override {
|
||||||
++clears;
|
++clears;
|
||||||
surveyLines = curtains = volumes = terrains = 0;
|
surveyLines = curtains = volumes = terrains = 0;
|
||||||
|
perDs.clear();
|
||||||
}
|
}
|
||||||
void setVerticalExaggeration(double v) override { ve = v; }
|
void setVerticalExaggeration(double v) override { ve = v; }
|
||||||
void addSurveyLine(const core::Grid&) override { ++surveyLines; }
|
void addSurveyLine(const core::Grid&) override { ++surveyLines; }
|
||||||
void addCurtain(const core::Grid&, const core::ColorScale&) override { ++curtains; }
|
void addCurtain(const std::string& dsId, const core::Grid&, const core::ColorScale&) override {
|
||||||
void addVolume(const data::VolumeGrid&, const core::ColorScale&) override { ++volumes; }
|
++curtains;
|
||||||
|
++perDs[dsId].first;
|
||||||
|
}
|
||||||
|
void addVolume(const std::string& dsId, const data::VolumeGrid&,
|
||||||
|
const core::ColorScale&) override {
|
||||||
|
++volumes;
|
||||||
|
++perDs[dsId].second;
|
||||||
|
}
|
||||||
void addTerrain(const data::TerrainPaths&) override { ++terrains; }
|
void addTerrain(const data::TerrainPaths&) override { ++terrains; }
|
||||||
|
void removeDataset(const std::string& dsId) override {
|
||||||
|
auto it = perDs.find(dsId);
|
||||||
|
if (it == perDs.end()) return;
|
||||||
|
curtains -= it->second.first;
|
||||||
|
volumes -= it->second.second;
|
||||||
|
perDs.erase(it);
|
||||||
|
}
|
||||||
|
void setAxes(AxesMode mode, AxesUnit unit, int fontSize) override {
|
||||||
|
++setAxesCalls;
|
||||||
|
lastAxesMode = mode; lastAxesUnit = unit; lastAxesFont = fontSize;
|
||||||
|
}
|
||||||
|
void applyCameraView(ViewDir dir) override { ++cameraViewCalls; lastViewDir = dir; }
|
||||||
|
void zoom(double factor) override { ++zoomCalls; lastZoomFactor = factor; }
|
||||||
|
void fitView() override { ++fitCalls; }
|
||||||
void render(bool is2D) override { ++renders; lastIs2D = is2D; }
|
void render(bool is2D) override { ++renders; lastIs2D = is2D; }
|
||||||
|
void renderIncremental() override { ++renders; }
|
||||||
|
|
||||||
int props() const { return surveyLines + curtains + volumes + terrains; }
|
int props() const { return surveyLines + curtains + volumes + terrains; }
|
||||||
};
|
};
|
||||||
|
|
@ -75,9 +114,39 @@ struct FakeSceneRepo : data::I3dSceneRepository {
|
||||||
g.vmin = 0.0; g.vmax = 1.0;
|
g.vmin = 0.0; g.vmax = 1.0;
|
||||||
onOk(std::move(g)); // 同步回调(异步壳)
|
onOk(std::move(g)); // 同步回调(异步壳)
|
||||||
}
|
}
|
||||||
|
void loadSection(const std::string&, std::function<void(data::SectionData)> onOk,
|
||||||
|
OnError) override {
|
||||||
|
data::SectionData s;
|
||||||
|
s.grid = core::Grid(2, 2);
|
||||||
|
s.grid.lat = {22.0, 22.001};
|
||||||
|
s.grid.lon = {114.0, 114.001};
|
||||||
|
s.scale.addStop(0.0, core::Rgba{0, 0, 255, 255});
|
||||||
|
s.scale.addStop(1.0, core::Rgba{255, 0, 0, 255});
|
||||||
|
onOk(std::move(s)); // 同步回调(异步壳)
|
||||||
|
}
|
||||||
void loadTerrainPaths(std::function<void(data::TerrainPaths)> onOk, OnError) override {
|
void loadTerrainPaths(std::function<void(data::TerrainPaths)> onOk, OnError) override {
|
||||||
onOk(data::TerrainPaths{"dem.tif", "image.tif"});
|
onOk(data::TerrainPaths{"dem.tif", "image.tif"});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 切片/异常/任务 stub(满足纯虚,行为同 LocalSample3dRepository)
|
||||||
|
void createSlice(const SliceSpec&, const std::string&,
|
||||||
|
std::function<void(std::string)> onOk, OnError) override { onOk("slice-0"); }
|
||||||
|
void saveSlice(const std::string&, const SliceSpec&,
|
||||||
|
std::function<void()> onOk, OnError) override { onOk(); }
|
||||||
|
void deleteSlice(const std::string&,
|
||||||
|
std::function<void()> onOk, OnError) override { onOk(); }
|
||||||
|
void loadAnomalyTree(const std::string&,
|
||||||
|
std::function<void(AnomalyTree)> onOk, OnError) override { onOk({}); }
|
||||||
|
void saveAnomaly(const core::Anomaly&, const std::string&,
|
||||||
|
std::function<void(std::string)> onOk, OnError) override { onOk("anomaly-0"); }
|
||||||
|
void deleteAnomaly(const std::string&,
|
||||||
|
std::function<void()> onOk, OnError) override { onOk(); }
|
||||||
|
void deleteAnomalyGroup(const std::string&,
|
||||||
|
std::function<void()> onOk, OnError) override { onOk(); }
|
||||||
|
void loadTaskRecords(const std::string&,
|
||||||
|
std::function<void(std::vector<TaskRecord>)> onOk, OnError) override { onOk({}); }
|
||||||
|
void loadUsableTasks(const std::string&,
|
||||||
|
std::function<void(std::vector<UsableTask>)> onOk, OnError) override { onOk({}); }
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
@ -132,19 +201,33 @@ TEST(VtkSceneController, View3DWithTerrainAddsTerrain) {
|
||||||
EXPECT_EQ(view.curtains, 1);
|
EXPECT_EQ(view.curtains, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 取消勾选 → clear 后无任何图元。
|
// 取消勾选 → 增量移除该 ds 图元(不整场 clear,3D 增量路径)。
|
||||||
TEST(VtkSceneController, UncheckAllClearsScene) {
|
TEST(VtkSceneController, UncheckRemovesDatasetIncrementally) {
|
||||||
FakeDsRepo ds; FakeSceneRepo sc; FakeView view;
|
FakeDsRepo ds; FakeSceneRepo sc; FakeView view;
|
||||||
VtkSceneController c(ds, sc, view);
|
VtkSceneController c(ds, sc, view);
|
||||||
c.setViewMode(ViewMode::View3D);
|
c.setViewMode(ViewMode::View3D);
|
||||||
c.setCheckedDatasets({"ds1"});
|
c.setCheckedDatasets({"ds1"});
|
||||||
ASSERT_EQ(view.curtains, 1);
|
ASSERT_EQ(view.curtains, 1);
|
||||||
|
const int clearsAfterCheck = view.clears;
|
||||||
|
|
||||||
c.setCheckedDatasets({}); // 取消全部勾选
|
c.setCheckedDatasets({}); // 取消全部勾选 → 增量移除 ds1
|
||||||
EXPECT_EQ(view.curtains, 0);
|
EXPECT_EQ(view.curtains, 0);
|
||||||
EXPECT_EQ(view.volumes, 0);
|
EXPECT_EQ(view.volumes, 0);
|
||||||
// 最后一次重建仍调用 clear。
|
EXPECT_EQ(view.clears, clearsAfterCheck); // 增量取消不触发整场 clear
|
||||||
EXPECT_GE(view.clears, 2);
|
}
|
||||||
|
|
||||||
|
// 增量追加:已勾选 ds1 时再勾 ds2,只新增 ds2,不移除/重建 ds1。
|
||||||
|
TEST(VtkSceneController, IncrementalAddKeepsExisting) {
|
||||||
|
FakeDsRepo ds; FakeSceneRepo sc; FakeView view;
|
||||||
|
VtkSceneController c(ds, sc, view);
|
||||||
|
c.setViewMode(ViewMode::View3D);
|
||||||
|
c.setCheckedDatasets({"ds1"});
|
||||||
|
const int clearsAfterFirst = view.clears;
|
||||||
|
ASSERT_EQ(view.curtains, 1);
|
||||||
|
|
||||||
|
c.setCheckedDatasets({"ds1", "ds2"}); // 增量加 ds2
|
||||||
|
EXPECT_EQ(view.curtains, 2);
|
||||||
|
EXPECT_EQ(view.clears, clearsAfterFirst); // 不重建 → 无新 clear
|
||||||
}
|
}
|
||||||
|
|
||||||
// 纵向比例传到视图。
|
// 纵向比例传到视图。
|
||||||
|
|
@ -165,3 +248,67 @@ TEST(VtkSceneController, MultipleDatasetsAddMultipleCurtains) {
|
||||||
c.setCheckedDatasets({"ds1", "ds2", "ds3"});
|
c.setCheckedDatasets({"ds1", "ds2", "ds3"});
|
||||||
EXPECT_EQ(view.curtains, 3);
|
EXPECT_EQ(view.curtains, 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── P2:坐标轴 / 快捷视图 / Zoom 编排 ──
|
||||||
|
|
||||||
|
// 每次重建都把当前坐标轴设置下发给视图(clear 后须重设)。
|
||||||
|
TEST(VtkSceneController, RebuildForwardsAxesSettings) {
|
||||||
|
FakeDsRepo ds; FakeSceneRepo sc; FakeView view;
|
||||||
|
VtkSceneController c(ds, sc, view);
|
||||||
|
c.setViewMode(ViewMode::View3D); // 触发一次重建
|
||||||
|
EXPECT_GE(view.setAxesCalls, 1);
|
||||||
|
// 默认 = 标准 + 米 + 字号 12。
|
||||||
|
EXPECT_EQ(view.lastAxesMode, AxesMode::Standard);
|
||||||
|
EXPECT_EQ(view.lastAxesUnit, AxesUnit::Meter);
|
||||||
|
EXPECT_EQ(view.lastAxesFont, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
// setAxesMode 改模式并重建下发。
|
||||||
|
TEST(VtkSceneController, SetAxesModeForwardedOnRebuild) {
|
||||||
|
FakeDsRepo ds; FakeSceneRepo sc; FakeView view;
|
||||||
|
VtkSceneController c(ds, sc, view);
|
||||||
|
c.setViewMode(ViewMode::View3D);
|
||||||
|
c.setAxesMode(AxesMode::None);
|
||||||
|
EXPECT_EQ(view.lastAxesMode, AxesMode::None);
|
||||||
|
const int rebuilds = view.setAxesCalls;
|
||||||
|
c.setAxesMode(AxesMode::Stereo);
|
||||||
|
EXPECT_EQ(view.lastAxesMode, AxesMode::Stereo);
|
||||||
|
EXPECT_GT(view.setAxesCalls, rebuilds); // 又触发一次重建
|
||||||
|
}
|
||||||
|
|
||||||
|
// setAxesUnit 改单位并重建下发。
|
||||||
|
TEST(VtkSceneController, SetAxesUnitForwarded) {
|
||||||
|
FakeDsRepo ds; FakeSceneRepo sc; FakeView view;
|
||||||
|
VtkSceneController c(ds, sc, view);
|
||||||
|
c.setAxesUnit(AxesUnit::Feet);
|
||||||
|
EXPECT_EQ(view.lastAxesUnit, AxesUnit::Feet);
|
||||||
|
c.setAxesUnit(AxesUnit::LatLon);
|
||||||
|
EXPECT_EQ(view.lastAxesUnit, AxesUnit::LatLon);
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyView 转发方向,不重建场景(不增 clear)。
|
||||||
|
TEST(VtkSceneController, ApplyViewForwardsDirectionWithoutRebuild) {
|
||||||
|
FakeDsRepo ds; FakeSceneRepo sc; FakeView view;
|
||||||
|
VtkSceneController c(ds, sc, view);
|
||||||
|
c.setViewMode(ViewMode::View3D);
|
||||||
|
const int clearsBefore = view.clears;
|
||||||
|
c.applyView(ViewDir::Top);
|
||||||
|
EXPECT_EQ(view.cameraViewCalls, 1);
|
||||||
|
EXPECT_EQ(view.lastViewDir, ViewDir::Top);
|
||||||
|
EXPECT_EQ(view.clears, clearsBefore); // 不重建
|
||||||
|
c.applyView(ViewDir::Left);
|
||||||
|
EXPECT_EQ(view.lastViewDir, ViewDir::Left);
|
||||||
|
}
|
||||||
|
|
||||||
|
// zoomIn/zoomOut 用 1.2 / (1/1.2);fit 调 fitView。
|
||||||
|
TEST(VtkSceneController, ZoomAndFitForwarded) {
|
||||||
|
FakeDsRepo ds; FakeSceneRepo sc; FakeView view;
|
||||||
|
VtkSceneController c(ds, sc, view);
|
||||||
|
c.zoomIn();
|
||||||
|
EXPECT_EQ(view.zoomCalls, 1);
|
||||||
|
EXPECT_DOUBLE_EQ(view.lastZoomFactor, 1.2);
|
||||||
|
c.zoomOut();
|
||||||
|
EXPECT_DOUBLE_EQ(view.lastZoomFactor, 1.0 / 1.2);
|
||||||
|
c.fit();
|
||||||
|
EXPECT_EQ(view.fitCalls, 1);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,3 +37,25 @@ TEST(GeoFrame, NorthwardLatitudeGivesPositiveY) {
|
||||||
EXPECT_NEAR(p.y, expected, expected * 0.05);
|
EXPECT_NEAR(p.y, expected, expected * 0.05);
|
||||||
EXPECT_NEAR(p.x, 0.0, 1e-9);
|
EXPECT_NEAR(p.x, 0.0, 1e-9);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// toLatLon 是 toLocal 的反算:toLocal∘toLatLon 与 toLatLon∘toLocal 都恒等。
|
||||||
|
TEST(GeoFrame, ToLatLonRoundTrips) {
|
||||||
|
GeoLocalFrame f(22.5, 114.16);
|
||||||
|
// 经纬度 → 局部 → 经纬度 恒等。
|
||||||
|
auto p = f.toLocal(22.53, 114.19);
|
||||||
|
auto ll = f.toLatLon(p.x, p.y);
|
||||||
|
EXPECT_NEAR(ll.lat, 22.53, 1e-9);
|
||||||
|
EXPECT_NEAR(ll.lon, 114.19, 1e-9);
|
||||||
|
// 局部 → 经纬度 → 局部 恒等。
|
||||||
|
auto q = f.toLocal(ll.lat, ll.lon);
|
||||||
|
EXPECT_NEAR(q.x, p.x, 1e-6);
|
||||||
|
EXPECT_NEAR(q.y, p.y, 1e-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 原点局部 (0,0) 反算回 (lat0,lon0)。
|
||||||
|
TEST(GeoFrame, ToLatLonOriginMapsToLat0Lon0) {
|
||||||
|
GeoLocalFrame f(22.5, 114.16);
|
||||||
|
auto ll = f.toLatLon(0.0, 0.0);
|
||||||
|
EXPECT_NEAR(ll.lat, 22.5, 1e-12);
|
||||||
|
EXPECT_NEAR(ll.lon, 114.16, 1e-12);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,105 @@
|
||||||
|
#include <gtest/gtest.h>
|
||||||
|
|
||||||
|
#include <vtkCubeAxesActor.h>
|
||||||
|
|
||||||
|
#include "actors/AxesActor.hpp"
|
||||||
|
#include "geo/GeoLocalFrame.hpp"
|
||||||
|
|
||||||
|
using namespace geopro::render;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
constexpr double kFeetPerMeter = 3.28084;
|
||||||
|
}
|
||||||
|
|
||||||
|
// unitScaleFactor:米=1,英尺=3.28084。
|
||||||
|
TEST(AxesActor, UnitScaleFactor) {
|
||||||
|
EXPECT_DOUBLE_EQ(unitScaleFactor(AxesUnit::Meter), 1.0);
|
||||||
|
EXPECT_DOUBLE_EQ(unitScaleFactor(AxesUnit::Feet), kFeetPerMeter);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 不显示模式 → 返回 nullptr(不入场景)。
|
||||||
|
TEST(AxesActor, NoneModeReturnsNull) {
|
||||||
|
double b[6] = {0, 10, 0, 20, -5, 0};
|
||||||
|
AxesOptions opts;
|
||||||
|
opts.mode = AxesMode::None;
|
||||||
|
EXPECT_EQ(buildAxes(b, opts, nullptr), nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 退化包围盒(全 0)→ nullptr。
|
||||||
|
TEST(AxesActor, DegenerateBoundsReturnsNull) {
|
||||||
|
double zero[6] = {0, 0, 0, 0, 0, 0};
|
||||||
|
AxesOptions opts;
|
||||||
|
opts.mode = AxesMode::Standard;
|
||||||
|
EXPECT_EQ(buildAxes(zero, opts, nullptr), nullptr);
|
||||||
|
double inverted[6] = {10, 0, 0, 20, -5, 0}; // xmin>xmax
|
||||||
|
EXPECT_EQ(buildAxes(inverted, opts, nullptr), nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标准模式 + 米:构建非空,几何 bounds 保留,X 显示范围 = 原值。
|
||||||
|
TEST(AxesActor, StandardMeterKeepsRange) {
|
||||||
|
double b[6] = {0, 100, 0, 200, -50, 0};
|
||||||
|
AxesOptions opts;
|
||||||
|
opts.mode = AxesMode::Standard;
|
||||||
|
opts.unit = AxesUnit::Meter;
|
||||||
|
auto ax = buildAxes(b, opts, nullptr);
|
||||||
|
ASSERT_NE(ax, nullptr);
|
||||||
|
double xr[2];
|
||||||
|
ax->GetXAxisRange(xr);
|
||||||
|
EXPECT_NEAR(xr[0], 0.0, 1e-9);
|
||||||
|
EXPECT_NEAR(xr[1], 100.0, 1e-9);
|
||||||
|
// 几何 bounds 不变。
|
||||||
|
double gb[6];
|
||||||
|
ax->GetBounds(gb);
|
||||||
|
EXPECT_NEAR(gb[1], 100.0, 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 英尺:显示范围 = 米值 × 3.28084(几何 bounds 仍为米)。
|
||||||
|
TEST(AxesActor, FeetScalesDisplayRange) {
|
||||||
|
double b[6] = {0, 100, 0, 200, -50, 0};
|
||||||
|
AxesOptions opts;
|
||||||
|
opts.mode = AxesMode::Standard;
|
||||||
|
opts.unit = AxesUnit::Feet;
|
||||||
|
auto ax = buildAxes(b, opts, nullptr);
|
||||||
|
ASSERT_NE(ax, nullptr);
|
||||||
|
double xr[2];
|
||||||
|
ax->GetXAxisRange(xr);
|
||||||
|
EXPECT_NEAR(xr[1], 100.0 * kFeetPerMeter, 1e-6);
|
||||||
|
// 几何 bounds 仍是米,不被换算。
|
||||||
|
double gb[6];
|
||||||
|
ax->GetBounds(gb);
|
||||||
|
EXPECT_NEAR(gb[1], 100.0, 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 经纬度:X 显示范围反算为经度(在 lon0 附近、随 +x 增大)。
|
||||||
|
TEST(AxesActor, LatLonUsesFrameReverse) {
|
||||||
|
geopro::core::GeoLocalFrame frame(22.5, 114.16);
|
||||||
|
double b[6] = {0, 1000, 0, 1000, -50, 0}; // 1km 范围
|
||||||
|
AxesOptions opts;
|
||||||
|
opts.mode = AxesMode::Standard;
|
||||||
|
opts.unit = AxesUnit::LatLon;
|
||||||
|
opts.frame = &frame;
|
||||||
|
auto ax = buildAxes(b, opts, nullptr);
|
||||||
|
ASSERT_NE(ax, nullptr);
|
||||||
|
double xr[2], yr[2];
|
||||||
|
ax->GetXAxisRange(xr);
|
||||||
|
ax->GetYAxisRange(yr);
|
||||||
|
// x=0 → lon0;x=1000m → 略大于 lon0。
|
||||||
|
EXPECT_NEAR(xr[0], 114.16, 1e-9);
|
||||||
|
EXPECT_GT(xr[1], 114.16);
|
||||||
|
EXPECT_NEAR(yr[0], 22.5, 1e-9);
|
||||||
|
EXPECT_GT(yr[1], 22.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 经纬度但无 frame → 退化为米(不反算,显示范围 = 原值)。
|
||||||
|
TEST(AxesActor, LatLonWithoutFrameFallsBackToMeter) {
|
||||||
|
double b[6] = {0, 100, 0, 200, -50, 0};
|
||||||
|
AxesOptions opts;
|
||||||
|
opts.mode = AxesMode::Standard;
|
||||||
|
opts.unit = AxesUnit::LatLon;
|
||||||
|
opts.frame = nullptr;
|
||||||
|
auto ax = buildAxes(b, opts, nullptr);
|
||||||
|
ASSERT_NE(ax, nullptr);
|
||||||
|
double xr[2];
|
||||||
|
ax->GetXAxisRange(xr);
|
||||||
|
EXPECT_NEAR(xr[1], 100.0, 1e-9); // 米回退
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,152 @@
|
||||||
|
#include <gtest/gtest.h>
|
||||||
|
|
||||||
|
#include <vtkActor.h>
|
||||||
|
#include <vtkCamera.h>
|
||||||
|
#include <vtkConeSource.h>
|
||||||
|
#include <vtkPolyDataMapper.h>
|
||||||
|
#include <vtkRenderer.h>
|
||||||
|
#include <vtkSmartPointer.h>
|
||||||
|
|
||||||
|
#include "CameraPreset.hpp"
|
||||||
|
|
||||||
|
using namespace geopro::render;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// 造一个带包围盒的 renderer(一个 cone actor),使 ResetCamera 有内容可重定位。
|
||||||
|
vtkSmartPointer<vtkRenderer> rendererWithContent() {
|
||||||
|
auto cone = vtkSmartPointer<vtkConeSource>::New();
|
||||||
|
cone->SetCenter(0, 0, 0);
|
||||||
|
cone->SetHeight(2.0);
|
||||||
|
cone->SetRadius(1.0);
|
||||||
|
auto mapper = vtkSmartPointer<vtkPolyDataMapper>::New();
|
||||||
|
mapper->SetInputConnection(cone->GetOutputPort());
|
||||||
|
auto actor = vtkSmartPointer<vtkActor>::New();
|
||||||
|
actor->SetMapper(mapper);
|
||||||
|
auto r = vtkSmartPointer<vtkRenderer>::New();
|
||||||
|
r->AddActor(actor);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 相机的视线方向单位向量 = focalPoint - position(归一化)。
|
||||||
|
void viewDir(vtkRenderer* r, double out[3]) {
|
||||||
|
auto* c = r->GetActiveCamera();
|
||||||
|
double p[3], f[3];
|
||||||
|
c->GetPosition(p);
|
||||||
|
c->GetFocalPoint(f);
|
||||||
|
double d[3] = {f[0] - p[0], f[1] - p[1], f[2] - p[2]};
|
||||||
|
double n = std::sqrt(d[0] * d[0] + d[1] * d[1] + d[2] * d[2]);
|
||||||
|
out[0] = d[0] / n; out[1] = d[1] / n; out[2] = d[2] / n;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
// Top:相机在焦点上方(pos.z>focal.z),视线朝 -Z,viewUp=+Y。
|
||||||
|
TEST(CameraPreset, TopLooksDown) {
|
||||||
|
auto r = rendererWithContent();
|
||||||
|
applyView(r, ViewDir::Top);
|
||||||
|
auto* c = r->GetActiveCamera();
|
||||||
|
double p[3], f[3], up[3];
|
||||||
|
c->GetPosition(p); c->GetFocalPoint(f); c->GetViewUp(up);
|
||||||
|
EXPECT_GT(p[2], f[2]); // 相机在上方
|
||||||
|
double d[3]; viewDir(r, d);
|
||||||
|
EXPECT_NEAR(d[2], -1.0, 1e-6); // 视线向下
|
||||||
|
EXPECT_NEAR(up[1], 1.0, 1e-6); // 北朝上
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bottom:相机在焦点下方,视线朝 +Z。
|
||||||
|
TEST(CameraPreset, BottomLooksUp) {
|
||||||
|
auto r = rendererWithContent();
|
||||||
|
applyView(r, ViewDir::Bottom);
|
||||||
|
auto* c = r->GetActiveCamera();
|
||||||
|
double p[3], f[3];
|
||||||
|
c->GetPosition(p); c->GetFocalPoint(f);
|
||||||
|
EXPECT_LT(p[2], f[2]);
|
||||||
|
double d[3]; viewDir(r, d);
|
||||||
|
EXPECT_NEAR(d[2], 1.0, 1e-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Front:相机在 -Y,视线朝 +Y,viewUp=+Z。
|
||||||
|
TEST(CameraPreset, FrontLooksNorth) {
|
||||||
|
auto r = rendererWithContent();
|
||||||
|
applyView(r, ViewDir::Front);
|
||||||
|
auto* c = r->GetActiveCamera();
|
||||||
|
double p[3], f[3], up[3];
|
||||||
|
c->GetPosition(p); c->GetFocalPoint(f); c->GetViewUp(up);
|
||||||
|
EXPECT_LT(p[1], f[1]);
|
||||||
|
double d[3]; viewDir(r, d);
|
||||||
|
EXPECT_NEAR(d[1], 1.0, 1e-6);
|
||||||
|
EXPECT_NEAR(up[2], 1.0, 1e-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Back:相机在 +Y,视线朝 -Y。
|
||||||
|
TEST(CameraPreset, BackLooksSouth) {
|
||||||
|
auto r = rendererWithContent();
|
||||||
|
applyView(r, ViewDir::Back);
|
||||||
|
double d[3]; viewDir(r, d);
|
||||||
|
EXPECT_NEAR(d[1], -1.0, 1e-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Left:相机在 -X,视线朝 +X。
|
||||||
|
TEST(CameraPreset, LeftLooksEast) {
|
||||||
|
auto r = rendererWithContent();
|
||||||
|
applyView(r, ViewDir::Left);
|
||||||
|
auto* c = r->GetActiveCamera();
|
||||||
|
double p[3], f[3];
|
||||||
|
c->GetPosition(p); c->GetFocalPoint(f);
|
||||||
|
EXPECT_LT(p[0], f[0]);
|
||||||
|
double d[3]; viewDir(r, d);
|
||||||
|
EXPECT_NEAR(d[0], 1.0, 1e-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Right:相机在 +X,视线朝 -X。
|
||||||
|
TEST(CameraPreset, RightLooksWest) {
|
||||||
|
auto r = rendererWithContent();
|
||||||
|
applyView(r, ViewDir::Right);
|
||||||
|
double d[3]; viewDir(r, d);
|
||||||
|
EXPECT_NEAR(d[0], -1.0, 1e-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
// zoomBy(>1) 放大:透视下 vtkCamera::Zoom 收窄视角(ViewAngle 变小→画面放大)。
|
||||||
|
TEST(CameraPreset, ZoomInNarrowsViewAngle) {
|
||||||
|
auto r = rendererWithContent();
|
||||||
|
applyFree3D(r);
|
||||||
|
auto* c = r->GetActiveCamera();
|
||||||
|
const double before = c->GetViewAngle();
|
||||||
|
zoomBy(r, 1.2);
|
||||||
|
EXPECT_LT(c->GetViewAngle(), before);
|
||||||
|
}
|
||||||
|
|
||||||
|
// zoomBy(<1) 缩小:透视下视角变宽(画面缩小)。
|
||||||
|
TEST(CameraPreset, ZoomOutWidensViewAngle) {
|
||||||
|
auto r = rendererWithContent();
|
||||||
|
applyFree3D(r);
|
||||||
|
auto* c = r->GetActiveCamera();
|
||||||
|
const double before = c->GetViewAngle();
|
||||||
|
zoomBy(r, 1.0 / 1.2);
|
||||||
|
EXPECT_GT(c->GetViewAngle(), before);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 正交投影下 zoomBy 改 parallelScale(放大缩小可视范围)。
|
||||||
|
TEST(CameraPreset, ZoomInOrthoReducesParallelScale) {
|
||||||
|
auto r = rendererWithContent();
|
||||||
|
applyView(r, ViewDir::Top); // Top 不改投影模式;显式打开正交
|
||||||
|
auto* c = r->GetActiveCamera();
|
||||||
|
c->ParallelProjectionOn();
|
||||||
|
r->ResetCamera();
|
||||||
|
const double before = c->GetParallelScale();
|
||||||
|
zoomBy(r, 2.0);
|
||||||
|
EXPECT_LT(c->GetParallelScale(), before);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 空指针/非法 factor 安全。
|
||||||
|
TEST(CameraPreset, NullAndInvalidAreSafe) {
|
||||||
|
applyView(nullptr, ViewDir::Top);
|
||||||
|
zoomBy(nullptr, 1.2);
|
||||||
|
fitView(nullptr);
|
||||||
|
auto r = rendererWithContent();
|
||||||
|
const double before = r->GetActiveCamera()->GetDistance();
|
||||||
|
zoomBy(r, 0.0); // 非法 factor 忽略
|
||||||
|
zoomBy(r, -1.0);
|
||||||
|
EXPECT_DOUBLE_EQ(r->GetActiveCamera()->GetDistance(), before);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,131 @@
|
||||||
|
#include <gtest/gtest.h>
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "interact/SlicePlaneMath.hpp"
|
||||||
|
|
||||||
|
using namespace geopro::render::interact;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
void expectVec(const Vec3& a, double x, double y, double z, double eps = 1e-9) {
|
||||||
|
EXPECT_NEAR(a[0], x, eps);
|
||||||
|
EXPECT_NEAR(a[1], y, eps);
|
||||||
|
EXPECT_NEAR(a[2], z, eps);
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
// ── axisNormal:轴向法向(spec F22–F24)+ 任意 45°(F25)──
|
||||||
|
TEST(SlicePlaneMath, AxisNormalUpDownIsZ) { expectVec(axisNormal(SliceAxis::UpDown), 0, 0, 1); }
|
||||||
|
TEST(SlicePlaneMath, AxisNormalFrontBackIsY) { expectVec(axisNormal(SliceAxis::FrontBack), 0, 1, 0); }
|
||||||
|
TEST(SlicePlaneMath, AxisNormalLeftRightIsX) { expectVec(axisNormal(SliceAxis::LeftRight), 1, 0, 0); }
|
||||||
|
TEST(SlicePlaneMath, AxisNormalObliqueIs45) {
|
||||||
|
const auto n = axisNormal(SliceAxis::Oblique);
|
||||||
|
const double s = std::sqrt(0.5);
|
||||||
|
expectVec(n, s, 0, s);
|
||||||
|
EXPECT_NEAR(norm(n), 1.0, 1e-9); // 单位向量
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── boundsCenter ──
|
||||||
|
TEST(SlicePlaneMath, BoundsCenter) {
|
||||||
|
expectVec(boundsCenter({0, 10, -4, 4, 0, 6}), 5, 0, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── advanceOrigin:沿法向平移(滚轮推进,D46)──
|
||||||
|
TEST(SlicePlaneMath, AdvanceAlongZ) {
|
||||||
|
expectVec(advanceOrigin({1, 2, 3}, {0, 0, 1}, 5.0), 1, 2, 8);
|
||||||
|
}
|
||||||
|
TEST(SlicePlaneMath, AdvanceBackward) {
|
||||||
|
expectVec(advanceOrigin({1, 2, 3}, {0, 0, 1}, -2.0), 1, 2, 1);
|
||||||
|
}
|
||||||
|
TEST(SlicePlaneMath, AdvanceNormalizesDirection) {
|
||||||
|
// 非单位法向:先归一化再推进,步长为世界距离。
|
||||||
|
expectVec(advanceOrigin({0, 0, 0}, {0, 0, 5}, 3.0), 0, 0, 3);
|
||||||
|
}
|
||||||
|
TEST(SlicePlaneMath, AdvanceObliqueMovesAlong45) {
|
||||||
|
const auto o = advanceOrigin({0, 0, 0}, {1, 0, 1}, std::sqrt(2.0));
|
||||||
|
expectVec(o, 1, 0, 1); // 沿 45° 推进 √2 → (1,0,1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── clampToBounds:推进出体外被夹回(滚轮限位)──
|
||||||
|
TEST(SlicePlaneMath, ClampInsideUnchanged) {
|
||||||
|
expectVec(clampToBounds({5, 0, 3}, {0, 10, -4, 4, 0, 6}), 5, 0, 3);
|
||||||
|
}
|
||||||
|
TEST(SlicePlaneMath, ClampOutsideHigh) {
|
||||||
|
expectVec(clampToBounds({5, 0, 99}, {0, 10, -4, 4, 0, 6}), 5, 0, 6);
|
||||||
|
}
|
||||||
|
TEST(SlicePlaneMath, ClampOutsideLow) {
|
||||||
|
expectVec(clampToBounds({-5, 0, -1}, {0, 10, -4, 4, 0, 6}), 0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── faceOnCamera:双击正视(E54)──
|
||||||
|
// 法向 +Y:相机退到 focal+Y*dist,视线 = -Y,viewUp = +Z(切面内向上)。
|
||||||
|
TEST(SlicePlaneMath, FaceOnFrontBackNormal) {
|
||||||
|
const auto cam = faceOnCamera({0, 0, 0}, {0, 1, 0}, 10.0);
|
||||||
|
expectVec(cam.position, 0, 10, 0);
|
||||||
|
// viewUp 与法向正交且偏 +Z。
|
||||||
|
EXPECT_NEAR(dot(cam.viewUp, Vec3{0, 1, 0}), 0.0, 1e-9);
|
||||||
|
EXPECT_GT(cam.viewUp[2], 0.5);
|
||||||
|
}
|
||||||
|
// 法向 +X:position=focal+X*dist,viewUp 偏 +Z。
|
||||||
|
TEST(SlicePlaneMath, FaceOnLeftRightNormal) {
|
||||||
|
const auto cam = faceOnCamera({1, 2, 3}, {1, 0, 0}, 5.0);
|
||||||
|
expectVec(cam.position, 6, 2, 3);
|
||||||
|
EXPECT_NEAR(dot(cam.viewUp, Vec3{1, 0, 0}), 0.0, 1e-9);
|
||||||
|
EXPECT_GT(cam.viewUp[2], 0.5);
|
||||||
|
}
|
||||||
|
// 法向竖直 +Z(上下切片):viewUp 不能再取 +Z(与法向共线),兜底取 +Y。
|
||||||
|
TEST(SlicePlaneMath, FaceOnVerticalNormalFallsBackToY) {
|
||||||
|
const auto cam = faceOnCamera({0, 0, 0}, {0, 0, 1}, 8.0);
|
||||||
|
expectVec(cam.position, 0, 0, 8);
|
||||||
|
// viewUp 与法向(+Z)正交(z≈0),且非零。
|
||||||
|
EXPECT_NEAR(cam.viewUp[2], 0.0, 1e-9);
|
||||||
|
EXPECT_NEAR(norm(cam.viewUp), 1.0, 1e-9);
|
||||||
|
}
|
||||||
|
// 法向竖直 -Z 同样兜底。
|
||||||
|
TEST(SlicePlaneMath, FaceOnVerticalDownNormalFallsBack) {
|
||||||
|
const auto cam = faceOnCamera({0, 0, 0}, {0, 0, -1}, 4.0);
|
||||||
|
expectVec(cam.position, 0, 0, -4);
|
||||||
|
EXPECT_NEAR(cam.viewUp[2], 0.0, 1e-9);
|
||||||
|
EXPECT_NEAR(norm(cam.viewUp), 1.0, 1e-9);
|
||||||
|
}
|
||||||
|
// 非单位法向:position 用归一化法向 → 距焦点恰为 dist。
|
||||||
|
TEST(SlicePlaneMath, FaceOnNormalizesNormal) {
|
||||||
|
const auto cam = faceOnCamera({0, 0, 0}, {0, 3, 0}, 6.0);
|
||||||
|
expectVec(cam.position, 0, 6, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── wheelStep:滚轮推进步长(按对角线比例 × 方向)──
|
||||||
|
TEST(SlicePlaneMath, WheelStepForwardPositive) {
|
||||||
|
EXPECT_GT(wheelStep({0, 10, 0, 0, 0, 0}, +1), 0.0);
|
||||||
|
}
|
||||||
|
TEST(SlicePlaneMath, WheelStepBackwardNegative) {
|
||||||
|
EXPECT_LT(wheelStep({0, 10, 0, 0, 0, 0}, -1), 0.0);
|
||||||
|
}
|
||||||
|
TEST(SlicePlaneMath, WheelStepScalesWithBounds) {
|
||||||
|
const double small = wheelStep({0, 10, 0, 0, 0, 0}, 1);
|
||||||
|
const double big = wheelStep({0, 100, 0, 0, 0, 0}, 1);
|
||||||
|
EXPECT_GT(big, small); // 体越大步长越大
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── nearestPlane:找点所在切片(按到平面距离最小)──
|
||||||
|
TEST(SlicePlaneMath, NearestPlaneEmptyIsMinusOne) {
|
||||||
|
EXPECT_EQ(nearestPlane({}, {}, {0, 0, 0}), -1);
|
||||||
|
}
|
||||||
|
TEST(SlicePlaneMath, NearestPlanePicksClosest) {
|
||||||
|
// 两张水平切片 z=0 与 z=10(法向 +Z);点 z=8 → 更近 z=10(索引 1)。
|
||||||
|
std::vector<Vec3> centers{{0, 0, 0}, {0, 0, 10}};
|
||||||
|
std::vector<Vec3> normals{{0, 0, 1}, {0, 0, 1}};
|
||||||
|
EXPECT_EQ(nearestPlane(centers, normals, {5, 5, 8}), 1);
|
||||||
|
EXPECT_EQ(nearestPlane(centers, normals, {5, 5, 2}), 0);
|
||||||
|
}
|
||||||
|
TEST(SlicePlaneMath, NearestPlaneIgnoresInPlaneOffset) {
|
||||||
|
// 单张 z=0 水平面:点无论 x/y 多远,只要 z=0 距离为 0 → 命中。
|
||||||
|
std::vector<Vec3> centers{{0, 0, 0}};
|
||||||
|
std::vector<Vec3> normals{{0, 0, 1}};
|
||||||
|
EXPECT_EQ(nearestPlane(centers, normals, {999, -999, 0}), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 向量工具 ──
|
||||||
|
TEST(SlicePlaneMath, NormalizeZeroFallsBack) { expectVec(normalize({0, 0, 0}), 0, 0, 1); }
|
||||||
|
TEST(SlicePlaneMath, CrossBasic) { expectVec(cross({1, 0, 0}, {0, 1, 0}), 0, 0, 1); }
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
#include <gtest/gtest.h>
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
|
#include "ground/TileMath.hpp"
|
||||||
|
|
||||||
|
using geopro::render::lonLatToTile;
|
||||||
|
using geopro::render::tileBounds;
|
||||||
|
|
||||||
|
// z=1 把世界分 2x2:原点(0°,0°)在东/南象限交界 → 标准 slippy 取 (1,1)。
|
||||||
|
TEST(TileMath, OriginZoom1) {
|
||||||
|
auto t = lonLatToTile(0.0, 0.0, 1);
|
||||||
|
EXPECT_EQ(t.z, 1);
|
||||||
|
EXPECT_EQ(t.x, 1);
|
||||||
|
EXPECT_EQ(t.y, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// z=1 西北瓦片 (0,0) 覆盖西半球北部:west=-180, east=0, north≈85.0511(墨卡托上限), south=0。
|
||||||
|
TEST(TileMath, BoundsZoom1NW) {
|
||||||
|
auto b = tileBounds(1, 0, 0);
|
||||||
|
EXPECT_NEAR(b.west, -180.0, 1e-6);
|
||||||
|
EXPECT_NEAR(b.east, 0.0, 1e-6);
|
||||||
|
EXPECT_NEAR(b.north, 85.0511287798, 1e-4);
|
||||||
|
EXPECT_NEAR(b.south, 0.0, 1e-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 往返一致:任一经纬点所属瓦片的边界必须包含该点(经度严格、纬度含墨卡托方向)。
|
||||||
|
TEST(TileMath, RoundTripContains) {
|
||||||
|
const double lon = 116.391, lat = 39.907; // 北京附近
|
||||||
|
const int z = 12;
|
||||||
|
auto t = lonLatToTile(lon, lat, z);
|
||||||
|
EXPECT_EQ(t.z, z);
|
||||||
|
auto b = tileBounds(t.z, t.x, t.y);
|
||||||
|
EXPECT_GE(lon, b.west);
|
||||||
|
EXPECT_LE(lon, b.east);
|
||||||
|
EXPECT_LE(lat, b.north); // north 是瓦片上边界(纬度大)
|
||||||
|
EXPECT_GE(lat, b.south);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 夹紧:超界经纬不应产生越界瓦片索引。
|
||||||
|
TEST(TileMath, ClampInRange) {
|
||||||
|
auto t = lonLatToTile(500.0, 95.0, 3); // 非法输入
|
||||||
|
const int hi = (1 << 3) - 1;
|
||||||
|
EXPECT_GE(t.x, 0);
|
||||||
|
EXPECT_LE(t.x, hi);
|
||||||
|
EXPECT_GE(t.y, 0);
|
||||||
|
EXPECT_LE(t.y, hi);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue