Compare commits

..

88 Commits

Author SHA1 Message Date
gaozheng a2e16e18e8 web版页面可嵌入改造方案 2026-06-17 18:09:25 +08:00
gaozheng 3635f295b2 fix(vtk): 拉最远仍加载超大面积根因=比范围还大的粗瓦盖住数据中心过了距离剔除;改为瓦片大于范围则强制细分,真正限定在动态范围内 2026-06-17 18:05:30 +08:00
gaozheng 223b8ecf70 perf(vtk): 瓦片就近优先加载(离相机近的先拉,用户正看区域最先出)+并发8->12 2026-06-17 17:58:43 +08:00
gaozheng 484992a434 feat(vtk): 底图范围按剖面合并范围动态定(半径x10,夹2-30km),随勾选增删自动伸缩;取代固定10km/234km 2026-06-17 17:52:45 +08:00
gaozheng c5577ce071 fix(vtk): 剖面被切根因=远处底图把近裁剪面顶出去切掉近处剖面;调小近裁剪容差(1e-5)+底图限数据周围10km(远裁剪面有界) 2026-06-17 17:25:40 +08:00
gaozheng 052fdc1168 refactor(vtk): VE默认值收敛为单一来源(kVerticalExaggeration一处定义,组合根下发控制器/底图/UI);加预设取景诊断日志 2026-06-17 17:10:24 +08:00
gaozheng 97dfd54445 chore(vtk): 默认水平/垂直比例 2.0→1.0(真实比例) 2026-06-17 16:47:58 +08:00
gaozheng 876d88c251 fix(vtk): 地形半透明(0.55)-根因是剖面改真实高程后落地下,不透明地形从侧面遮挡地下剖面;半透明后剖面可从任意角度透过地面看到(专业地球物理三维标准做法) 2026-06-17 16:32:02 +08:00
gaozheng e718336385 fix(vtk): 合并渲染前更新裁剪面-异步落地的瓦片纳入近/远裁剪范围,治瓦片到达后被切(屏幕暗带) 2026-06-17 16:10:12 +08:00
gaozheng c3f72fdc8d fix(vtk): A-复刻原版垂直配准.剖面z用真实高程+g.y(原版data.y=高程,非深度);地形改真实高程(去基准减法)x同一VE;剖面与地形同系对齐,剖面顶≈地表露出地面 2026-06-17 16:00:14 +08:00
gaozheng 692ee057ab fix(vtk): 瓦片视锥剔除只用4侧面不用近/远裁剪面-根治首帧远处瓦片被随几何变化的远裁剪面误剔除(等多久不出/微动才出) 2026-06-17 15:45:57 +08:00
gaozheng e15930d8fb fix(vtk): 治首帧部分瓦片需手动微动才出-相机程序化变化(取景/预设/缩放)后经onCameraChanged通知底图按新视锥重算覆盖 2026-06-17 15:30:59 +08:00
gaozheng fb43237830 fix(vtk): 修垂直偏移根因-四叉树粗块先到时baseline未定就按全高程warp把地形抬高;改show时先拉数据中心DEM定基准再铺瓦片(全场同一基准) 2026-06-17 15:19:16 +08:00
gaozheng fd43051d8d perf(vtk): 撤销防抖定时器,改合并渲染(requestRender:同事件循环多次请求只渲一帧)治滚轮卡顿-根因是逐瓦片重复Render 2026-06-17 15:09:00 +08:00
gaozheng 69a81b2eac fix(vtk): 统一相机取景用数据包围盒-预设按钮(上下左右)不再被底图推远; 基准高程锚数据中心(确定性)修垂直偏移; 滚轮交互防抖140ms治缩放卡顿 2026-06-17 14:53:33 +08:00
gaozheng 67eaade7bd fix(vtk): 根治底图被'蒙版'切=远裁剪面忽略底图(UseBounds=false所致);底图改回参与裁剪+坐标轴/取景改用数据自身包围盒,交互后ResetCameraClippingRange含底图 2026-06-17 14:36:26 +08:00
gaozheng 0ac2765fd7 fix(vtk): 四叉树加视锥剔除(只细分视野内瓦片,根治预算被屏幕外块耗尽致粗块/拉近无图/地形丢失)+焦点自校正平面方向 2026-06-17 14:18:04 +08:00
gaozheng ca847f5a77 refactor(vtk): 底图改真正四叉树多级LOD(按屏幕误差细分,近细远粗铺满视野)取代单层级+视野盒+独立粗底,根治倾斜模糊/黑边 2026-06-17 14:03:31 +08:00
gaozheng 63e7874175 fix(vtk): 加请求限流(最多8并发,治瓦片暴发饱和单域名致加载不进/卡死)+精细优先于粗底排队 2026-06-17 13:47:29 +08:00
gaozheng b5bab42825 fix(vtk): 加远处粗底图层(z13~34km,随地形起伏,持久不purge)填到天边治倾斜露黑边;基准高程base/detail共用保连续 2026-06-17 12:20:24 +08:00
gaozheng 52f7a7d5e8 feat(vtk): 底图按相机可视范围覆盖(视锥交z=0,治旋转黑边,带上限+回退)+影像/DEM缓存跨隐藏-重选复用(治重选慢) 2026-06-17 12:10:01 +08:00
gaozheng a4866de68c fix(vtk): 底图覆盖半径3->4治近距旋转黑边; 纹理加mipmap+各向异性16x+edgeClamp治潜在糊/接缝渗色 2026-06-17 11:47:03 +08:00
gaozheng 5f27e59685 perf(vtk): DEM源换Mapbox terrain-RGB(原版web同款,全球CDN比AWS Terrarium快)+对应解码公式 2026-06-17 11:15:45 +08:00
gaozheng 23ed390faf fix(vtk): 底图瓦片SetUseBounds(false)-不参与包围盒/相机取景,修复勾第二个ds后坐标轴被底图撑到公里级 2026-06-17 11:05:16 +08:00
gaozheng d27ef37a24 feat(vtk): VTK全屏进入时展开左侧三栏抽屉(drawer本在vtkDock内,确保可见) 2026-06-17 10:49:17 +08:00
gaozheng ad3310b5bb feat(vtk): 默认天地图底图(下拉默认项)+frame重锚后在数据位置加载(onFrameReanchored,免启动拉无关瓦片) 2026-06-17 10:49:17 +08:00
gaozheng c03dc35469 feat(vtk): 勾选/取消增量渲染-按ds跟踪图元,diff增删不全量重建;clear保留底图;增量不重置相机(全量/首批才取景) 2026-06-17 10:49:06 +08:00
gaozheng d99e5c61f4 fix(vtk): 消除地形先平面后逐块形变-改DEM到位直接铺起伏块(拉不到才降级平面)+DEM缓存(同祖先块复用)减少逐块卡顿 2026-06-17 10:15:53 +08:00
gaozheng 33e9949623 fix(vtk): 地形不出根因-看小测区LOD选z16-18>DEM上限15致fetchTerrain静默退出;改取祖先DEM瓦片按经纬采样+诊断日志 2026-06-17 10:06:43 +08:00
gaozheng 67f767d787 fix(vtk): DEM瓦片改https(S3对http返回403,实测)+失败打日志便于诊断SSL 2026-06-17 09:56:12 +08:00
gaozheng ef8a9da254 feat(vtk): 底图地面起伏-AWS Terrarium DEM瓦片位移网格+卫星贴图(平面先铺,DEM到位换起伏,拉不到则降级平面) 2026-06-17 09:47:16 +08:00
gaozheng 29ea44560d fix(vtk): 底图LOD消除缩放空白闪烁(延迟清理旧层,落地后再删)+按层级Z偏移防共面z-fighting 2026-06-17 08:54:20 +08:00
gaozheng aaf150ca2e feat(vtk): 底图LOD-按相机视距自动选瓦片层级+覆盖可视范围+增量增删(交互结束触发) 2026-06-17 08:19:04 +08:00
gaozheng 11349e533c feat(vtk): 底图(B方案)天地图改卫星影像img层(局部卫星地面,对齐原版观感) 2026-06-17 08:02:15 +08:00
gaozheng d68fc31ae7 docs(vtk): 3D地球改造现状与约束评估(opus代码实测评审:判断准确且偏保守,补全耦合面) 2026-06-17 07:56:16 +08:00
gaozheng 8fceb6c1f3 fix(vtk): 二维数据集栏「地图」下拉默认隐藏(选天地图即触发显示) 2026-06-16 22:05:37 +08:00
gaozheng a588b651a6 feat(vtk): 天地图底图瓦片层 TileBasemap(③:复用WMTS token,经同一frame配准,col2D地图开关) 2026-06-16 22:01:57 +08:00
gaozheng c06f9ea0f8 refactor(vtk): GeoLocalFrame 就地 reanchor + 帘面重锚改就地(共享 frame 一致,供底图等同源对齐) 2026-06-16 22:01:57 +08:00
gaozheng 8684e52939 feat(vtk): 底图瓦片坐标数学 TileMath(EPSG:3857 经纬↔z/x/y+瓦片边界)+单测(P5基石) 2026-06-16 21:29:59 +08:00
gaozheng f407c0adbc docs(vtk): P5计划修正——dd_grid是白化数据点表(非地图面),2D可渲染仅轨迹线+底图 2026-06-16 21:21:18 +08:00
gaozheng cc53a74b88 docs(vtk): P5 二维数据集栏渲染+天地图底图 实现计划 2026-06-16 21:18:42 +08:00
gaozheng 86764b0cd9 docs(vtk): 反演剖面竖向字段 y/z/elevation 语义待业务确认(附4条真实ds证据) 2026-06-16 21:11:16 +08:00
gaozheng fe04bb1266 feat(vtk): 帘面按首个真实剖面 lat/lon 重锚 GeoLocalFrame 原点(②)
默认原点取自样本、可能离真实数据很远→局部坐标巨大、轴刻度无意义。每次 clear 后首个带经纬剖面
到达时把原点重锚到其 lat/lon 中心:坐标从0附近起、轴刻度有意义,同一选择内多剖面共用→相互地理配准。
无经纬剖面是平面、不受影响。
2026-06-16 21:00:20 +08:00
gaozheng b2740898f6 feat(vtk): parseInversionGrid 解析 lat/lon,弯曲测线渲染为曲面帘面
之前丢弃经纬度→所有剖面退化成 y=0 平面。解析后 CurtainActor 经 GeoLocalFrame 按真实测线摆位:
37/136 弯曲测线→曲面帘面,直线/无geo→平面(正确)。
2026-06-16 20:41:14 +08:00
gaozheng 37b433208e fix(vtk): 帘面消隐无数据(NaN)格,根治真实反演剖面渲染崩溃(0xc0000005)
真实反演 v 矩阵大量 null→Grid 存 NaN→vtkBandedPolyDataContourFilter 裁剪运算崩(经真实API数据+崩溃栈定位)。
消隐含 NaN 的点(ghost)使其不入表面/色带滤镜+标量填0兜底;顺带清洗色带等值线值(去非有限/去重)。保留色带功能。
2026-06-16 20:41:13 +08:00
gaozheng 5d1cf07882 fix(vtk): 抽屉折叠按钮 ◀▶ 文字改 SVG chevron 图标(根因:YaHei缺三角符→DirectWrite字体回退崩);回退GDI临时方案 2026-06-16 18:59:44 +08:00
gaozheng 5fe1c298d2 fix(vtk): 改用 GDI 字体引擎绕开 DirectWrite 字体回退崩溃(勾选数据集渲染时 QPushButton sizeHint 触发,见crash dump) 2026-06-16 18:48:29 +08:00
gaozheng e34abd271f feat(vtk): 注入 Api3dRepository 渲染真实 ERT 帘面+删样本桥(勾选真实ds→真实数据)(①.4) 2026-06-16 18:38:30 +08:00
gaozheng 2934bacd34 refactor(vtk): 帘面渲染改异步走 loadSection(QPointer+generation守护,对齐volume路径)(①.3) 2026-06-16 18:29:45 +08:00
gaozheng 2d155c864c feat(vtk): Api3dRepository(loadSection复用真实ERT反演端点;其余后端未就绪暂stub)(①.2) 2026-06-16 18:25:43 +08:00
gaozheng 744b55c1b6 feat(vtk): I3dSceneRepository 加异步 loadSection(帘面Grid+色阶)+LocalSample样本实现(①.1) 2026-06-16 18:18:38 +08:00
gaozheng 77f1b5543e feat(vtk): I3dSceneRepository 补齐切片/异常/任务接口设计(spec §6.3-6.5)+LocalSample内存态stub 2026-06-16 18:11:21 +08:00
gaozheng 624cdcbb2e fix(vtk): 本阶段勾选3D数据集渲染样本帘面(LocalSample仅样本;Api3dRepository就绪后改真id) 2026-06-16 17:51:07 +08:00
gaozheng 575529e5a0 fix(vtk): 数据集卡片代理支持复选框(可勾选项画框+点击切换)+折叠按钮加图标样式 2026-06-16 17:34:32 +08:00
gaozheng 5e15941cd2 fix(vtk): 修审查问题(H1 setDatasets信号风暴/H2异步陈旧批次竞态/I1全屏按钮互斥) 2026-06-16 17:08:45 +08:00
gaozheng 24d88530af feat(vtk): 三栏数据集列表按维度过滤数据驱动(替换grid1假实现) 2026-06-16 16:46:42 +08:00
gaozheng 2179f149b7 feat(vtk): dockState bump v3 + VTK视图/数据详情 全屏按钮(隐藏其余dock) 2026-06-16 16:17:59 +08:00
gaozheng 5e57d462c8 refactor(vtk): 删三浮层+分段切换,改挂三栏抽屉,接信号,中央改名VTK视图 2026-06-16 15:49:35 +08:00
gaozheng d81494fd5e feat(vtk): ColumnDrawer 抽屉容器(三tab+折叠) 2026-06-16 15:33:39 +08:00
gaozheng 5f19a0c0db feat(vtk): 三维分析栏 widget(对象→三维体→切片树+两套右键菜单) 2026-06-16 15:30:01 +08:00
gaozheng b6143a0cb6 fix(vtk): Column2DDataset 自定义Z整行隐藏(setRowVisible,非自定义不留孤标签) 2026-06-16 15:26:46 +08:00
gaozheng efc09a5877 feat(vtk): 二维数据集栏 widget(地图/2D视图+自定义Z输入+2D列表) 2026-06-16 15:23:25 +08:00
gaozheng 45662ff897 style(vtk): Column3DDataset 中文/×改直写字面量(去掉多余十六进制转义,对齐代码库惯例) 2026-06-16 15:20:22 +08:00
gaozheng 97d1e70099 feat(vtk): 三维数据集栏 widget(4工具条栏位+3D数据集列表,只发信号) 2026-06-16 15:16:53 +08:00
gaozheng 3f24ad81e3 feat(vtk): 维度过滤纯函数 splitByDimension + 单测 2026-06-16 15:11:07 +08:00
gaozheng 529ffc023c feat(vtk): 加 Glyph::Fullscreen 图标(三栏重构全屏按钮用) 2026-06-16 15:03:54 +08:00
gaozheng 01a8c0ae03 docs(vtk): 三栏结构重构实现计划(10任务:抽屉/三栏widget/维度过滤/全屏/装配) 2026-06-16 14:57:15 +08:00
gaozheng 540fb1cde5 docs(vtk): 三栏结构重构设计 + 高保真原型(方案C视图内嵌侧栏/全屏/右键菜单/改名VTK视图) 2026-06-16 14:39:33 +08:00
gaozheng c058c851ee docs: VTK 三维视图任务交接文档(背景/进度/下一步/构建铁律/代码地图) 2026-06-16 11:44:10 +08:00
gaozheng 07f2f25b58 fix(vtk): 选中切片边框改亮青(0,0.95,1)+加粗, 未选暗灰, 拉开对比 2026-06-16 11:38:53 +08:00
gaozheng 5809b88a44 fix(vtk): D39 改为自定义 Rotate 绕支点增量旋转(真不跳)
前法错: 设焦点=切片中心会把相机位置挪走(透视视差)→画面平移=跳; 诊断只验了视向、漏看位置。
正确: 按下完全不动相机(不跳); 重写 Rotate(): 有选中物时, 用 T(c)R(up)R(right)T(-c) 把
相机 position/focal/up 绕选中切片中心 c 增量旋转→c 在世界/屏幕都不动、场景绕它转、无跳。
无选中回退默认绕焦点。ctest 221/221
2026-06-16 11:33:03 +08:00
gaozheng 8d94247dd9 feat(vtk): D39 以选中三维体/切片为中心旋转视图(不跳)
正确做法: 点击只选中(不动相机)→切换点选不跳; 在'按下开始拖动'那刻才把焦点设到
选中切片中心(焦点+位置同步补偿 delta→视向/距离不变、画面不跳), 之后默认 TrackballCamera
即绕该中心旋转。PickInteractorStyle 加 getRotateCenter 回调, InteractionManager 提供
选中切片中心; 无选中则绕默认焦点。ctest 221/221
2026-06-16 11:27:42 +08:00
gaozheng 2e5cc4e6db chore(vtk): 移除未用的 InteractionManager::faceSelected(正视按钮已删, 双击走 faceSlice) 2026-06-16 11:19:03 +08:00
gaozheng a7edfa5c78 fix(vtk): 轴向切片禁用旋转(G22-24 角度不能再调整),仅任意切片可转(F25)
上一版让所有切片都能拖边缘旋转,违反 G22-24(上下/前后/左右角度不能再调整)。
修: 轴向切片 SetMarginSizeX/Y(0) 去掉旋转抓取区→只能移动不能转;
任意切片(Oblique)保留默认 margin→可拖边缘旋转(F25)。ctest 221/221
2026-06-16 11:14:05 +08:00
gaozheng f3a1ba9f99 fix(vtk): 恢复原型切片可拖动配置(SLICE_MOTION)+双击正视(去按钮)
据用户指出'原型阶段切片可拖动调整',查 git(f57291a)原型配置:
  SetLeftButtonAction(VTK_SLICE_MOTION_ACTION) + SetMiddleButtonAction(VTK_CURSOR_ACTION)
我的 SliceTool 漏了这两行→默认左键是窗位调整(无用)→拖切片面无反应=用户的'不能调'。
- 补回两行: 左键拖动=移动切面; 切面边缘 margins 拖动=旋转角度(F25 任意切片可调)。
- 双击正视(D40)改用 widget StartInteractionEvent 检测(同一切片350ms内两次=双击→正视),
  恢复 spec 的双击交互, 去掉上一版擅自加的'正视'按钮(改了需求, 错)。
- 选中=触碰(onInteract); 滚轮/关闭=选中切片; 相机旋转=空白处拖动。ctest 221/221
2026-06-16 11:07:30 +08:00
gaozheng 87c5cc910e fix(vtk): 恢复任意切片可调(F25)+触碰选中+正视按钮(不再砍功能)
之前用 InteractionOff 砍掉了切片可调(F25),错。改回:
- widget 交互保持开启 → 任意切片可拖动调整角度/位置(F25 恢复); 拖切面=widget 处理。
- 选中改为监听 widget StartInteractionEvent(SliceTool::onInteract → selectByTool):
  触碰某切片即选中+高亮(widget 开交互后独占切面事件,拾取式选中失效,故改观察式)。
- 正视(E54)从双击改为工具条「正视」按钮(faceSelected): 双击会被 widget 抢、QVTK 上本不稳。
- 滚轮推进/关闭 仍作用于选中切片; 相机旋转=空白处拖动(默认 trackball)。
- ctest 221/221
2026-06-16 10:50:05 +08:00
gaozheng 43f8228e49 fix(vtk): 切片单击仅选中+高亮,不动相机(终态) — 切换切片不再跳
据用户实测: '按切片中心移焦点'(eb8cb9e)在切换两切片时仍跳(两次焦点跳动), 且切片中心≈体中心
→ 与默认绕中心旋转视觉等价、价值低。终态: 单击=仅选中+高亮, 拖动=默认 TrackballCamera 绕
场景/体中心旋转(稳定、永不跳)。spec C38 '以体为中心' 由此满足; 切片本身不自转(符合语义)。
2026-06-16 10:34:11 +08:00
gaozheng eb8cb9e7ee fix(vtk): 切片旋转支点用切片中心而非点击点(根因修复,据日志证据)
诊断日志证据: onPicked 改相机后 dir(视向) before==after, 补偿正确、点击瞬间画面不变;
但命中点 world 明显偏离体中心 → 之前以'点击点'为焦点, 拖动绕偏心点旋转→大幅摆动(=用户的'跳')。
根因修复: 旋转焦点设为**切片中心**(slices_[idx]->center(), ≈体中心, spec C38 '以切片为中心'),
焦点+位置同步平移保持画面不变 → 点击不跳、拖动绕切片中心居中旋转、不甩。
未命中切片则不动相机。
2026-06-16 10:26:29 +08:00
gaozheng 8a06014e0b fix(vtk): 切片单击=仅选中(不动相机,去跳)+高亮反馈
- onPicked 去掉改相机焦点/位置(实测仍致点击跳变)→ 单击仅选中命中切片;
  拖动旋转回归默认 TrackballCamera(绕场景中心,不跳)。'以切片为中心旋转'(C38)
  因致跳且预期不清,暂去, 后续用更稳方式再加。
- SliceTool::setSelected: 选中切片边框高亮(亮黄粗线)、其余暗灰; InteractionManager
  在 单击/新增/关闭/双击 后 updateSelectionVisual → 解决'选中无视觉反馈'。
- ctest 221/221
2026-06-16 10:12:20 +08:00
gaozheng ff3ce27978 fix(vtk): 切片 widget InteractionOff —— 修点击选中/双击/绕点旋转全失效
实测根因: vtkImagePlaneWidget 默认消费落在切片面上的左键(窗位/光标), 自定义
PickInteractorStyle 收不到 → 单击选中/双击正视/绕点旋转/滚轮推进选中项 全失效;
滚轮看似只对最后新增切片有效, 实为选中从未改变(addSlice 设的 last)。
关掉 widget 自身交互(Interaction off): 其事件回调提前 return 不设 AbortFlag →
事件穿透到样式; 切片面 actor 可被 cellPicker 拾取 → 选中/双击/旋转恢复。切面移动由滚轮 advance 驱动。
2026-06-16 09:57:34 +08:00
gaozheng 29710a8484 fix(build): build.bat rebuild 块改纯 ASCII(中文注释致 cmd GBK 解析崩 'ld'/'此时不应有 build') 2026-06-16 09:46:01 +08:00
gaozheng a5e4f04bd9 build: 加 rebuild 命令(--clean-first 强制全量重编+启动),规避 ninja 增量偶发漏编 2026-06-16 09:38:07 +08:00
gaozheng 87b90a2022 fix(vtk): P3 切片交互手感修复(用户实测反馈)
- 删左上「视图详情」遗留的禁用「切片」复选框(P1占位,已被P3工具条取代)
- 点击跳变 + 绕点旋转无效: onPicked 焦点与相机位置同步平移同一delta,
  图像不跳、旋转中心移到命中点(原来只改焦点致视图突变)
- 双击正视无效: 弃 vtkRenderHWindowInteractor::GetRepeatCount(QVTK+Win不可靠),
  改 std::chrono 手动判双击(间隔<350ms且位置相近); 避开 vtkTimerLog 依赖
- 选中移除: 随上面拾取修复,单击切片可选中→关闭移除选中项(不再只能倒序)
2026-06-16 09:09:38 +08:00
gaozheng 85d4ff57df feat(vtk): P3 三维分析切片交互(核心) — 轴向/任意切片+滚轮+拾取+正视
interact/ 交互层(README早规划,本期落地):
- SlicePlaneMath: 纯几何(法向/45°/滚轮平移/双击正视相机含竖直兜底/最近切片), 25 单测
- SliceTool: 封装 vtkImagePlaneWidget — 轴向(上下/前后/左右,角度固定)+任意45°(Origin/Pt1/Pt2),
  套色阶LUT, reslice着色(非cutter), close幂等
- PickInteractorStyle: 继承TrackballCamera+vtkCellPicker, 拾取/双击/滚轮回调
- InteractionManager: 活动切片/选中态/滚轮分发/拾取联动/翻转, 体素变更先closeAll再附着
- VtkSceneView 暴露 currentVolumeImage_(含VE) + onVolumeChanged; main.cpp 切片工具条(上下/前后/左右/任意/翻转/关闭)
- ctest 221/221

评审修复:
- H1 vtkTrivialProducer 提为成员(局部变量构造后析构→管线断裂崩溃)
- H2 uninstallStyle 向 interactor 注销 style
- H3 safeRender 统一守 destroying_ 跳过析构期 Render
- M1 advance 刚性平移 origin+point1+point2(只移origin致轴向切面变形)
- M2 closeSelected 选中位就近不跳0; M4 sliceBar 加 BottomLeftAnchor 随resize

范围外(P4): 切片保存/导出/删除为数据集/三维分析树/右键菜单/异常圈定/详情
2026-06-16 08:23:16 +08:00
gaozheng c44203d6ca docs(plan): P3 三维分析切片交互(核心)实现计划 2026-06-16 07:52:13 +08:00
gaozheng 86e07722e5 fix(ui): P2 工具条去 border-radius,消除原生GL上四角浅色直角
对齐左上「视图详情」浮层做法:悬于 GL 画布的浮层不设圆角(圆角四角露父级浅底),
改直角矩形+不透明底。控件自身圆角(落在不透明frame上)保留。
2026-06-16 07:48:12 +08:00
gaozheng 73deb2b159 fix(ui): P2 三维数据集栏控件深色主题 — 修白底浅字看不清
工具条只设了文字色未设控件背景,下拉/按钮保持默认白底→浅字不可读。
补:QComboBox/QPushButton/QSlider 深色背景+边框+hover/pressed+下拉弹窗配色(canvas/* token)。
2026-06-16 07:45:07 +08:00
gaozheng 3dea339ddc feat(vtk): P2 三维数据集栏 — 坐标轴/比例/快捷视图/Zoom
- CameraPreset 扩 6 向快捷视图(前后左右上下) + zoomBy(透视改视角/正交改parallelScale) + fitView
- AxesActor(新, vtkCubeAxesActor): 显示模式 标准(外缘)/三维立体(静态边+网格线)/不显示;
  刻度 无/米/英尺(×3.28084)/经纬度(GeoLocalFrame反算); 字号12(字体待1.0确认)
- GeoLocalFrame 补 toLatLon 反算(等距圆柱)
- I3dSceneView 扩 setAxes/applyCameraView/zoom/fitView; VtkSceneController 加对应槽
  (坐标轴随场景重建; 快捷视图/zoom 仅改相机不重建)
- main.cpp 三维视图工具条: 坐标轴/刻度下拉(枚举绑itemData)+比例滑块(1-10)+6向钮+Zoom钮, 仅3D显示
- 测试 +24(toLatLon往返/相机6向/坐标轴单位换算/控制器编排), ctest 196/196

评审修复:
- HIGH rebuildAxes 异步路径坐标轴 prop 累积 → 持 currentAxes_ 重建前先移除(幂等)
- MEDIUM combo index 魔数 → itemData/currentData 取枚举值(防项序调整静默错位)

注: 坐标轴标准/立体语义 + 字体 + O点 为合理近似, 待 Geopro 1.0 实地确认精修
2026-06-15 21:54:48 +08:00
66 changed files with 6471 additions and 210 deletions

View File

@ -2,11 +2,13 @@
REM ============================================================
REM geopro build helper (Windows / MSVC + Ninja, CMake presets)
REM
REM Usage: build [app | all | test | run | configure]
REM app (default) build target geopro_desktop
REM all build all targets
REM Usage: build [app | all | test | run | rebuild | configure]
REM app (default) build target geopro_desktop (incremental)
REM all build all targets (incremental)
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
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%"=="test" goto :test
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
:ensure
@ -80,3 +83,11 @@ call :ensure
"%CMAKE%" --build "%BUILDDIR%" --target geopro_desktop || exit /b 1
"%BUILDDIR%\src\app\geopro_desktop.exe"
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%

View File

@ -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 项的明确定义后,客户端按真实竖向模型实现(避免凭猜测导致渲染错误)。

View File

@ -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 不一致);天地图同样有卫星 WMTSWeb 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. **【评审补充】地形 actorTerrainActor也全程局部系**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 俯视模式**(已补入 §4b4e。无任何"夸大耦合"之处。
- 总体结论 **SOUND**3D 地球触及整条 3D 管线根基,非底图增量;局部卫星平面是务实折中;站点尺度曲率视觉可忽略。其中**体素/切片基于 vtkImageData 无法弯曲贴球**是最强佐证。

View File

@ -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 dockWindows/MSVC+Ninja。
- 任务:实现需求表「补充需求」页签 = **VTK 三维视图的整套交互/结构**。需求源:`D:\Projects\GEOPRO\Geopro3.0 需求表.xlsx`「补充需求」(用 openpyxl 读,控制台中文乱码须导 UTF-8 文件再读A1C92)。
- 历史:做本需求前已有"基于本地样本数据的原型渲染"(帘面/体素/切片/地形/散点),但在 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 过滤勾选对象的 dsC9/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 解析 .batUTF-8 中文注释会让解析崩)。
- **Claude 工具跑 build 会间歇被一个 `Start-Process 'C:\Users\corey\...'` 钩子劫持**(环境问题、非项目;只影响 Claude 工具,不影响用户终端)→ 我的构建验证有时静默没跑,会误判"已更新"**交互类改动必须让用户在其终端 `build.bat rebuild` 实测**。
- **Claude 无法 GUI 测试**VTK 交互(切片/旋转/拾取)的正确性只能靠用户实测——纯逻辑(几何/相机数学)抽成单测widget 行为靠目视。
- app 启动需登录(真实 APItenant.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 反复返工,务必先逐条读「补充需求」对应行)。

View File

@ -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>`;}
// 对象树 dockA/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>

View File

@ -0,0 +1,48 @@
# P3三维分析·切片交互核心
- 日期2026-06-16
- 分支:`feat/vtk-3d-view`
- 上游spec `2026-06-15-vtk-3d-supplementary-design.md` §9交互层、§4 行 F22F25/C38D46/E54E56接 P1/P2VtkSceneController/VtkSceneView/Scene/体素管线已就位)
- 目标:补充需求最重模块。在 P1 的 LocalSample 体素上实现**切片交互**:轴向/任意切片、滚轮推进、拾取选中、双击正视。让用户能在三维体上切出带色阶的剖面并交互调整。
## 范围
**范围内P3 核心)**
- 新建 `src/render/interact/` 交互层README 早有规划但目录不存在)。
- **切片工具**:轴向(上下=水平面/前后/左右角度固定F22F24+ 任意切片(初始 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 已烤入 VEP1 体素管线切面几何与体绘制对齐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/interactorCI 无显示环境多半跳过故纯逻辑plane/相机数学尽量抽出单测widget 行为靠目视。报告标清目视项。
- 切片"标准/任意"手感、色阶、正视/翻转细节若需对齐 Geopro 1.0,先合理实现,待 1.0 实地学习再精修([[study-original-via-playwright]])。

View File

@ -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 归 2DTask 2/3/4 只做轨迹。
## Task 1取消dd_grid 非地图渲染类型,维持 Other。
## Task 22D 数据异步加载(轨迹 + 网格)
`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 32D actorrender 层)
- 轨迹线:新增 `render/actors/TrajectoryActor`(或复用 `MapLineActor`):经 `GeoLocalFrame.toLocal` 把经纬序列 → 局部米折线,摆在 Z=0 平面(或 2D视图 Z。橙色对齐轨迹详情 `#ff8c00`)。
- 网格面:复用 `MapLineActor`(俯视红线) 或 `GridContourActor`(着色面,需 frame)。
- 单测:纯几何(经纬→局部米折线点数/坐标)抽出可测。
## Task 4VtkSceneView + 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 5main.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 URLtoken/子域见上)→ 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 的网格分支 → 先确认。

View File

@ -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 vtkDockbump 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]
独立 widget4 工具条栏位(坐标轴设置 / 水平垂直比例 / 快捷视图 / 缩放)+ 数据集树。只发信号。控件创建可**搬运** `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` lambda804-827及其所有调用。
- `updateSliceButtons`559-569、`addSlice`572-575、sliceBar 按钮 connect576-590
- 旧 connectlayer checkboxes846-851、axisBar 控件857-889、act2D/act3D832-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点位置/字体本期 stubconnect 到一个提示(可空 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 本期 stubconnect 到提示 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` = 除目标外的全部 dockvtkDock 全屏时 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` 取当前项目 idmain.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/切片 ds2D/分析栏为空属正常;可后续在 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 用户实测把关)。

View File

@ -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 三维数据集栏(行 311
工具条 = **4 个分组栏位** + 数据集列表:
1. **坐标轴设置**(行 46——表单式每项一行、左对齐
- 显示方式:下拉(标准 / 三维立体 / 不显示)
- O点位置按钮弹框设原点
- 刻度:下拉(无刻度 / 米 / 英尺 / 经纬度)
- 字体:按钮(设刻度文字字体)
2. **水平/垂直比例**(行 7——**单个拖动滑块 + 数值**(如 2.0×,纵向放大系数;现有 `kVerticalExaggeration`)。**非两个独立控件**。
3. **快捷视图**(行 8——6 钮:前 / 后 / 左 / 右 / 上 / 下。
4. **缩放 Zoom**(行 9——3 钮:放大(In) / 缩小(Out) / 适配(Fit)。
5. **数据集列表**(行 1011——`dimensionOf==Dim3D` 过滤勾选对象的 ds勾选→渲染。
### 3.2 二维数据集栏(行 1216= 3 栏位
1. **地图**(行 13——下拉天地图 / Google Map / 隐藏)。**底图瓦片渲染留 P5**。
2. **2D视图**(行 14——下拉关闭 / Z=0 / 顶部 / 底部 / **自定义**);选「自定义」显数值输入框。
- **「自定义 Z」= 世界绝对高程(米),向上为正**与「Z=0/顶部/底部」同坐标系(`GeoLocalFrame` 世界 Z。决策依据同一下拉里「Z=0」即绝对值「自定义」只是输入任意绝对 Z与业界Petrel/Leapfrog/ParaView水平面高度用项目 CRS 绝对高程一致。
3. **数据集列表**(行 1516——`dimensionOf==Dim2D` 过滤,勾选→渲染到 VTK 的 2D 视图。
### 3.3 三维分析栏(行 1735
- **数据集列表 = 树**(行 18对象 → 三维体模型数据集 → 切片。可勾选三维体/切片 → 渲染(行 19
- **右键菜单(按节点类型分派):**
**三维体数据集**(行 2027**直接项,无"创建切片"父级,无删除**
| 菜单项 | 说明 | 本轮 |
|---|---|---|
| 切片 ▸ 上下 / 前后 / 左右 / 任意 | 一级「切片」父菜单 + 二级方向(上下/前后/左右=固定角度;任意=初始 45°可调| **接已有 SliceTool** |
| 色阶 | 参考 Geopro 1.0 | OUTstub/禁用)|
| 显示 / 隐藏 | actor 可见性 | IN |
| 数据详情 | 详情栏显示 | IN接现有详情|
> 注:需求字面是"上下切片/前后切片…"直接项;本轮按用户决策归入「切片」一级父菜单、二级去「切片」二字显「上下/前后/左右/任意」。
**切片数据集**(行 2835**有删除**
| 菜单项 | 本轮 |
|---|---|
| 保存 / 保存为 / 导出 / 删除 | OUTP4 CRUDstub/禁用)|
| 色阶 | OUT |
| 显示 / 隐藏 | IN |
| 数据详情 | IN |
- **VTK视图交互**(行 3638、**切片分析**(行 3951含视图内切片右键创建异常/保存/导出图片/导出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 版本号后回落默认排布,可接受)。

View File

@ -0,0 +1,341 @@
# 原版 Web 系统「子页面嵌入挂载」最小侵入改造设计
- 日期2026-06-17
- 状态:**设计稿 v2已经 opus 双评审 + 代码实测修订,可据以实现)**
- 涉及仓库:
- **Web 端(被改造方)**`D:\Git\lanbingtech\commercial-admin`Vue3 + Vite + Arcohash 路由)
- **客户端(接入方)**`D:\Git\lanbingtech\geopro`Qt + `QWebEngineView`
- 需求背景:客户端需把原版 web 系统的**单个子功能页**(如"系统管理"下的组织/用户/角色,以及项目空间下的数据视图等)**裸挂**进客户端窗口,不带原系统的"标题栏 + 左菜单 + 页签"外壳,复用既有页面与鉴权。
- 设计约束(用户已确认):
1. **尽可能少改动原有代码**——既有函数保持行为零变化,优先"新增"而非"修改"。
2. token 注入:客户端用 `QWebEngineProfile` 预置 localStorage首选`loadStarted` 注入token **不进 URL**
3. 挂载范围:**同时支持租户空间页面(`space=2`)与项目空间页面(`space=3`,需 `projectId`**。
> v2 修订摘要(详见 §10EmbedLayout 必须写成**懒加载函数**(否则被 `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/历史/日志无 tokenlocalStorage token 由 profile 预置成功。
5. **隔离**:普通浏览器新 tab 访问业务页,`EMBED_MODE` 不存在、守卫照常生效。
---
## 8. 回滚
新增物删除即回滚guard 早返回分支删除即恢复。无数据/接口副作用。
---
## 9. 实现顺序
1. WebEmbedLayout.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。
- **必错-1cloneDeep 拷坏组件)**`EmbedLayout` 必须懒加载函数 `() => import()`。lodash `cloneDeep` 对作为对象属性的**函数按引用返回**(故原系统 `Layout=()=>import()` 正常),对**静态导入的组件 options 对象会深拷贝**并破坏 Vue 内部标识。→ 已落到 §3.4。
- **S3持久化污染 + 隔离)**`routeStore/projectStore/userStore` 均 `persist`(localStorage)。EmbedLayout 路由会被持久化,复用 profile 时污染正常链路/反之。→ 独立 profile 升为**强制**§4生成前 `$reset`§3.2)。
- **M3projectId 脏读)**`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-aliveM2**EmbedLayout 去掉 keep-alivecacheList 恒空§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以及"导航中枢"类(工作台、项目列表,职责即切空间/进项目,单独嵌无意义)。这些通常也不是普通叶子功能菜单。

View File

@ -50,6 +50,10 @@ add_executable(geopro_desktop WIN32
panels/chart/ContourPlotItem.cpp
panels/chart/LivePanner.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/LoadingOverlay.cpp
panels/DatasetDetailPage.cpp
@ -60,14 +64,16 @@ add_executable(geopro_desktop WIN32
ImportDatasetDialog.cpp
ExportDatasetDialog.cpp
SettingsDialog.cpp
Logging.cpp)
Logging.cpp
DatasetDimension.cpp
TileBasemap.cpp)
target_include_directories(geopro_desktop PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
# QtKeychain FetchContent target / export
target_include_directories(geopro_desktop PRIVATE ${qtkeychain_SOURCE_DIR} ${qtkeychain_BINARY_DIR})
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
${VTK_LIBRARIES}
ads::qt6advanceddocking

View File

@ -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

View File

@ -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

View File

@ -65,6 +65,14 @@ QString svgPathFor(Glyph t)
"<path d='M7 10l5 5 5-5'/><path d='M12 15V3'/>");
case Glyph::Collapse:
return QStringLiteral("<path d='m17 11-5-5-5 5'/><path d='m17 18-5-5-5 5'/>");
case Glyph::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:
return QStringLiteral(
"<rect width='7' height='7' x='3' y='3' rx='1'/>"

View File

@ -26,6 +26,9 @@ enum class Glyph {
Upload, // 上传
Download, // 导出/下载
Collapse, // 折叠(双箭头)
Fullscreen, // 全屏 / 最大化
ChevronLeft, // 折叠抽屉(向左)
ChevronRight, // 展开抽屉(向右)
// 顶部应用栏图标
Workspace, // 工作空间2x2 宫格)
Folder, // 项目(文件夹)

539
src/app/TileBasemap.cpp Normal file
View File

@ -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 公开 tokenpk.*,客户端用,同 天地图 tk 性质)。
const char* kMapboxToken =
"pk.eyJ1IjoidGJ1c2FuIiwiYSI6ImNtZjY2emZneDBkY24ybXB4cmpvdmwzNWYifQ.h6tcQ380WN5AW6fZr08how";
constexpr int kDemMaxZoom = 15;
constexpr int kTerrainGrid = 32; // 每瓦片网格分辨率(33x33 顶点)
// key 打包z<<44 | x<<22 | yz≤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 自动 tcoordorigin=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-RGBpngraw 无损,保证高程解码准确);原版同源,全球 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

96
src/app/TileBasemap.hpp Normal file
View File

@ -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

View File

@ -1,8 +1,17 @@
#include "VtkSceneView.hpp"
#include <algorithm>
#include <cmath>
#include <memory>
#include <utility>
#include <QDebug>
#include <QString>
#include <vtkActor.h>
#include <vtkBoundingBox.h>
#include <vtkCubeAxesActor.h>
#include <vtkProp.h>
#include <vtkRenderWindow.h>
#include <vtkRenderer.h>
#include <vtkVolume.h>
@ -10,6 +19,7 @@
#include "CameraPreset.hpp"
#include "Scene.hpp"
#include "Theme.hpp"
#include "actors/AxesActor.hpp"
#include "actors/CurtainActor.hpp"
#include "actors/MapLineActor.hpp"
#include "actors/TerrainActor.hpp"
@ -18,42 +28,222 @@
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,
std::shared_ptr<geopro::core::GeoLocalFrame> frame, double zRefElev)
: scene_(scene),
renderWindow_(renderWindow),
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::addSurveyLine(const geopro::core::Grid& grid) {
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_);
if (curtain) {
curtain->SetScale(1.0, 1.0, verticalExaggeration_); // 纵向夸张成墙
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 的 buildVoxel 重载:保留 currentVolumeImage_ 供 P3 切片附着(几何含 VE
vtkSmartPointer<vtkImageData> image;
auto volume = geopro::render::buildVoxel(
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);
if (volume) scene_.addViewProp(volume);
vol.spacing[0], vol.spacing[1], vol.spacing[2] * verticalExaggeration_, vol.vmin, vol.vmax,
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) {
auto terrain = geopro::render::buildTerrain(paths.demPath, paths.imagePath, *frame_, zRefElev_,
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() {
// 先移除上一次的坐标轴 proprender 可能在一次 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) {
@ -61,11 +251,26 @@ void VtkSceneView::render(bool is2D) {
double bgR, bgG, bgB;
geopro::app::vtkBackground(bgR, bgG, bgB);
scene_.renderer()->SetBackground(bgR, bgG, bgB);
// 坐标轴仅三维视图显示2D 俯视测线不需要立体坐标轴)。
if (!is2D) rebuildAxes();
if (is2D)
geopro::render::applyTop2D(scene_.renderer());
else
geopro::render::applyFree3D(scene_.renderer());
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();
}

View File

@ -1,12 +1,22 @@
#pragma once
#include <functional>
#include <map>
#include <memory>
#include <string>
#include <vector>
#include <vtkCubeAxesActor.h>
#include <vtkImageData.h>
#include <vtkSmartPointer.h>
#include "I3dSceneView.hpp"
#include "model/ColorScale.hpp"
namespace geopro::core { class GeoLocalFrame; }
namespace geopro::render { class Scene; }
class vtkRenderer;
class vtkRenderWindow;
class vtkProp;
namespace geopro::app {
@ -23,17 +33,79 @@ public:
void clear() override;
void setVerticalExaggeration(double ve) override;
void addSurveyLine(const geopro::core::Grid& grid) override;
void addCurtain(const geopro::core::Grid& grid, const geopro::core::ColorScale& cs) override;
void addVolume(const geopro::data::VolumeGrid& vol, const geopro::core::ColorScale& cs) override;
void addCurtain(const std::string& dsId, const geopro::core::Grid& grid,
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 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 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:
// 按当前坐标轴设置 + 场景包围盒重建坐标轴 proprender 末尾调)。
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_;
vtkRenderWindow* renderWindow_;
std::shared_ptr<geopro::core::GeoLocalFrame> frame_;
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;
// 当前坐标轴 proprender 可能多次调用 rebuildAxesrebuild 末尾 + 异步回灌),
// 持引用以便重建前移除旧 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

View File

@ -34,8 +34,11 @@
#include <QFile>
#include <QButtonGroup>
#include <QCheckBox>
#include <QComboBox>
#include <QFrame>
#include <QHBoxLayout>
#include <QPushButton>
#include <QSlider>
#include <QGraphicsOpacityEffect>
#include <QDate>
#include <QLabel>
@ -59,6 +62,7 @@
#include <QStatusBar>
#include <QStyle>
#include <QSurfaceFormat>
#include <QSignalBlocker>
#include <QTimer>
#include <QToolBar>
#include <QTreeWidget>
@ -78,10 +82,10 @@
#include "model/ColorScale.hpp"
#include "model/Field.hpp"
#include "repo/LocalSampleRepository.hpp"
#include "repo/LocalSample3dRepository.hpp"
#include "ApiClient.hpp"
#include "AuthService.hpp"
#include "DatasetDimension.hpp"
#include "Credential.hpp"
#include "Glyphs.hpp"
#include "Logging.hpp"
@ -105,6 +109,7 @@
#include "panels/chart/GridStrategy.hpp"
#include "api/ApiProjectRepository.hpp"
#include "api/ApiDatasetRepository.hpp"
#include "api/Api3dRepository.hpp"
#include "panels/ObjectTreePanel.hpp"
#include "login/LoginWindow.hpp"
#include "panels/DatasetListPanel.hpp"
@ -113,11 +118,18 @@
#include "panels/ObjectAttrPanel.hpp"
#include "panels/DatasetAttrPanel.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 "ColorLutBuilder.hpp"
#include "Scene.hpp"
#include "VoxelFromScatters.hpp"
#include "interact/InteractionManager.hpp"
#include "interact/SlicePlaneMath.hpp"
#include "actors/AnomalyActor.hpp"
#include "actors/CurtainActor.hpp"
#include "actors/ElectrodeActor.hpp"
@ -203,7 +215,7 @@ double median(std::vector<double> v)
// 纵向夸张倍数Z 基准统一M-3全项目共用同一倍数使 帘面(z) / 体素 / 切片 /
// 数据详情剖面(y) / 地形(relief) 的纵向比例一致——避免「剖面×1.5、帘面×3」不一致。
// 单一可调常量:要整体调纵向观感改这一处即可。
constexpr double kVerticalExaggeration = 2.0;
constexpr double kVerticalExaggeration = 1.0;
// 项目 CRS(实证确定,STATUS §4):散点 projX/Y→经纬→GeoLocalFrame 配准体素到世界系。
constexpr const char* kProjectCrs = "EPSG:4547"; // CGCS2000 / 3-degree GK CM 114E
@ -213,6 +225,7 @@ constexpr const char* kWgs84 = "EPSG:4326";
// repo 生命周期须覆盖到事件循环结束(由调用方保证)。
void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& repo,
geopro::data::IAsyncProjectRepository& projectRepo,
geopro::data::IAsyncDatasetRepository& datasetRepo,
geopro::controller::WorkbenchNavController& nav,
geopro::controller::DatasetDetailController& detailCtrl)
{
@ -236,23 +249,34 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
vtkGenericOpenGLRenderWindow* renderWindowPtr = renderWindow.Get();
// 中央渲染编排VtkSceneController + VtkSceneView取代旧 rebuildCentral lambda 与裸 show* 标志)。
// 3D 场景仓储用 LocalSample3dRepository本期样本驱动接口异步将来换 Api 实现不动上层)。
// 3D 场景仓储用 Api3dRepository真实后端loadSection 走真实 ERT 反演端点,委托 datasetRepo)。
// 视图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,
frame, refElev);
auto* sceneCtrl = new geopro::controller::VtkSceneController(repo, *scene3dRepo, *sceneView,
vtkWidget);
sceneCtrl->setVerticalExaggeration(kVerticalExaggeration);
// 非 QObject 堆对象统一在此清理按构造逆序sceneView(持 scene&) → scene3dRepo → scene。
// sceneCtrl 是 vtkWidget 的 QObject 子对象,由 Qt 在 destroyed 前先析构,不再触发信号回灌。)
QObject::connect(vtkWidget, &QObject::destroyed, [scene, scene3dRepo, sceneView]() {
// ── 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 可用性(体素/地形/切片层都需配准):失败则浮层相应勾选禁用并提示。
// PROJ 可用性探测(体素/地形/切片层都需配准):三栏重构后浮层勾选已移除,
// 仅保留探测以便将来在三栏里据此禁用相关项;本期结果暂未消费。
bool crsAvailable = false;
try {
geopro::core::CrsTransform probe(kProjectCrs, kWgs84);
@ -260,6 +284,7 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
} catch (const std::exception&) {
crsAvailable = false;
}
(void)crsAvailable;
// 停靠系统配置(必须在 CDockManager 构造前设置):对齐原型——面板固定、
// 标题栏不显示「关闭 / 浮动 / 标签菜单」等子窗口操作按钮,并关闭自动隐藏(钉住)。
@ -303,67 +328,100 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
return box;
};
// 中央容器:顶部「二维地图/三维视图」工具条 + 下方 QVTK 视图
// 中央容器:顶部「VTK视图」表头 + 下方 [左三栏抽屉 | 右 QVTK 画布]
auto* centerWidget = new QWidget();
auto* centerLayout = new QVBoxLayout(centerWidget);
centerLayout->setContentsMargins(0, 0, 0, 0);
centerLayout->setSpacing(0);
// 「二维地图/三维视图」分段切换表头:与「异常/属性」面板表头同款42px 表头底 + 强调色下划线页签)。
auto seg = geopro::app::buildSegmentedHeader(
{QStringLiteral("二维地图"), QStringLiteral("三维视图")},
{{geopro::app::Glyph::Collapse, QStringLiteral("折叠")},
{geopro::app::Glyph::Download, QStringLiteral("导出")}});
auto* viewHeader = seg.header;
auto* act2D = seg.buttons[0];
auto* act3D = seg.buttons[1];
centerLayout->addWidget(viewHeader);
centerLayout->addWidget(vtkWidget, 1);
// VTK视图面板表头Task 7图标 + 标题「VTK视图」+ 全屏操作按钮(全屏 connect 见 Task 8
auto* viewHeader = geopro::app::buildPanelHeader(
geopro::app::Glyph::Map, QStringLiteral("VTK视图"),
{{geopro::app::Glyph::Fullscreen, QStringLiteral("全屏")}});
// ──「视图详情」图层浮层(对齐原型 3D 视图左上):浮在 QVTK 之上,控制三维图层显隐。
// 仅三维视图显示;含 帘面 / 体素 勾选(体素=两交叉测线散点配准 IDW 的派生层,正确归宿)。
auto* layerPanel = new QFrame(centerWidget);
layerPanel->setFrameShape(QFrame::StyledPanel);
geopro::app::applyTokenizedStyleSheet(
layerPanel,
// 不设 border-radius浮层是 centerWidget 的子控件,悬于原生 GL 画布上,圆角四角处会
// 露出父级浅底(浅色模式下即四个白直角)。改为直角矩形,不透明底铺满整块,无四角伪影。
QStringLiteral("QFrame{background:{{canvas/bg-soft}};border:1px solid {{canvas/grid}};}"
"QCheckBox{padding:2px 1px;color:{{canvas/text}};}"
"QCheckBox:disabled{color:{{canvas/text-dim}};}"));
auto* layerLayout = new QVBoxLayout(layerPanel);
// 浮层内边距取间距令牌:左右 lg(12)、上下 ml(10),对称(原 13/10/15/11 是手调奇数)。
layerLayout->setContentsMargins(geopro::app::space::kLg, geopro::app::space::kMl,
geopro::app::space::kLg, geopro::app::space::kMl);
layerLayout->setSpacing(geopro::app::space::kSm);
auto* layerTitle = new QLabel(QStringLiteral("视图详情"));
geopro::app::applyTokenizedStyleSheet(
layerTitle, QStringLiteral("font-weight:%1;color:{{canvas/text}};border:none;background:transparent;"
"padding-bottom:3px;font-size:%2px;")
.arg(geopro::app::type::kWeightSemibold)
.arg(geopro::app::scaledPx(geopro::app::type::kTitle)));
auto* chkCurtain = new QCheckBox(QStringLiteral("帘面(断面墙)"));
chkCurtain->setChecked(true);
auto* chkVoxel = new QCheckBox(QStringLiteral("体素dd_voxel"));
chkVoxel->setChecked(false);
auto* chkTerrain = new QCheckBox(QStringLiteral("地形DEM+影像)"));
chkTerrain->setChecked(false);
auto* chkSlice = new QCheckBox(QStringLiteral("切片dd_slice"));
chkSlice->setChecked(false);
if (!crsAvailable) { // PROJ 不可用 → 体素/地形层(都需配准)禁用并提示
const QString tip = QStringLiteral("PROJ 数据(proj.db)缺失,配准不可用");
chkVoxel->setEnabled(false); chkVoxel->setToolTip(tip);
chkTerrain->setEnabled(false); chkTerrain->setToolTip(tip);
}
// 切片dd_slice交互切片留待 P3本轮禁用。
chkSlice->setEnabled(false);
chkSlice->setToolTip(QStringLiteral("(切片交互 P3 接入)"));
layerLayout->addWidget(layerTitle);
layerLayout->addWidget(chkCurtain);
layerLayout->addWidget(chkVoxel);
layerLayout->addWidget(chkSlice);
layerLayout->addWidget(chkTerrain);
layerPanel->setVisible(false); // 默认二维,不显示图层浮层
// 左侧内嵌三栏抽屉(自带折叠按钮)+ 右侧 GL 画布,水平并列(非 GL 覆盖层,避免 z-order/圆角伪影)。
auto* drawer = new geopro::app::ColumnDrawer(centerWidget);
auto* viewRow = new QHBoxLayout();
viewRow->setContentsMargins(0, 0, 0, 0);
viewRow->setSpacing(0);
viewRow->addWidget(drawer); // 左侧抽屉(自带折叠按钮)
viewRow->addWidget(vtkWidget, 1); // 右侧 GL 画布
centerLayout->addWidget(viewHeader);
centerLayout->addLayout(viewRow, 1);
// 体素变化(重建/清场)后把体素 image 推给 InteractionManager切片基底
sceneView->onVolumeChanged = [interactionMgr, sceneView]() {
if (sceneView->hasVolume())
interactionMgr->setVolumeImage(sceneView->currentVolumeImage(),
sceneView->currentColorScale(), sceneView->currentVmin(),
sceneView->currentVmax());
else
interactionMgr->setVolumeImage(nullptr, sceneView->currentColorScale(), 0.0, 0.0);
};
// ── 三栏抽屉信号 → 控制器/交互Task 7 接线)──────────────────────────────
auto* c3 = drawer->col3D();
QObject::connect(c3, &geopro::app::Column3DDataset::axesModeChanged, sceneCtrl,
&geopro::controller::VtkSceneController::setAxesMode);
QObject::connect(c3, &geopro::app::Column3DDataset::axesUnitChanged, sceneCtrl,
&geopro::controller::VtkSceneController::setAxesUnit);
QObject::connect(c3, &geopro::app::Column3DDataset::verticalExaggerationChanged, sceneCtrl,
&geopro::controller::VtkSceneController::setVerticalExaggeration);
QObject::connect(c3, &geopro::app::Column3DDataset::viewRequested, sceneCtrl,
&geopro::controller::VtkSceneController::applyView);
QObject::connect(c3, &geopro::app::Column3DDataset::zoomInRequested, sceneCtrl,
&geopro::controller::VtkSceneController::zoomIn);
QObject::connect(c3, &geopro::app::Column3DDataset::zoomOutRequested, sceneCtrl,
&geopro::controller::VtkSceneController::zoomOut);
QObject::connect(c3, &geopro::app::Column3DDataset::fitRequested, sceneCtrl,
&geopro::controller::VtkSceneController::fit);
// 渲染勾选的 3D 数据集:真实 ds id 直达控制器异步帘面路径
// setCheckedDatasets → Api3dRepository.loadSection(realId) → 真实 ERT 反演端点 → 真实帘面)。
QObject::connect(c3, &geopro::app::Column3DDataset::checkedDatasetsChanged, sceneCtrl,
&geopro::controller::VtkSceneController::setCheckedDatasets);
// O点位置/字体本期 stubTODO P4弹框
QObject::connect(c3, &geopro::app::Column3DDataset::oPointClicked, vtkWidget,
[]() { /* TODO P4: O点位置弹框 */ });
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 时,引导首次使用者从左侧入手。──
// 透明背景 + 鼠标穿透(不挡 QVTK 交互CenterOverlay 随视口尺寸保持居中;
@ -411,7 +469,7 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
auto* emptyCentering = new CenterOverlay(emptyState, vtkWidget);
emptyCentering->reposition();
auto* vtkDock = new ads::CDockWidget(QStringLiteral("二维地图/三维视图"));
auto* vtkDock = new ads::CDockWidget(QStringLiteral("VTK视图"));
vtkDock->setWidget(centerWidget);
auto* centerDockArea = dockManager->addDockWidget(ads::CenterDockWidgetArea, vtkDock);
@ -422,9 +480,10 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
// ForceNoScrollArea禁止 ADS 默认把整块内容(含标题栏/页签栏)包进 QScrollArea。
// 否则内容最小尺寸超过 dock 时,整个面板(标题+页签+图)一起滚动。改为内容自适应填充;
// 需要时由内层(图表内容区)自行滚动,标题/页签固定。
detailDock->setWidget(wrapWithHeader(
geopro::app::Glyph::Detail, QStringLiteral("数据详情"), detailPanel),
ads::CDockWidget::ForceNoScrollArea);
auto* detailHeader = wrapWithHeader(
geopro::app::Glyph::Detail, QStringLiteral("数据详情"), detailPanel,
{{geopro::app::Glyph::Fullscreen, QStringLiteral("全屏")}});
detailDock->setWidget(detailHeader, ads::CDockWidget::ForceNoScrollArea);
// 放在中央视图下方。
dockManager->addDockWidget(ads::BottomDockWidgetArea, detailDock, centerDockArea);
@ -565,52 +624,49 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
}
});
// 「视图详情」浮层显隐:仅三维显示,置于 QVTK 左上(工具条下方)并置顶。
auto showLayerPanel = [layerPanel, viewHeader](bool show3D) {
if (show3D) {
layerPanel->move(14, viewHeader->height() + 12);
layerPanel->adjustSize();
layerPanel->setVisible(true);
layerPanel->raise();
} else {
layerPanel->setVisible(false);
// ── 左上对象树勾选 → 拉取各 TM 的 ds 子树按维度分发到三栏列表spec §6.1/§8──
// 渲染由三栏勾选框驱动Task 7Column3DDataset::checkedDatasetsChanged → setCheckedDatasets
auto generation = std::make_shared<unsigned long long>(0);
QObject::connect(
objectTree, &geopro::app::ObjectTreePanel::checkedTmsChanged, &window,
[&projectRepo, &nav, drawer, emptyState, generation](const QStringList& tmIds) {
const unsigned long long myGen = ++(*generation);
emptyState->setVisible(tmIds.isEmpty()); // 有勾选→隐藏引导层,露出中央渲染
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);
};
// ── 工具条「二维地图/三维视图」:切换互斥视图 → 控制器重建 + 图层浮层显隐 ──
using geopro::controller::SceneLayer;
using CtrlViewMode = geopro::controller::ViewMode;
QObject::connect(act2D, &QAbstractButton::clicked, vtkWidget,
[sceneCtrl, showLayerPanel]() {
showLayerPanel(false);
sceneCtrl->setViewMode(CtrlViewMode::Map2D);
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(act3D, &QAbstractButton::clicked, vtkWidget,
[sceneCtrl, showLayerPanel]() {
showLayerPanel(true);
sceneCtrl->setViewMode(CtrlViewMode::View3D);
QObject::connect(req, &geopro::data::NavRequest::failed, drawer,
[remaining, finish](const QString&) {
if (--(*remaining) == 0) finish(); // 单个失败不卡死,其余照常分发
});
}
});
// ──「视图详情」图层勾选 → 控制器更新图层 → 重建中央 ──
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);
// ── 启动:建立一次中央视图。三栏重构后删除了 2D/3D 切换,统一固定为三维视图
// (帘面默认开启 showCurtain_=true勾选 dd_section → 帘面)。无勾选 → 空场景 + 背景。
sceneCtrl->setViewMode(geopro::controller::ViewMode::View3D);
// VTK 背景随主题切换:控制器重渲染(走完整渲染路径、末尾必 Render
// context 用 sceneCtrl非 windowThemeManager 是进程级单例,连接须随 sceneCtrl 析构自动断开,
@ -731,6 +787,49 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
return nullptr;
};
// ── 全屏切换VTK视图 / 数据详情 表头右上角「全屏」按钮 ──────────────────────────
// 点击 → 目标 dock 全屏(隐藏其余所有 dock再点 → 还原(全部显示)。
// 使用 ADS CDockWidget::toggleView(bool) 控制可见性(标准 ADS APIv4+)。
{
const QList<ads::CDockWidget*> allDocks{vtkDock, detailDock, leftDock, datasetDock,
rightDock, propDock};
auto applyFullscreen = [](ads::CDockWidget* target,
const QList<ads::CDockWidget*>& all, bool on) {
for (ads::CDockWidget* d : all) {
if (d == target) continue;
d->toggleView(!on); // on=进入全屏→隐藏其它; off=还原→全部显示
}
};
auto* vtkFsBtn = findHeaderAction(viewHeader, geopro::app::Glyph::Fullscreen);
auto* detailFsBtn = findHeaderAction(detailHeader, geopro::app::Glyph::Fullscreen);
if (vtkFsBtn) {
vtkFsBtn->setCheckable(true);
QObject::connect(vtkFsBtn, &QToolButton::toggled, &window,
[applyFullscreen, vtkDock, allDocks, detailFsBtn, drawer](bool on) {
if (on && detailFsBtn && detailFsBtn->isChecked()) {
QSignalBlocker b(detailFsBtn);
detailFsBtn->setChecked(false);
}
// VTK 全屏含左侧三栏(drawer 本就在 vtkDock 内):进入时确保展开可见。
if (on) drawer->expand();
applyFullscreen(vtkDock, allDocks, on);
});
}
if (detailFsBtn) {
detailFsBtn->setCheckable(true);
QObject::connect(detailFsBtn, &QToolButton::toggled, &window,
[applyFullscreen, detailDock, allDocks, vtkFsBtn](bool on) {
if (on && vtkFsBtn && vtkFsBtn->isChecked()) {
QSignalBlocker b(vtkFsBtn);
vtkFsBtn->setChecked(false);
}
applyFullscreen(detailDock, allDocks, on);
});
}
}
// 对象树右键菜单动作路由。
QObject::connect(
objectTree, &geopro::app::ObjectTreePanel::contextActionRequested, &window,
@ -1138,7 +1237,7 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
if (!geo.isEmpty()) window.restoreGeometry(geo);
// 注意ADS 按 dock 唯一名作键。改过 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()) {
dockManager->restoreState(dockState);
// restoreState 重建停靠区会重新显示 ADS 标题栏,再隐藏一次保持“无双标题”。
@ -1149,7 +1248,7 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
QObject::connect(qApp, &QCoreApplication::aboutToQuit, dockManager, [dockManager, &window]() {
QSettings settings;
settings.setValue(QStringLiteral("ui/geometry"), window.saveGeometry());
settings.setValue(QStringLiteral("ui/dockState_v2"), dockManager->saveState());
settings.setValue(QStringLiteral("ui/dockState_v3"), dockManager->saveState());
});
}
@ -1278,7 +1377,7 @@ int main(int argc, char* argv[])
window->setWindowTitle(kTitle);
window->resize(1280, 800);
window->setMinimumSize(1024, 680);
buildWorkbench(*window, repo, projectRepo, nav, detailCtrl);
buildWorkbench(*window, repo, projectRepo, datasetRepo, nav, detailCtrl);
// 主题桥ThemeManager 明/暗切换 → 重应用全局 QSS+调色板(标准控件 + ADS内联 chrome 经各自连接)。
QObject::connect(&geopro::app::ThemeManager::instance(), &geopro::app::ThemeManager::changed,

View File

@ -1,14 +1,18 @@
#include "panels/DatasetListPanel.hpp"
#include <QAbstractItemView>
#include <QApplication>
#include <QColor>
#include <QHash>
#include <QKeyEvent>
#include <QListWidget>
#include <QListWidgetItem>
#include <QMouseEvent>
#include <QObject>
#include <QPainter>
#include <QPainterPath>
#include <QString>
#include <QStyle>
#include <QStyledItemDelegate>
#include <QTreeWidget>
#include <QTreeWidgetItem>
@ -77,6 +81,23 @@ public:
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;
const int nl = disp.indexOf(QLatin1Char('\n'));
if (nl >= 0) {
@ -84,7 +105,7 @@ public:
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;
tf.setPixelSize(geopro::app::scaledPx(13));
@ -106,6 +127,34 @@ public:
}
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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -1,10 +1,19 @@
#pragma once
#include <string>
#include "model/ColorScale.hpp"
#include "model/Field.hpp"
#include "repo/I3dSceneRepository.hpp"
namespace geopro::controller {
// 坐标轴显示方式spec §4 C3I3标准 / 三维立体 / 不显示。
enum class AxesMode { Standard, Stereo, None };
// 坐标轴刻度单位spec §4 D5I5无 / 米 / 英尺 / 经纬度。
enum class AxesUnit { None, Meter, Feet, LatLon };
// 快捷视图方向spec §4 C7前/后/左/右/上/下。
enum class ViewDir { Front, Back, Left, Right, Top, Bottom };
// 三维场景视图抽象(编排层与 VTK 渲染解耦的缝):
// VtkSceneController 只发出"清场 / 加某类图元 / 提交渲染"指令,不认 vtkActor/vtkVolume
// 真实实现VtkSceneView调 render actor + Scene测试用 fake 记录调用断言编排。
@ -18,17 +27,32 @@ public:
// 2D俯视测线红线z=0
virtual void addSurveyLine(const geopro::core::Grid& grid) = 0;
// 3D竖直帘面grid + colorScale 着色)
virtual void addCurtain(const geopro::core::Grid& grid,
// 3D竖直帘面grid + colorScale 着色);按 dsId 跟踪以支持增量移除
virtual void addCurtain(const std::string& dsId, const geopro::core::Grid& grid,
const geopro::core::ColorScale& cs) = 0;
// 3D体绘制IDW 体素 + colorScale
virtual void addVolume(const geopro::data::VolumeGrid& vol,
// 3D体绘制IDW 体素 + colorScale;按 dsId 跟踪
virtual void addVolume(const std::string& dsId, const geopro::data::VolumeGrid& vol,
const geopro::core::ColorScale& cs) = 0;
// 3DDEM 地形 + 影像纹理。
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;
// 缩放P2factor>1 放大、<1 缩小,提交渲染。
virtual void zoom(double factor) = 0;
// 适配全览P2ResetCamera 并提交渲染。
virtual void fitView() = 0;
// 应用相机预设2D 俯视 / 3D 自由)并提交渲染(全量重建用,会 ResetCamera
virtual void render(bool is2D) = 0;
// 增量提交渲染:重建坐标轴并刷新,但不动相机(勾选/取消单个 ds 时视角不跳)。
virtual void renderIncremental() = 0;
};
} // namespace geopro::controller

View File

@ -1,5 +1,7 @@
#include "VtkSceneController.hpp"
#include <algorithm>
#include <set>
#include <utility>
#include <QPointer>
@ -15,10 +17,85 @@ VtkSceneController::VtkSceneController(data::IDatasetRepository& dsRepo,
: QObject(parent), dsRepo_(dsRepo), sceneRepo_(sceneRepo), view_(view) {}
void VtkSceneController::setCheckedDatasets(const QStringList& dsIds) {
checkedDs_.clear();
checkedDs_.reserve(static_cast<std::size_t>(dsIds.size()));
for (const QString& id : dsIds) checkedDs_.push_back(id.toStdString());
std::vector<std::string> newDs;
newDs.reserve(static_cast<std::size_t>(dsIds.size()));
for (const QString& id : dsIds) newDs.push_back(id.toStdString());
// 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) {
@ -42,6 +119,22 @@ void VtkSceneController::setVerticalExaggeration(double ve) {
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) {
auto it = gridCache_.find(dsId);
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() {
const unsigned long long gen = ++rebuildGeneration_;
const unsigned long long gen = ++rebuildGeneration_; // 自增:作废此前所有在途增量回调
const bool is2D = (mode_ == ViewMode::Map2D);
view_.clear();
view_.clear(); // 移除全部数据图元(保留底图)frame 重锚标志复位
loadingDs_.clear(); // 旧在途加载随之作废(回调按 gen 丢弃)
view_.setVerticalExaggeration(verticalExaggeration_);
// 坐标轴设置在 clear 后下发render 末尾据当前场景包围盒重建坐标轴 prop。
view_.setAxes(axesMode_, axesUnit_, kAxesFontSize);
fitOnArrival_ = true; // 全量重建:到场数据自动取景
inRebuild_ = true;
// 坏 dsIdloadGrid/loadColorScale 抛异常)= best-effort 跳过emit loadFailed 但不中断,
// 其余勾选数据集照常渲染(非 fail-fast
// 坏 dsIdloadGrid/loadColorScale 抛异常)= best-effort 跳过emit loadFailed 但不中断。
try {
if (is2D) {
for (const auto& dsId : checkedDs_) view_.addSurveyLine(grid(dsId));
} else {
// 回调用 QPointer<self> 守对象存活(控制器是 QObject+ gen 守数据新鲜:
// 将来 Api 实现在网络线程迟到回调时self 已析构则直接丢弃,不触 dangling。
// 回调用 QPointer<self> 守对象存活 + gen 守数据新鲜:迟到回调若已析构/作废则丢弃。
QPointer<VtkSceneController> self(this);
if (showTerrain_) {
sceneRepo_.loadTerrainPaths(
[self, gen](data::TerrainPaths p) {
if (!self || gen != self->rebuildGeneration_) return; // 已析构/迟到:丢弃
self->view_.addTerrain(std::move(p));
if (!self->inRebuild_) self->view_.render(false); // 同步路径由末尾统一 render
self->onDatasetArrived();
},
[self, gen](const std::string& m) {
if (!self || gen != self->rebuildGeneration_) return;
emit self->loadFailed(QString::fromStdString(m));
});
}
if (showCurtain_) {
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));
});
}
}
for (const auto& dsId : checkedDs_) addDatasetAsync(dsId, gen);
}
} catch (const std::exception& e) {
emit loadFailed(QString::fromStdString(e.what()));
}
inRebuild_ = false;
view_.render(is2D);
view_.render(is2D); // 设背景/相机预设/坐标轴 + ResetCamera数据到场再由 onDatasetArrived 取景)
}
} // namespace geopro::controller

View File

@ -4,8 +4,10 @@
#include <QStringList>
#include <map>
#include <optional>
#include <set>
#include <string>
#include "I3dSceneView.hpp"
#include "model/ColorScale.hpp"
#include "model/Field.hpp"
#include "repo/I3dSceneRepository.hpp"
@ -16,8 +18,6 @@ class IDatasetRepository;
namespace geopro::controller {
class I3dSceneView;
// 中央视图模式:二维地图(俯视测线)/ 三维视图(帘面/体素/地形)。
enum class ViewMode { Map2D, View3D };
@ -42,11 +42,23 @@ public slots:
void setVerticalExaggeration(double ve);
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:
void loadFailed(const QString& message);
private:
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::I3dSceneRepository& sceneRepo_;
@ -57,19 +69,25 @@ private:
bool showCurtain_ = true;
bool showVoxel_ = 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避免重复读盘/插值。
std::map<std::string, geopro::core::Grid> gridCache_;
std::map<std::string, geopro::core::ColorScale> colorScaleCache_;
std::map<std::string, data::VolumeGrid> volumeCache_;
// 异步回灌防护:每次 rebuild 自增,回调比对丢弃迟到结果。
// 异步回灌防护:每次全量 rebuild 自增,回调比对丢弃迟到结果。
unsigned long long rebuildGeneration_ = 0;
// rebuild 进行中标志同步回调LocalSample在 rebuild 内立即触发时跳过自身 render
// 由 rebuildInternal 末尾统一 render 覆盖(避免双重 ResetCamera/Render
// 真异步回调迟到时 inRebuild_ 已 false → 自行 render 追加。
bool inRebuild_ = false;
// 增量渲染状态:本批数据到场是否自动取景(全量重建/从空开始=true增量追加=false保持相机
bool fitOnArrival_ = true;
// 正在加载的 ds防重复勾选竞态重复请求全量重建时清空。
std::set<std::string> loadingDs_;
const geopro::core::Grid& grid(const std::string& dsId);
const geopro::core::ColorScale& colorScale(const std::string& dsId);

View File

@ -17,8 +17,19 @@ GeoLocalFrame::GeoLocalFrame(double lat0, double lon0)
mPerDegLon_(kMetersPerDegLonEquator * std::cos(lat0 * kPi / 180.0)),
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 {
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

View File

@ -2,13 +2,19 @@
namespace geopro::core {
struct LocalXY { double x, y; };
struct LatLon { double lat, lon; };
// 等距圆柱(equirectangular)近似:把经纬度投到以(lat0,lon0)为原点的局部米平面。
// 小范围测区足够x=East、y=North(米)。
class GeoLocalFrame {
public:
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)
// toLocal 的反算:局部米 (x East, y North) -> 经纬度。
// lon = lon0 + x/mPerDegLonlat = lat0 + y/mPerDegLat坐标轴经纬度刻度用
LatLon toLatLon(double x, double y) const;
private:
double lat0_, lon0_, mPerDegLon_, mPerDegLat_;
};

View File

@ -12,6 +12,7 @@ add_library(geopro_data STATIC
dto/GridDto.cpp
api/ApiProjectRepository.cpp
api/ApiDatasetRepository.cpp
api/Api3dRepository.cpp
api/DatasetLoadHandles.cpp
api/NavRequest.cpp)
target_include_directories(geopro_data PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})

View File

@ -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

View File

@ -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

View File

@ -19,6 +19,10 @@ Grid parseInversionGrid(const QJsonObject& data) {
Grid g(nx < 1 ? 1 : nx, ny < 1 ? 1 : ny);
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));
// 经纬度(每列 [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记录便于排查非静默
qWarning("parseInversionGrid: v rows=%d != ny=%d (缺失行将填 NaN)", v.size(), ny);
for (int j = 0; j < ny; ++j) {

View File

@ -1,8 +1,12 @@
#pragma once
#include <array>
#include <functional>
#include <map>
#include <string>
#include <vector>
#include "model/Anomaly.hpp"
#include "model/ColorScale.hpp"
#include "model/Field.hpp"
#include "repo/RepoTypes.hpp"
@ -25,13 +29,19 @@ struct TerrainPaths {
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
// 取数方法走回调 std::functionLocalSample 本地数据同步算好后直接回调;
// 将来 Api3dRepository 在网络完成时回调,上层不变)。
// **契约onOk/onErr 必须在主(GUI)线程调用**——上层(VtkSceneController)回调内直接操作
// 场景/发 Qt 信号依赖主线程亲和Api 实现若在工作线程完成须 post 回主线程再回调。
// dimensionOf 是同步纯函数(无 I/O只做类型→维度映射
// 切片/异常/任务等签名本期不在接口内(留 P3/P4
// 切片/异常/任务接口已按 spec §6.36.5 完整设计见下方LocalSample 内存态 stub将来 Api3dRepository 换实现
class I3dSceneRepository {
public:
using OnError = std::function<void(const std::string& message)>;
@ -45,8 +55,87 @@ public:
virtual void loadVolume(const std::string& dsId,
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/影像路径。
virtual void loadTerrainPaths(std::function<void(TerrainPaths)> onOk, OnError onErr) = 0;
// ── 切片数据集 CRUDspec §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.36.5 完整设计;
// LocalSample3dRepository 提供内存态 stub真实后端 = 将来 Api3dRepository上层不变
} // namespace geopro::data

View File

@ -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,
OnError onErr) {
try {
@ -156,4 +169,69 @@ void LocalSample3dRepository::loadTerrainPaths(std::function<void(TerrainPaths)>
}
}
// ── 切片 CRUDspec §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

View File

@ -1,6 +1,8 @@
#pragma once
#include <functional>
#include <map>
#include <string>
#include <vector>
#include "repo/I3dSceneRepository.hpp"
@ -23,12 +25,47 @@ public:
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;
// 切片 CRUDspec §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:
LocalSampleRepository& base_;
std::string projectCrs_;
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

View File

@ -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)
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_link_libraries(geopro_render PUBLIC geopro_core ${VTK_LIBRARIES} GDAL::GDAL)
target_compile_features(geopro_render PUBLIC cxx_std_17)

View File

@ -37,4 +37,54 @@ void applyFree3D(vtkRenderer* r)
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

View File

@ -8,4 +8,20 @@ void applyTop2D(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

View File

@ -7,7 +7,7 @@
- `actors/` — ScatterActor, GridContourActor, VoxelVolumeActor, AnomalyActor, TerrainActor
- `color/` — ColorLutBuildercolorBar → 离散 vtkLookupTable, ScalarBar
- `camera/` — CameraPresetTop2D / Free3D
- `interact/`InteractionManager + InteractionToolMeasure/Slice/PickSelect切片用 vtkResliceCursorWidget
- `interact/`SlicePlaneMath纯几何可测+ SliceToolvtkImagePlaneWidget轴向 + 任意 45° reslice 着色剖面)+ PickInteractorStyle拾取/双击正视/滚轮)+ InteractionManager持切片/选中态/分发)。切片走 vtkImageReslice 路线vtkImagePlaneWidget 内部 reslice + 纹理),非 vtkCutterspec §9.1
- `ground/` — IGroundLayer + DemImageGroundLayerM1TileGroundLayerM1.5
网格管线:`vtkImageData(+vtkWarpScalar) → vtkDataSetSurfaceFilter → vtkBandedPolyDataContourFilter(GenerateContourEdgesOn)`(设计 §4.3)。

View File

@ -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

View File

@ -0,0 +1,44 @@
#pragma once
#include <vtkSmartPointer.h>
#include "geo/GeoLocalFrame.hpp"
class vtkCubeAxesActor;
class vtkRenderer;
namespace geopro::render {
// 坐标轴显示方式spec §4 C3I3
// Standard 标准 = vtkCubeAxesActor 包围盒 + 刻度(外侧最近轴显示刻度)。
// Stereo 三维立体 = vtkCubeAxesActor 闭合立方(四周/网格更完整)。语义待 1.0 确认,先合理近似。
// None 不显示 = 不构建(返回 nullptr
enum class AxesMode { Standard, Stereo, None };
// 刻度单位spec §4 D5I5
// 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。
// cameravtkCubeAxesActor 需绑定相机(决定外侧刻度轴);可空(测试场景)。
vtkSmartPointer<vtkCubeAxesActor> buildAxes(const double bounds[6], const AxesOptions& opts,
vtkRenderer* renderer);
// 单位换算系数米→目标单位。LatLon 不是线性系数X/Y 分别反算),此处仅供米/英尺;
// 暴露为可测纯函数。
double unitScaleFactor(AxesUnit unit);
} // namespace geopro::render

View File

@ -9,6 +9,8 @@
#include <vtkPolyDataMapper.h>
#include <vtkStructuredGrid.h>
#include <algorithm>
#include <cmath>
#include <vector>
#include <cstddef>
@ -47,6 +49,10 @@ vtkSmartPointer<vtkActor> buildCurtain(const geopro::core::Grid& g,
sc->SetName("v");
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 i = 0; i < nx; ++i) {
double px, py;
@ -60,17 +66,38 @@ vtkSmartPointer<vtkActor> buildCurtain(const geopro::core::Grid& g,
py = 0.0;
}
const vtkIdType id = static_cast<vtkIdType>(j) * nx + i;
// g.y 是深度(越大越深)VTK Z 向上 → 取负,使深部在下、浅部在上(剖面不倒置)。
points->SetPoint(id, px, py, -g.y[j]);
sc->SetValue(id, g.valueAt(i, j));
// g.y 是真实高程(米,越大越高,与原版 web data.y 同义):直接作世界 Z使剖面落在
// 真实海拔上,与同样按真实高程渲染的地形对齐(剖面顶≈地表→露出地面,复刻原版)。
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->GetPointData()->SetScalars(sc);
// 消隐无数据点vtkStructuredGrid 消隐(ghost)→ 含该点的 cell 不被 vtkDataSetSurfaceFilter 输出,
// 故 NaN 永不进入 banded contour。282/1900 有效的不规则反演区→渲染出正确的梯形帘面。
for (vtkIdType id : blanks) sgrid->BlankPoint(id);
// 用 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;
if (stops.size() >= 2) { vmin = stops.front(); vmax = stops.back(); }
else {
@ -85,7 +112,7 @@ vtkSmartPointer<vtkActor> buildCurtain(const geopro::core::Grid& g,
auto lut = buildLut(cs, vmin, vmax, kLutLevels);
// structuredGrid → 表面 polydata → banded contour(分段色带)
// structuredGrid → 表面 polydata(消隐格已剔除) → banded contour(分段色带,色带#18)
vtkNew<vtkDataSetSurfaceFilter> surf;
surf->SetInputData(sgrid);
vtkNew<vtkBandedPolyDataContourFilter> banded;

View File

@ -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

View File

@ -0,0 +1,22 @@
#pragma once
// Web Mercator(EPSG:3857) 瓦片坐标数学:天地图/XYZ 底图瓦片定位用(纯函数,无 VTK/Qt 依赖)。
// 标准 slippy-map 公式n=2^zx=(lon+180)/360*ny 用墨卡托纬度映射。
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

View File

@ -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 跳过 RenderQt 拆台时窗口可能已半析构)
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

View File

@ -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:
// interactorQVTK 提供的活 interactorrenderWindow->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

View File

@ -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/focalup 只转不平移。
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

View File

@ -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

View File

@ -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

View File

@ -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 F22F24
// 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

View File

@ -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 接入 widgetwidget 只暴露 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角度不可调符合 G22G24
// 上下=水平面=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°F25vtkImagePlaneWidget 用 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

View File

@ -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° 法向,允许旋转。
// 套上调用方提供的色阶 LUTColorLutBuilder
//
// 生命周期:构造即 SetInteractor + On()(须传活的 interactor
// 析构(或 close())时 Off(),由 vtkSmartPointer 释放,避免悬挂观察者崩溃。
// 仅三维视图使用;切到二维由 InteractionManager 统一 close。
class SliceTool {
public:
// image体素管线产物含 VE 烤入的 origin/spacing。interactorQVTK 的活 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;
// 沿法向推进切面滚轮D46origin += 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

View File

@ -77,7 +77,7 @@ endif()
# render ColorLutBuildercore ColorScale -> vtkLookupTable
# vtkLookupTableVTK::CommonCoregeopro_render PUBLIC VTK
find_package(VTK REQUIRED COMPONENTS CommonCore CommonDataModel RenderingCore)
find_package(VTK REQUIRED COMPONENTS CommonCore CommonDataModel RenderingCore RenderingAnnotation FiltersSources)
# SceneaddActor/addViewProp + clear vtkVolume addViewProp
target_sources(geopro_tests PRIVATE render/test_scene.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)
# TerrainbuildTerrain(GDAL dem/image + 重投影 warp 面+纹理) /缺文件安全( PROJ_DATA)
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})
vtk_module_autoinit(TARGETS geopro_tests MODULES ${VTK_LIBRARIES})
@ -116,6 +124,11 @@ target_sources(geopro_tests PRIVATE
)
# hover inline qwt/ ScatterHoverTip
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
find_package(Qt6 COMPONENTS Test REQUIRED)

View File

@ -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());
}

View File

@ -1,7 +1,9 @@
#include <gtest/gtest.h>
#include <functional>
#include <map>
#include <string>
#include <utility>
#include <vector>
#include "I3dSceneView.hpp"
@ -27,17 +29,54 @@ struct FakeView : I3dSceneView {
bool lastIs2D = false;
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 {
++clears;
surveyLines = curtains = volumes = terrains = 0;
perDs.clear();
}
void setVerticalExaggeration(double v) override { ve = v; }
void addSurveyLine(const core::Grid&) override { ++surveyLines; }
void addCurtain(const core::Grid&, const core::ColorScale&) override { ++curtains; }
void addVolume(const data::VolumeGrid&, const core::ColorScale&) override { ++volumes; }
void addCurtain(const std::string& dsId, const core::Grid&, const core::ColorScale&) override {
++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 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 renderIncremental() override { ++renders; }
int props() const { return surveyLines + curtains + volumes + terrains; }
};
@ -75,9 +114,39 @@ struct FakeSceneRepo : data::I3dSceneRepository {
g.vmin = 0.0; g.vmax = 1.0;
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 {
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
@ -132,19 +201,33 @@ TEST(VtkSceneController, View3DWithTerrainAddsTerrain) {
EXPECT_EQ(view.curtains, 1);
}
// 取消勾选 → clear 后无任何图元
TEST(VtkSceneController, UncheckAllClearsScene) {
// 取消勾选 → 增量移除该 ds 图元(不整场 clear3D 增量路径)
TEST(VtkSceneController, UncheckRemovesDatasetIncrementally) {
FakeDsRepo ds; FakeSceneRepo sc; FakeView view;
VtkSceneController c(ds, sc, view);
c.setViewMode(ViewMode::View3D);
c.setCheckedDatasets({"ds1"});
ASSERT_EQ(view.curtains, 1);
const int clearsAfterCheck = view.clears;
c.setCheckedDatasets({}); // 取消全部勾选
c.setCheckedDatasets({}); // 取消全部勾选 → 增量移除 ds1
EXPECT_EQ(view.curtains, 0);
EXPECT_EQ(view.volumes, 0);
// 最后一次重建仍调用 clear。
EXPECT_GE(view.clears, 2);
EXPECT_EQ(view.clears, clearsAfterCheck); // 增量取消不触发整场 clear
}
// 增量追加:已勾选 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"});
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);
}

View File

@ -37,3 +37,25 @@ TEST(GeoFrame, NorthwardLatitudeGivesPositiveY) {
EXPECT_NEAR(p.y, expected, expected * 0.05);
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);
}

105
tests/render/test_axes.cpp Normal file
View File

@ -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 → lon0x=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); // 米回退
}

View File

@ -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),视线朝 -ZviewUp=+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视线朝 +YviewUp=+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);
}

View File

@ -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 F22F24+ 任意 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视线 = -YviewUp = +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);
}
// 法向 +Xposition=focal+X*distviewUp 偏 +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); }

View File

@ -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);
}