Compare commits

..

36 Commits

Author SHA1 Message Date
gaozheng 868cc4daba Merge pull request 'fix/3d-volume-blanking-mask' (#8) from fix/3d-volume-blanking-mask into main
Reviewed-on: https://gitea.geomative.cn/gaozheng/geopro/pulls/8
2026-06-28 22:37:24 +08:00
gaozheng e6fb087a7f docs(detail-view): 新增数据集详情视图架构与扩展指南
给同事无缝接手"新增一种 ds 类型详情页"的交接文档:端到端数据流、5 个核心
抽象(ViewKind/TabSpec/策略+注册表/IDetailView/payload+DetailLoad)、现有 5 种
类型对照表、分层职责(带 file:line)、扩展配方(5A 复用视图/5B 全新视图 + 代码
骨架)、关键约定与坑、触碰文件速查表、自测建议。基于精读全链路 + Explore 代理
交叉验证。
2026-06-28 22:34:54 +08:00
gaozheng eef8188bcb feat(3d): 色阶跨视图同步真源 + 三维体/切片白化与不透明度重做
- 跨视图色阶单一真源 DatasetViewState:2D详情/3D帘面体共用按dsId的色阶,编辑→真源
  →各视图实时联动且无信号回环;散点(type1) load-then-save 回写,避免覆盖网格的
  lineConfig/层级方案(共享同一条 businessCode="" 后端记录)。
- 色阶两级透明度:ColorScale.globalOpacity 独立存储不烘焙、渲染时与每色 alpha 相乘;
  对话框回显真实值、单色独立;properties 补全 lvlSchemeType/logLinesCount/
  equalAreaLayerCount,避免整条覆盖写清空;"整体透明度"改名"不透明度"、显示 0~100。
- 切片白化:SetWindowLevel 钉死 [vmin,vmax] + LUT 0号白化槽,哨兵真透明
  (tests/spike/slice_alpha_probe.cpp 真 widget 离屏实测);同时纠正切片颜色映射。
- 切片不透明度:与三维体解耦的独立模型(100%/三维体+50%/跟随),默认100%;保存切片建
  自己的色阶对象(颜色快照+不透明度),已保存切片走列表右键"色阶"编辑自身。
- 三维体白化:二值 mask 真白化(NoData 排除出插值,符合 ESRI/GDAL/Surfer 标准);改体
  色阶改为原地更新传函(不重建image),未保存切片不再被刷掉且跟随改色;GPU 探测+CPU
  回退;体不透明度归一为色阶"不透明度"单一控制(去 kMaxOpacity、移除工具条"透"滑块)。
- 持久化:网格视图补 saveColorGradation;DatasetChartDto.parseColorBar 回读 opacity。

详见 docs/superpowers/specs/2026-06-27-inversion-3d-volume-surfer-method-and-gaps.md §7。
2026-06-28 22:14:46 +08:00
gaozheng c653a659b2 Merge pull request 'feat/vtk-3d-view' (#7) from feat/vtk-3d-view into main
Reviewed-on: https://gitea.geomative.cn/gaozheng/geopro/pulls/7
2026-06-27 18:43:52 +08:00
gaozheng 4f6abf0c83 feat(3d-view): 三维体渲染稳定性修复 + 透明度可调/交互优化
- 修偶发"不渲染/淡蓝/很实"根因:合并体值域取"首个到达源色阶"随网络到达
  顺序抖动→改取所有源色阶 vmax 中位者(确定性+抗单线离群)
- 体素标量 double→float:GPU 体绘制对 double 处理不稳/间歇出空,float 更稳且省显存
- 源剖面加载瞬时失败(如后端 502)自动重试,避免一条抖动致整体建不出;失败弹 toast 不再静默
- 退化薄体(共面剖面 ny/nz=1)网格每维补到≥2,避免 vtkGPUVolumeRayCastMapper 拒绝渲染
- 三维体透明度可调:工具条「透」按钮+弹出滑块(默认 0.30,实时改已渲染体)
- 工具条 z 序修复:引导层挂 vtkWidget 并 raise,工具条/提示再 raise 其上(缩小渲染区不再被挡)
- 收起左栏同步 QSplitter 尺寸,消除残留空白
- 切换项目清空三维体/切片/异常列表
- VTK 警告/错误转 Qt 日志,不再弹独立 vtkOutputWindow 窗口
- 勾选非三维体 ds 首次加载也显示等待动画(复选框↔spinner)
- 新建三维体后该行多拍重试滚动到分析栏顶部
2026-06-27 18:32:07 +08:00
gaozheng 9b4f172809 fix(3d-view): 二维/三维分析切换时取消前视图选中
三维分析选中切片后切到二维分析,残留的selected_使InteractionManager::onWheel
持续消费滚轮(二维下无法缩放),且切回三维仍残留高亮。setMode2D进入二维时清切片
选中+高亮,并经onSliceSelectionChanged("")联动清三维分析列表选中行与异常高亮。
与VtkSceneView::setAnalysisMode2D离开二维时clearMapLineSelection清足迹选中相对称。
2026-06-26 23:43:26 +08:00
gaozheng d5e3522bfa feat(project-menu): 项目管理菜单按Excel接入web嵌入页
- 项目管理一级菜单仅保留需「直接嵌入」的4个子菜单(在线监测/工具组件/批量导出/
  告警管理,共10项,对应Excel「单个项目」页签第10~21行带嵌入地址者);其余全部隐藏
  (数据视图/项目配置/数据管理/业务管理/项目资料管理/自动任务/模板管理)
- 新增 ProjectWebView(内嵌QWebEngineView):DocumentCreation阶段注入
  localStorage['token']=登录token,早于页面脚本执行
- 中央区用QStackedWidget承载:page0=工作台,page1=web整窗;点菜单项整窗加载,
  顶部视图菜单「分析视图」切回工作台
- URL: tenant.geomative.cn/#/embed?space=3&projectId=<运行时项目id>&target=<target>
  (space=3为项目空间常量;两个OpenAPI文档均无embed相关space字段)
2026-06-26 23:43:06 +08:00
gaozheng fadcd12239 fix(gpr): 补 Api3dRepository::createGprVolume 声明 + 仓储透传 targetDy
HEAD 的 Api3dRepository.cpp 已定义 createGprVolume,但 .hpp 缺该声明
(成员定义无声明 → geopro_data 编译不过)。本提交补上声明,修复构建。

并把线内通道插值 targetDy(默认 2.5cm) 透传到 createGprVolumeGrid →
app 渲染链也得密 Y 体(与 io::gpr 桥同口径,0=不插值)。
2026-06-26 23:30:04 +08:00
gaozheng cf1c06cde8 feat(gpr): 三维体 LOD 多线渲染 + 全局切片(深度/横切/顺路) + 诊断
渲染架构改 LOD 中心:各线独立 mapper + 视野自适应 LOD,弃 multi-volume 单遍。
实测确诊多线卡顿真因是"没用 LOD、渲整卷大贴图"(passcost 排除固定开销;
overlapStat 实测重叠 ~9× 非 20×;ESS 实测仅 ~2× 不解决重叠),非渲染器问题。

切片(view-all --slice [updown|leftright|frontback]):
- 深度 C-scan:逐线整张水平片(深度共面→拼成完整 C-scan,全覆盖、原生分辨率)
- 横切/顺路:全局世界面 reslice 各线到同一面 + blend(竖直面几何上每线只切细断面)
- ↑↓ 整片扫过 / [ ] 体透明度 / v 体显隐 / --sliceAt 跳位

其他:通道插值(2.5cm,从.ord读)接入 gpr_poc;--bgSuppress 压背景突出反射;
slice 命令复用桌面端 SliceTool 切单线。

诊断命令:ess-stat(空块潜力)/--overlapStat(重叠层数)/passcost(N遍vs重叠隔离)。
分析文档:性能确诊(否定 ESS/OSPRay,LOD 为通用解)。
2026-06-26 23:25:51 +08:00
gaozheng 5bf3a8e5dd docs(backlog): 登记 OPT-003 二维分析C期 dd_raster(阻塞·待后端栅格端点)
A/B 期已实现(6a10975/bdebe54);C 期=dd_raster 栅格地理配准渲染,阻塞在后端无
栅格数据端点(实测 business_OpenAPI.json 无 dd_raster/栅格影像端点)。须后端提供
返回「像素+四至/仿射+投影」的端点后方可落地。
2026-06-26 22:01:11 +08:00
gaozheng bdebe54859 feat(3d-view): 二维分析B期(足迹高程Z拖动)+选择联动/滚轮升降/工具条禁用
- B期:二维分析里选中足迹(单击/Ctrl 多选)→ 竖向拖动只改世界 Z(锁 XY)、
  顶部实时高程读数浮层;Z 偏移按 dsId 持久(切走再回/全量重建保留)。
  VtkSceneView 加 pickMapLineAt/nudgeSelectedMapLinesZ/selectedMapLineZ(vtkCellPicker
  +PickFromList 只拾可见足迹、选中黄高亮加粗、mapLineZOffset_ 持久);PickInteractorStyle
  lock2D 下命中足迹→Z 拖动(onPick2D/onDrag2D/onDrag2DEnd + worldPerPixelZ 像素→世界Z);
  InteractionManager::pickStyle() 暴露样式;main.cpp 接回调 + 读数浮层。
- 列表↔VTK 双向选择联动:Column2DDataset 多选行 + selectedDatasetsChanged/setSelectedDsIds;
  VtkSceneView onMapLineSelectionChanged/setSelectedMapLines;两向各自断环。
- 滚轮升降:onWheel2D——有选中足迹时滚轮改其 Z(一格≈拖动24px)、消费滚轮,否则缩放;
  读数浮层滚轮后 1.2s 自动隐藏。
- 工具条:二维分析激活禁用 6 向快捷视图(会改朝向破坏近俯视锁定),切回三维恢复。
2026-06-26 21:56:45 +08:00
gaozheng 6a10975b6b feat(3d-view): 二维分析A期(一场景两相机)+视图切换/底图/增量修复
- 切「二维分析」tab:锁近俯视(下压12°≈78°)、禁旋转(左键平移=仅平移+缩放)、
  按维度翻 actor 可见(轨迹↔体/帘面/异常,不清空)、切片 SetEnabled 显隐、坐标轴在二维移除;
  地形+底图常驻。ColumnDrawer 新增 analysisModeChanged 信号串起三处协作。
- 修复切回三维数据不取景:按目标维度重置取景基线(VtkSceneController::onAnalysisModeChanged),
  computeDataBounds 只计可见 prop,render 朝向认 analysisMode2D_。
- 修复底图在远离锚点的数据处为空(如台湾,frame 锚深圳):底图取瓦片中心+距离剔除
  改用相机焦点(cenX_/cenY_)而非坐标系原点。
- 修复删到空不重锚致底图错位:removeDataset 清空 dsProps_ 时复位 frameAnchoredToData_。
- 修复二维分析列表非增量:对象树勾选刷新保留已勾选足迹(对齐 CategorySection::rebuildList),
  不再清空渲染/丢失列表选中。
- 新增控制器回归测试 2 项(切模式取景基线)。
2026-06-26 20:53:53 +08:00
gaozheng 4e998374e7 docs(handoff): 2026-06-26 创建异常打磨+切片异常交互+二维分析改造交接 2026-06-26 18:09:35 +08:00
gaozheng 227ee8fdef docs(spec): 二维分析锁定俯视相机+内容显隐+高程拖动 spec
一个场景两相机:二维分析=同一 3D 地形场景的锁定近俯视(禁旋转,仅平移缩放);切 tab 翻另一方数据集
可见标志(不清空,性能零代价);2D 内容选中后沿 Z 高程拖动分离(锁 XY,实时读数);dd_raster 过滤+地理
配准贴地形(C期,依赖端点)。含与雷达反馈的边界、维度口径(对齐 DD0623)、分期 A/B/C、风险、验收。
2026-06-26 18:04:53 +08:00
gaozheng c1a824e292 fix(data): 二维维度分类对齐数据字典 DD0623(去已删除轨迹类型)
据数据字典 DD0623:足迹型 2D 只 dd_trajectory_data 为统一通用轨迹「保留」(已并入
dd_radar_rtk_trajectory);dd_transient_electromagnetic_trajectory_data / dd_radar_channel_trajectory /
dd_radar_rtk_trajectory 字典均标「删除」。dimensionOf(Api3d/LocalSample3d)从 4 种轨迹收敛为
dd_trajectory_data;同步更新测试。(dd_raster=本次新增 2D 栅格,与渲染一并放第二步)

测试:439/439 通过
2026-06-26 17:17:07 +08:00
gaozheng e8bb2f82e7 fix(ui): 异常提示浮层去圆角+不透明深底(消白角/灰底)
上一版 WA_TranslucentBackground 让半透明背景在 GL 子控件上渲染成灰底。改为方角+不透明深底
(#0E1A2D)+accent 描边:方角无"圆角外三角区"故不露白,不透明深底不被白底冲淡成灰。
2026-06-26 16:23:44 +08:00
gaozheng 1648ccb8c4 fix(ui): 异常提示浮层圆角外露白底 → WA_TranslucentBackground
QLabel 默认不透明,QSS border-radius 只画圆角矩形,四角(圆角外三角区)露出 widget 白底色。
加 WA_TranslucentBackground → 圆角外透明(露画布),圆角矩形(深底0.85)正常。

构建:app 链接通过
2026-06-26 16:13:02 +08:00
gaozheng f230ca8dd1 fix(3d): 异常绘制提示中文乱码(改 QLabel 浮层) + 列表切到别对象清切片选中
1) 提示"乱码":vtkTextActor 用 VTK 内置字体不含中文字形 → 中文渲染不出(只剩 ASCII)。
   移除 VTK 文本提示,改 app 层右上角 QLabel 浮层:Qt 渲染中文 + QSS(深底/accent描边/圆角),
   绘制开始按形态显示结束方式、结束/取消隐藏;不挡画布鼠标。
2) 列表选中切片后切到别的对象(三维体/异常),VTK 切片仍高亮:datasetSelected 选非切片对象时
   未清切片选中。加 InteractionManager::deselectSlice();选异常/其它对象均清切片高亮(异常↔切片互斥)。

测试:439/439 通过
2026-06-26 16:06:07 +08:00
gaozheng 9782a2b93e feat(ui): 删除切片/异常加确认 + 弹框按钮全局中文化
3) 删除切片/异常前弹确认框(警告图标 + 明确「删除/取消」中文按钮 + 不可撤销提示)。
4) 弹框默认英文 OK/Cancel/Yes/No 全局中文化:
   - 安装 Qt 自带 zh_CN 翻译(QMessageBox/QDialogButtonBox/QFileDialog 等标准按钮);加载兼顾
     dev(Qt 安装路径)与部署版(exe 旁 translations\)。
   - formkit::addDialogButtons 默认按钮 QString()→「确定/取消」(不依赖翻译就位,覆盖自建对话框)。
   - 打包脚本补拷 qtbase_zh_CN.qm(windeployqt --no-translations 不带)。

测试:439/439 通过
2026-06-26 15:41:13 +08:00
gaozheng 306d7bc46e fix(3d): 异常绘制提示移右上角美化 + 线双击结束含双击位置
1) 操作提示从左上角(被工具条挡)移到右上角:深底+accent描边+右对齐+分行(标题/结束方式/取消),
   按当前形态显示对应结束方式。
2) 线双击结束改为"含双击位置"(同地图工具):去掉之前的回滚——双击第一下的落点即为末顶点。
   单/双击不再需要回滚隔离(双击第二下只 finish 不加点,无重复顶点)。
2026-06-26 15:41:12 +08:00
gaozheng d7ab7705c9 feat(3d): 切片保存后定稿锁定(不可移动/旋转)+ 菜单去保存·另存
按用户口径:切片保存后即定稿,不可再改。
- SliceTool::setInteractive(false) 关 widget 鼠标交互(锁移动/旋转,纹理仍显示;拾取选中/右键由
  PickInteractorStyle 独立处理不受影响)。tagSelectedSlice(临时→保存)与 showSavedSlice(重显已保存)均锁定。
- VTK 右键「保存」仅对未保存(临时)切片显示;已保存切片无保存项。
- 数据列表切片右键去掉「保存/另存」(列表中切片均为已保存=锁定),留 详情/导出/色阶/删除。

测试:439/439 通过
2026-06-26 15:17:54 +08:00
gaozheng 91a71064b2 feat(3d): 创建异常结束手势按业界通用做法重做(点单击/线双击/面点起点闭合)
双击作主手势别扭(与单击天然冲突)。按业界通用改:
- 点:左键单击即落点并完成(无需双击/回车)。
- 线:双击完成(保留)。
- 面:点回起点闭合(≥3点,屏幕邻近 12px 吸附);光标近起点时橡皮筋指向起点预览闭合,提示文案更新。
Esc 取消 / Backspace 撤点不变。
2026-06-26 15:17:53 +08:00
gaozheng 302d946bd9 fix(ui): 数据列表切片菜单「保存位姿/另存为…」→「保存/另存为」(与VTK菜单/用户口径一致)
构建:app 链接通过
2026-06-26 14:09:34 +08:00
gaozheng b48684a0ba fix(3d): 右键色阶去省略号 + 关闭切片取消列表勾选 + 异常取消选中清高亮
1) 数据列表三维体/切片右键「色阶…」→「色阶」(去省略号)。
3) VTK 右键关闭已保存切片后,数据列表仍勾选:onSliceClosed 此前从未连接 → 接 setChecked(dsId,false)。
4) 异常取消选中(选别的对象/点 VTK 空白)后异常图形仍高亮:
   - 选中非异常对象 → setSelectedAnomaly("") 清异常高亮;
   - onSliceSelectionChanged 收到空 dsId(点空白/清选) → 一并清异常高亮。

构建:app 链接通过
2026-06-26 14:06:11 +08:00
gaozheng 1a70ca0072 feat(3d): 异常对话框加样式预览(选中类型 legend 可视化)
应用户要求,在异常类型下拉下方加「样式」预览行:据选中类型的平台 legend 按形态画——
点=实心色球 / 线=按线宽·虚实的线 / 面=描边矩形+淡填充。选类型变化或样式拉到后实时刷新;
未取到时显占位「—」。

构建:app 链接通过
2026-06-26 12:07:11 +08:00
gaozheng 4ae8286bb0 fix(3d): 异常截图配色与切面一致——取 widget 同源着色输出(非另建 LUT)
用户实测异常截图与切面渲染配色差异极大(切面暖色彩虹、截图偏冷蓝绿)。根因:selectedSliceColorImage
另建 buildLut(v->cs,vmin,vmax) 上色, 与屏幕切片 widget 的实际着色可能分歧(范围/血缘处理不同)。
改:SliceTool 暴露 coloredResliceImage() = widget->GetColorMap()->GetOutput()(屏幕切片所贴的同一
RGBA 像素, 逐像素一致, 外区 alpha=0); selectedSliceColorImage 改取它再双线性上采样到 2048。
captureAnomalyShotFromSlice 处理 RGBA → 外区透明(顺带消除截图蓝边)。导出图片同样受益(与屏幕一致)。

测试:439/439 通过
2026-06-26 11:56:33 +08:00
gaozheng d470dc8154 fix(3d): 双击与单击隔离 + 异常类型下拉误显「暂无数据」
双击结束时"第一下"会先触发单击(点会移/线多一段/面多一条边)。修:每次单击前快照 pts_,双击时
先回滚那一下(pts_=快照)再 finish → 双击纯结束,不再附带加点/移点。

EmptyAwareComboBox::realItemCount 用 itemData(i,UserRole-1) 取 flags 是错的(非 Qt flags 角色),
对正常项恒判不可选 → 计数恒 0 → 有真实项(异常区)也误插「暂无数据」。改用 model()->flags()。

构建:app 链接通过
2026-06-26 11:49:14 +08:00
gaozheng 04af569b7d fix(3d): 创建异常返工(点交互/点渲染/截图/类型空态)——前一轮质量不过关
#1 点模式还拉线:updateRubber 点模式直接 return,不画末点→光标橡皮筋。
#3 点不是小球:AnomalyActor 点分支 + 绘制预览均开 RenderPointsAsSpheres,点径 13px → 真小球(非扁平方点)。
#4 截图整窗方框→改对:captureAnomalyShotFromSlice 只从切片 2D 剖面彩图、按异常几何向外缓冲一圈裁剪
   (GIS buffer+掩膜:点→圆/线→胶囊带/面→外扩多边形,缓冲外透明,再描轮廓);worldPts 经平面 o/e1/e2
   归一映射到图像像素(Qt QPainterPath/Stroker)。无切片图时回退相机框景。
#2 类型/样式选择"消失":异常类型下拉补占位「请选择异常类型」,空(该形态平台无类型)时显灰占位+「暂无数据」。

注:本项目平台「点」类型为空,故点的类型下拉确实无项——需平台先建点类异常类型。

测试:439/439 通过
2026-06-26 11:31:04 +08:00
gaozheng 3ed1ea75ac feat(3d): 异常样式接平台 legend(与平台一致,不依赖 mock 保存)
修正前一轮误判:异常样式("与平台一致")不依赖真后端保存链——getDetail/{id} 端点存在且返回 legend。
- 新增 getExceptionTypeDetail(typeId) → GET /business/exceptionType/getDetail/{id}(wireObject 取 data)
- AnomalySaveDialog 选中类型变化时拉其 legend,按形态(1点 pointColor / 2线·3面 polylineColor+Width+
  Shape→dashed)派生样式;首项自动预取
- main accept 后用 dlg.styleColor/Width/Dashed 覆盖默认(#ff3030/2/实线)→异常按平台类型样式渲染

注:真保存(newException)仍卡——实测真后端无任何登记三维体/切片为 dsObject 的端点(voxel/slice
generate 无、通用 dsObject create 也无),异常 remarkSourceId 无真实实体可指,是后端缺端点的硬依赖。

测试:通过
2026-06-26 11:00:34 +08:00
gaozheng 58544ffb3c feat(3d): 创建异常支持点/线/面三态(子菜单+统一双击·回车提交)
按原型「创建异常→点/线/面」改造:
- 右键菜单单项「创建异常」→ 点/线/面 子菜单;形态(1/2/3)同时驱动绘制 mode、a.markType、
  对话框查平台类型的 remarkSourceType(core::AnomalyMarkType 与 remarkSourceType 同值,一个 shape 贯通)
- AnomalyDrawTool 泛化 DrawMode{Point,Line,Face}:点≥1(再点重定位微调)/线≥2(开放)/面≥3(闭合);
  最少点数按模式;分形态屏幕提示
- 交互按锁定规范:**双击/回车 提交**(去掉右键提交,右键绘制中消费不响应保留菜单语义);
  **Backspace/Delete 撤上一点**;Esc 取消
- AnomalyActor 已支持点(verts)/折线/闭合多边形三态渲染,无需改

测试:439/439 通过
2026-06-26 10:36:38 +08:00
gaozheng c6756aafc5 feat(3d): 异常类型下拉接平台真实类型(去 mock,与平台一致)
AnomalySaveDialog 原硬编码 4 个 mock 类型。改为按标注形态(remarkSourceType)异步拉平台异常类型
(cmdRepo.listExceptionTypes,label→显示/value→id)填充下拉,与平台保持一致。net 层已把扁平
data 数组归一为 value→wireArray 可读。空/失败由 EmptyAwareComboBox 空态提示。
当前面(Polygon)固定 remarkSourceType=3;P2 接点/线后由菜单形态决定 1/2/3。

构建:app 链接通过
2026-06-26 10:28:11 +08:00
gaozheng 75c1327aa4 feat(3d): 创建异常截图改相机重构图(方案A,frame-to-fit selection)
异常截图原为整窗口截图。改为业界 frame/zoom-to-fit selection 范式:
captureFramedRegionPng 把相机临时重新取景到圈定 worldPts 外扩区域(padFactor=1.4≈异常占画面~70%
带周边语境),视角方向不变仅推近/缩放(ResetCamera),后台缓冲+关交换截图屏幕不闪,截后还原相机。
点(零体积)/线面共面(某轴零厚度)用切片尺寸 0.25×min(e1,e2) 作框景半径兜底。
main 调用处从 worldPts 算世界包围盒 + 从切片 o/p1/p2 算兜底尺寸。

构建:app 链接通过
2026-06-26 10:21:25 +08:00
gaozheng 56e4b3a7ff fix(ui): 登录验证码图形容器背景改白底
#captchaImg 背景 bg/hover(浅灰) → 白底。后端验证码图是浅底,白底贴合图边、两侧不再露灰条;
两种主题下验证码图都是浅底,故用白色字面值(随主题反而割裂)。

构建:app 链接通过
2026-06-26 09:02:00 +08:00
gaozheng 85636931af fix(ui): 分段折叠时向上收起(不再停在原位中间格)
根因:各段 addWidget(sec,1) 等高平分 stretch,折叠后该段仍占等分高度→段头浮在那格顶部、下方留空,
看着像"停在当前位置中间"(仅面板不出滚动条/内容short于视口时可见)。
修法:据折叠态动态重排 stretch——展开段=1(吸收余量铺满)、折叠段=0(只占段头高);末加尾部弹簧,
全部折叠时置 1 把段头顶到顶部。CategorySection 暴露 isExpanded()+collapsedChanged() 信号驱动重排。
保留"全展开等高铺满"原行为(#7)。

构建:app 链接通过
2026-06-26 07:49:08 +08:00
gaozheng d6e52cb51f fix(ui): 三维分析分段面板视觉打磨(段头/新增按钮/顶部留白,规范§4.3/§6.7)
用户反馈分段面板难看,逐项重做:
- 可折叠段头:去原生小三角(难看)→chevron 文本前缀 ▾/▸(随主题/hover 变色);标题改 title 字号
  +半粗(原默认字体);加浅底分段条 bg/panel-subtle + 底分隔线 divider 作视觉分段;hover 转 accent
- 「+新增三维体」:裸 autoRaise 文字 → 描边强调按钮(border/文字 accent/primary,hover 浅强调底,
  radius/sm,caption 字号),符合§6.7 次级强调按钮
- 顶部留白:CategoryAnalysisTab 内容区加 top margin → 首段段头不再贴顶
- 筛选行 setSpacing 一致化

构建:app 链接通过
2026-06-26 07:29:32 +08:00
gaozheng fb911a9d85 fix(ui): 坐标轴面板硬编码颜色 token 化(深色模式合规,规范§1/§6.7)
按视觉规范审计修 AxesSettingsPanel 硬编码(深色模式下会失效):
- 应用按钮 #2f6fed/#2a63d4/radius6 → setDefault(true) 走全局 QPushButton:default(accent/primary,随主题)
- 关闭按钮 #888/#2f6fed → token(text/secondary / accent/primary)
- 放大系数标签 + 最小/最大值标签 #888 → token(text/secondary / text/tertiary)
- 面板圆角 10px → 8px(radius/lg,规范§3.2 画布浮窗)

构建:app 链接通过
2026-06-25 22:46:13 +08:00
108 changed files with 5463 additions and 506 deletions

View File

@ -55,3 +55,22 @@
落在光标下那张切片 → 选对(`onPick` 仅命中时触发,未命中不误选)。重叠切片仍按最前优先(合理)。 落在光标下那张切片 → 选对(`onPick` 仅命中时触发,未命中不误选)。重叠切片仍按最前优先(合理)。
逻辑闭合但**未 live 点击验证**(工具无法交互点击 3D 切片);若仍有偏差需 live 复核(重叠循环切换等)。 逻辑闭合但**未 live 点击验证**(工具无法交互点击 3D 切片);若仍有偏差需 live 复核(重叠循环切换等)。
- **更新**2026-06-25 issue2+③+反向²(`69e8790`)+④(`63cda56`) 全部实现。 - **更新**2026-06-25 issue2+③+反向²(`69e8790`)+④(`63cda56`) 全部实现。
---
## OPT-003 · 二维分析 C 期dd_raster 栅格地理配准渲染(阻塞·待后端端点)
- **状态**:🔴 Open**阻塞**:后端无栅格数据端点)
- **记录日期**2026-06-26
- **背景/现状**:二维分析改造分期 A→B→Cspec `docs/superpowers/specs/2026-06-26-2d-analysis-topdown-elevation.md`)。
A一场景两相机、B足迹高程 Z 拖动已实现commit `6a10975`、`bdebe54`)。**C 期=dd_raster 栅格**
DD0623 新增 ddCode展示模式 2D形态=栅格/遥感影像)尚未做。
- **期望**`dd_raster` 纳入 2D 维度过滤(`dimOf`/`dimensionOf` 加 `dd_raster`→Dim2Dcol2D 勾选渲染按
ddCode 分派(轨迹走 `loadMapLine`,栅格走**栅格加载**,不可串);栅格取**像素 + 四至/仿射 + 投影 CRS**
作地理配准纹理平面贴到地形上(带高程,可被 B 期 Z 拖动),类似底图瓦片按经纬定位。
- **阻塞点(为何不做)**:实测后端 API 文档 `docs/apis/business_OpenAPI.json` **无任何 dd_raster / 栅格影像
端点**(仅有 grid 行/反演 grid、GPR 通道图,均非带四至+投影的栅格)。无端点 → 无像素/地理范围可加载 →
C 期无数据可渲染。**须后端提供返回「像素 + 四至/仿射 + 投影」的端点后方可落地。**
- **难点**:栅格加载路径(新)、按 ddCode 的渲染分派、地理配准纹理平面(参考 `TileBasemap``buildFlat`/
`buildWarped` 按经纬贴地形 + DEM 位移)。
- **关联**spec §6/§9/§10/§11handoff `docs/superpowers/HANDOFF-2026-06-26-vtk-anomaly-2d-analysis.md` §0。
- **更新**:—

View File

@ -0,0 +1,100 @@
# HANDOFF — VTK 3D 视图:创建异常打磨 + 切片/异常交互 + 二维分析改造2026-06-26
> 分支 `feat/vtk-3d-view`。桌面端 Qt6 + VTK 9.6。本会话围绕「创建异常」全链路打磨、切片/异常交互修复、全局中文化,最后转入「二维分析改造」的需求厘清 + 写 spec。**下一步=按 spec 实现二维分析 A 期。**
---
## 0. 立刻要做的事(下个会话从这里开始)
**二维分析改造 A 期已实现**(未提交,下个会话需用户实跑反馈手感/角度。spec`docs/superpowers/specs/2026-06-26-2d-analysis-topdown-elevation.md`commit `227ee8f`)。分期 A→B→C
- **A已实现 ✅build+439测试全绿未提交**一场景两相机。切「二维分析」tab → 近俯视(下压12°≈78°俯角)+禁旋转(左键改平移、仅平移/缩放);按维度翻 actor `SetVisibility`(轨迹↔体/帘面/异常,**不清空**);切片 `SetEnabled` 显隐(不销毁);地形+底图常驻;切回三维还原相机快照。**待用户实跑**:①近俯视角度是否合适②切换是否瞬时③左键平移手感④切回三维视角还原是否自然。
- 改动文件:`CameraPreset.{hpp,cpp}`(applyNearTop2D)、`PickInteractorStyle.{hpp,cpp}`(setLock2D)、`SliceTool.{hpp,cpp}`(setVisible)、`InteractionManager.{hpp,cpp}`(setMode2D)、`VtkSceneView.{hpp,cpp}`(setAnalysisMode2D+mapLineDs_+相机快照)、`ColumnDrawer.{hpp,cpp}`(analysisModeChanged 信号)、`main.cpp`(接信号)。
- 已知小风险2D 取景 `computeDataBounds` 含隐藏的 3D 体包围盒(地形主导,影响小);切片 `SetEnabled` 显隐属 GUI 不可自测项。
- **B已实现 ✅build+441测试全绿未提交待实跑**:二维里选中足迹(单/Ctrl 多选)→ 竖向拖动只改**高程 Z**、锁 XY、顶部实时高程读数浮层Z 偏移按 dsId 持久(切走再回/全量重建保留)。手势:单击足迹=选中、Ctrl+单击=多选切换、点空白=取消+平移、(多)选后竖向拖动=整体改 Z。
- 实现:`VtkSceneView` 加 `pickMapLineAt/nudgeSelectedMapLinesZ/selectedMapLineZ/clearMapLineSelection`vtkCellPicker+PickFromList 只拾可见足迹、选中高亮黄加粗、`mapLineZOffset_` 持久);`PickInteractorStyle` lock2D 下命中足迹→Z 拖动(`onPick2D/onDrag2D/onDrag2DEnd`+`worldPerPixelZ` 像素→世界Z)、否则平移;`InteractionManager::pickStyle()` 暴露样式;`main.cpp` 接回调 + 高程读数浮层(复用提示样式)。
- **待用户实跑**:①拾取灵敏度(tol 0.012)②拖动 Z 灵敏度/方向(上移=抬高)③多选拖动④读数是否合理(现为 actor 包围盒中心世界 Z含 placement+偏移,未除 VE)。
- **C下一步**dd_raster 纳入 2D 过滤 + 按 ddCode 分派渲染 + 栅格地理配准贴地形。**阻塞dd_raster 数据端点未确认**(需后端给「像素 + 四至/投影」端点)。
---
## 1. 环境 / 命令 / 铁律(必读)
- **构建**:用 PowerShell 工具跑 `cmd /c "D:\Git\lanbingtech\geopro\build.bat app"`Claude 的 Bash 跑 build 会被环境劫持,用 PowerShell。测试 `build.bat test`ctest**439 用例**)。`vswhere.exe not recognized` 噪声无害。LNK1104=exe 被运行中的 app 锁,需用户关 app 才能链接。
- **回复用中文**(用户要求)。
- **CLAUDE.md 两条绑定规则**:①发现技术债当场修,不以"非本轮引入"搪塞;②**能自己做的绝不让用户做**——日志(`%LOCALAPPDATA%/Geomative/Geopro3/logs/geopro_*.log`)/数据/构建/诊断都自己来,只在 LNK1104 关 app、或真正产品决策才找用户。
- **git**:精确 `git add <files>`(仓库有并行 GPR 会话的脏文件 `.superpowers/sdd/*`、`docs/.../poc-lod-shots/*.png`**勿误提交**)。提交无 Co-Authored-By全局禁
- **后端 token可访问真实接口用户给的**`geomativeauthorization: Geomative e6c1259748644c8da0954d864bb82604`base URL `http://tenant.geomative.cn/pop-api`;样例 projectId `1439735554211840`。可用 curl 查接口。
---
## 2. 本会话已完成(提交清单,新→旧)
**创建异常全链路(核心工作量)**
- `227ee8f` 二维分析 specdoc
- `c1a824e` 二维维度分类对齐数据字典 DD0623去 3 个已删除轨迹类型dimensionOf/dimOf/测试)。
- `e8bb2f8`/`1648ccb`/`f230ca8` 异常绘制提示vtkTextActor 渲染不出中文 → 改 **app 层 QLabel 浮层**(右上角、深底方角、不挡鼠标);列表切到别对象清切片选中(`deselectSlice`)。
- `9782a2b` 删除切片/异常加确认框 + **弹框按钮全局中文化**Qt zh_CN 翻译器 + `formkit::addDialogButtons` 默认「确定/取消」+ 打包补 qtbase_zh_CN.qm
- `306d7bc` 提示移右上角 + **线双击结束含双击位置**(去回滚)。
- `d7ab770` **切片保存后定稿锁定**`SetInteraction(0)` 不可移动/旋转)+ VTK/列表菜单去「保存·另存」。
- `91a7106` **结束手势**:点=单击即完成、线=双击、面=**点回起点闭合**(近起点 12px 吸附+橡皮筋指向起点)。
- `1a70ca0` 异常对话框加**样式预览**(选中类型 legend 可视化:点球/线/面)。
- `4ae8286` **异常截图配色与切面一致**:取 widget 自身 `GetColorMap()` 输出(非另建 LUT→ 逐像素一致 + RGBA 外区透明消蓝边。
- `d470dc8` 双击/单击隔离(后被 `306d7bc` 改回"含双击位置"+ 异常类型下拉误显「暂无数据」(`EmptyAwareComboBox::realItemCount` 用错 flags 角色→改 `model()->flags()`)。
- `04af569` 返工(点交互/点渲染小球/截图/类型空态)。
- `75c1327`→`3ed1ea7`→`58544ff`→`c6756aa` 截图相机方案A(已废)、**异常类型接平台真实类型**(`listExceptionTypes` 按形态)、**点/线/面三态**子菜单、**样式接平台 legend**(`getExceptionTypeDetail`)。
- 截图最终方案:**只从切片 2D 剖面图、按异常几何 buffer 裁剪**`captureAnomalyShotFromSlice`GIS buffer+掩膜,点圆/线胶囊/面外扩多边形)。
**其它**
- `56e4b3a` 登录验证码容器白底。
- `8563693` 分段折叠向上收起stretch 动态:展开=1/折叠=0+尾弹簧)。
- `d6e52cb` 三维分析分段面板视觉打磨chevron 段头/描边新增按钮/顶部留白)。
- `fb911a9` 坐标轴面板硬编码色 token 化。
- `cdd7613` vtk-3d-openapi 文档对齐实测DsPage 行在 `data.list`、DsRow 全字段、装置不在 data/page
- `2f6ec7d`→`1742b75`→`31ad7a4` **装置筛选**data/page 行不带 properties→改按行自带 `typeName/dsTypeCode` 筛选parseDsRows 兼容对象形态。
- 期间生成了一次安装包:`installer/dist/Geopro_Setup_3.0.0-20260625.exe`(脚本 `installer/build_installer.ps1`,含 windeployqt+样本+PROJ+vc_redist
---
## 3. 创建异常功能现状已落地端到端可用mock 保存)
入口VTK 切片右键 →「创建异常 → 点/线/面」。流程:圈定(AnomalyDrawTool)→草稿渲染→**从切片2D图buffer裁剪截图**→对话框(名称/异常类型[接平台]/样式预览/备注/截图)→保存(mock)→刷新树+渲染异常 actor。
- **关键文件**`src/render/interact/AnomalyDrawTool.{hpp,cpp}`(三态绘制:点单击完成/线双击/面点起点闭合Esc取消/Backspace撤点`src/app/AnomalySaveDialog.{hpp,cpp}`(接 `cmdRepo.listExceptionTypes`/`getExceptionTypeDetail`,样式预览);`src/app/SliceExport.{hpp,cpp}``captureAnomalyShotFromSlice``src/render/actors/AnomalyActor.cpp`(点=球/线=折线/面=闭合多边形);`src/app/main.cpp`onSliceContextMenuRequested ~509 起:菜单+绘制+对话框+保存接线QLabel 提示浮层)。
- **样式来源**:选中平台异常类型 → `getExceptionTypeDetail` 取 legendpolylineColor/Width/Shape, pointColor…→ 套到异常 lineColor/lineWidth/dashed。
- **截图**:只裁切片那张 2D 剖面图(`InteractionManager::selectedSliceColorImage` 现取 `SliceTool::coloredResliceImage()`=widget ColorMap 输出,与屏幕同源)。
### 仍卡在后端P3非客户端问题
- **异常真保存 `newException`** 卡:异常 `remarkSourceId` 须指向真实 dsObjectId三维体/切片),但真后端**无任何登记三维体/切片为 dsObject 的端点**(实测 `voxel/generate`、`slice/generate`、通用 `dsObject create` 全无)。当前 `saveAnomaly` 是内存 mock。后端补登记端点后整链可接真。
- 平台「点」类异常类型该项目为空(线/面有)→ 画点时类型下拉空属正常。
---
## 4. 二维分析改造——已确认的设计决策(与用户逐条敲定)
1. **不分两个场景**:一个 3D 地形场景(带高程)+ 底图贴地形,两栏只是相机不同。"二维分析只是 3D 的固定视角"。
2. **二维相机**:锁定**近俯视(7580°非绝对正俯视)**,禁旋转,仅平移+缩放。(绝对正俯视看不出高程→留倾斜)
3. **切 tab 显隐**:翻另一方数据集 `SetVisibility`**不显示,非清空**)→ 性能零代价VTK 跳过不可见 actor、切换瞬时、只占内存。地形+底图常驻。**绝不清空**(重体素重建会卡)。
4. **高程拖动(C1)**:二维里选中 2D 内容(单/多选)→ 竖向拖动只改**高程 Z**、锁 XY、实时读数。用途=分离叠在一起的 2D 层。
5. **维度过滤**2D = `dd_trajectory_data`+ C 期 `dd_raster``dd_radar_2d/3d` 是 3D不进 2D。已对齐 DD0623`c1a824e`)。
6. **雷达客户反馈**:只影响数据模型 + **3D 视图**渲染(二维雷达=线、三维雷达=切面、带打标)+ 详情页校准——**均属另立任务,不在本次 2D 改造**。2D 只显示轨迹线、打标暂不做(与本设计一致)。
---
## 5. 关键代码锚点(二维分析改造用)
- 切 2D 视图模式钩子:`Column2DDataset::view2DModeChanged` → `main.cpp``sceneCtrl`(搜 `view2DModeChanged`)。
- 维度分类:`src/app/DatasetDimension.cpp::dimOf`col2D 用);`src/data/api/Api3dRepository.cpp` + `src/data/repo/LocalSample3dRepository.cpp::dimensionOf`
- 2D 内容注入:`main.cpp` ~502 `col2D->setDatasets(splitByDimension(...).dim2D)`;勾选渲染 `Column2DDataset::checkedDatasetsChanged``loadMapLine`/`MapLineActor`。
- 场景/相机/可见:`VtkSceneView`actor 管理)、`VtkSceneController`、`InteractionManager`(拾取/选中,新增 `deselectSlice`)。
- 地形+底图:`buildTerrain`带高程VE 垂直夸张相关)、`TileBasemap`(天地图按经纬贴)。
- 数据字典:`D:\Projects\GEOPRO\DD0623.xlsx`46 个 ddCode序号/ddCode/领域/含义/状态/**展示模式(2D/3D)**/**展示形态**/备注…。解析openpyxl 可用,中文 GBK 终端会乱码→导 UTF-8 文件再 Read。
---
## 6. 待确认/风险
- **dd_raster 数据端点**(像素+四至/投影)未确认 → C 期阻塞。
- 近俯视角度需实机调;高程是否落库暂定会话内(不落库)。
- §3 翻可见标志需可靠区分每个 actor 的维度归属。
- 切相机/可见与现有 view2DModeChanged、底图、地形 VE 逻辑勿打架。
- **GUI 无法登录自测**:相机/截图/交互类改动我只能保证编译+逻辑,视觉/手感需用户实跑反馈。
---
## 7. 相关记忆/文档
- 记忆库 `MEMORY.md`(如 login-chain-truth、vtk-3d-persistence-structure、build-vs2026-vcpkg-gotchas、deploy-hardcoded-dev-paths 等)。
- vtk-3d API 设计草案:`docs/api/vtk-3d-openapi.json`已对齐实测0.6.1)。
- 真后端 API`docs/apis/business_OpenAPI.json`。
- 二维分析 spec`docs/superpowers/specs/2026-06-26-2d-analysis-topdown-elevation.md`。

View File

@ -0,0 +1,101 @@
# 二维分析:锁定俯视相机 + 内容显隐 + 高程拖动 — Spec2026-06-26
## 0. 一句话目标
把「二维分析」从"另一套平面地图"改为**同一个 3D 地形场景的一个锁定近俯视视角**:切 tab 只切相机+翻另一方数据集的可见标志(不清空),二维内容(轨迹/栅格)落在带高程的地形上,且可选中后沿高程上下拖动分离。
---
## 1. 背景与现状
- 三维分析栏(`CategoryAnalysisTab`)与二维分析栏(`Column2DDataset` / `col2D`**共用同一个 `VtkSceneView` / `InteractionManager` / `renderWindow`**。现状:两栏勾选的 actor 叠在同一场景,切 tab 不切相机、不区分内容。
- 二维内容现状:`col2D` 勾选 → `loadMapLine``dd/ert/trajectory/line`)→ `MapLineActor`lat/lon 折线);有 `view2DModeChanged` 信号已接到 `sceneCtrl`2D 视图模式钩子,本 spec 在其上扩展)。
- 地形 + 底图:场景已有地形(`buildTerrain`,带高程)+ 天地图底图(`TileBasemap`,按经纬贴)。
- 维度分类:`DatasetDimension::dimOf` + `Api3dRepository/LocalSample3dRepository::dimensionOf`,已对齐数据字典 DD06232D = `dd_trajectory_data`commit c1a824e
**用户确认的认知**:二维分析"只是 3D 的固定视角",底图不是平面图、是**带高程的地形图**;内容沿用不清空。
---
## 2. 核心设计:一个场景 + 两种相机
- **不分两个场景**,只有一个 3D 地形场景(地形 + 底图 + 全部已勾选数据),两栏区别仅在**相机**与**哪类数据集可见**。
| | 三维分析 | 二维分析 |
|---|---|---|
| 相机 | 自由透视(可旋转/倾斜/平移/缩放) | **锁定近俯视**(不可旋转,仅平移+缩放)|
| 可见数据集 | 3D 数据集(体/切片/剖面…可见2D 数据集隐藏 | 2D 数据集(轨迹/栅格可见3D 数据集隐藏 |
| 地形 + 底图 | 常驻可见 | 常驻可见(同一地形,俯视看即"地形图"|
---
## 3. 二维分析的相机:锁定近俯视
- 切到二维分析 → 相机切到**近俯视固定角(约 7580°非绝对正俯视**:理由——绝对正俯视在正交/小透视下高程变化不可见留一点倾斜以便看出高低§5 拖动反馈需要)。
- 锁定:**禁用旋转**interactor style 不响应旋转/倾斜),仅保留**平移 + 缩放**。
- 切回三维分析 → 恢复自由透视相机(恢复切走前的视角或合理默认)。
- 实现锚点:扩展 `view2DModeChanged` 钩子 → `VtkSceneController` 切相机模式 + 切 interactor style或在 style 内按模式禁旋转)。
---
## 4. 内容显隐:切 tab 翻可见标志(**不清空**
- 切 tab 时,对"另一方"的数据集 actor 用 `SetVisibility(false)`,切回 `SetVisibility(true)`。**不移除 actor、不重建**。
- 性能VTK 渲染跳过不可见 actor → 隐藏内容**不参与绘制、不耗 FPS**;切换瞬时(无重插值/重传 GPU唯一代价是内存/显存驻留(数据本已加载,无新增加载)。
- **禁止用清空**重体素GPR/ERT每次切回要重插值+重传 GPU必卡。
- 地形 + 底图两边都不隐藏。
- 实现锚点:`VtkSceneView` 按数据集维度(`dimensionOf`/记录每 actor 的维度)批量翻可见标志;切 tab 时调用。
---
## 5. 高程拖动C1选中 2D 内容沿 Z 上下移
- 二维分析里,**拾取选中已渲染的 2D 内容**(轨迹/栅格),支持**单选 / 多选**。
- 选中后**竖向拖动 → 仅改其高程 Z离地高度****锁死 X/Y**(不动地理位置)。用于把叠在一起的 2D 层分离、看清。
- 拖动时**实时显示当前高程数值**(屏幕浮层读数)。
- 近俯视固定角§3使高低可见。
- 实现锚点:新增/复用一个 2D 拾取-拖动交互(类似切片 widget 但只允许 Z 平移 + 多选actor 的 Z 偏移持久(切走再回保留)。
- 待定:高程是否需要随数据保存(暂定仅会话内 actor 变换,不落库;接真实端点再议)。
---
## 6. dd_raster二维栅格过滤 + 渲染(本期新增)
- 数据字典 DD0623`dd_raster`(栅格/遥感影像,**本次新增**,展示模式 2D形态=栅格)。
- 纳入 2D 过滤:`dimOf`/`dimensionOf` 增 `dd_raster``Dim2D`;但 col2D 勾选渲染须**按 ddCode 分派**——轨迹走 `loadMapLine`,栅格走**栅格加载**(不可让栅格走轨迹端点)。
- 渲染:取栅格的**地理范围(四至/仿射)+ 像素**,作为**地理配准的纹理平面贴到地形上**(带高程,可被 §5 高程拖动),类似底图瓦片按经纬度定位。
- 依赖dd_raster 的**数据端点**(返回像素 + 四至/投影)——**待确认**,未明确前 §6 不落地(先做 §3§5
---
## 7. 维度过滤口径(对齐数据字典 DD0623已部分落地
- 2D足迹/栅格):`dd_trajectory_data`(统一通用轨迹,"保留",已并入 dd_radar_rtk_trajectory+ `dd_raster`(本期新增,随 §6
- 已删除、不再单列:`dd_radar_rtk_trajectory` / `dd_transient_electromagnetic_trajectory_data` / `dd_radar_channel_trajectory`(字典均"删除"。已清理commit c1a824e。
- `dd_radar_2d` / `dd_radar_3d`:字典为 `展示模式=3D`(通道剖面 / 三维插值模型)→ **属三维分析,不进 2D 过滤**
---
## 8. 与雷达客户反馈的边界(本 spec **不含**
- 雷达 TM 模型(单/双/多频,每频一个 `dd_radar_2d`/`dd_radar_3d`,共用一个 `dd_trajectory_data` 轨迹)→ 数据模型,与本 spec 无冲突。
- 雷达**数据在 3D 视图的渲染**(二维雷达=线/curtain、三维雷达=切面,按 trace 坐标,带打标 hover tip**三维分析的另立任务**
- 详情页用 trace 坐标校准异常 + 剖面打标 → 详情页另立任务。
- **2D 视图只显示轨迹线、打标暂不在 2D 展示**(客户 #6 修正)→ 与本 spec 的"2D 显示轨迹足迹"一致,无新增 2D 工作。
- 雷达轨迹就是 `dd_trajectory_data`,本 spec 的 2D 分析按统一轨迹处理,无需特判。
---
## 9. 实现分期
- **A. 一场景两相机**:切 tab → 锁定近俯视/恢复自由相机 + 翻另一方数据集可见标志§3、§4。基础先做。
- **B. 高程拖动**2D 拾取单/多选 + 仅 Z 拖动 + 锁 XY + 实时读数§5
- **C. dd_raster**:过滤纳入 + 按 ddCode 分派渲染 + 栅格地理配准贴地形§6。依赖栅格数据端点确认。
---
## 10. 风险 / 待定
- 近俯视角度7580°需实机调用户若坚持绝对正俯视则 §5 高程反馈改为纯数值(不直观)。
- §4 翻可见标志需可靠区分每个 actor 的维度归属(按数据集 ddCode 记录维度 → actor
- §5 高程是否持久化/落库待定(暂会话内)。
- §6 dd_raster 数据端点(像素 + 四至/投影)未确认 → C 期阻塞点。
- 切相机/可见标志切换需与现有 `view2DModeChanged`、底图、地形 VE垂直夸张逻辑兼容勿互相打架。
---
## 11. 验收
- 切到二维分析:相机变近俯视、**不能旋转**,只能平移+缩放3D 数据集(体/切片/剖面)不可见,轨迹+地形+底图可见。
- 切回三维分析恢复自由相机3D 数据集重新可见2D 轨迹隐藏。切换**瞬时无卡顿**(无重建)。
- 二维分析里选中一条/多条轨迹,竖向拖动→只改高程、地理位置不动、实时显示高程;叠在一起的层能被拉开。
- C 期)勾选 dd_raster → 栅格按地理范围贴在地形上、可被高程拖动;轨迹与栅格各走各的加载路径、互不串。
- 维度过滤与数据字典 DD0623 一致2D=trajectory_data+rasterradar_2d/3d 归 3D

View File

@ -0,0 +1,210 @@
# GPR 多通道三维体渲染性能问题 — 分析文档(供外部专家评审)
> 自包含技术文档。读者无需了解本代码库内部,只需具备 GPU 体绘制 / VTK 基础。
> 目的:把"探地雷达(GPR)多通道阵列数据渲成可交互三维体"遇到的性能问题、已试方案、实测数据、
> 待定关键点完整呈现,供外部专家判断方向。
---
## 1. 背景与系统
- 桌面端 C++ 应用Qt6 + **VTK 9.6.2**),渲染探地雷达(GPR)采集的地下三维数据,要求**可交互**(旋转/缩放)。
- 渲染用 VTK 的 `vtkGPUVolumeRayCastMapper`OpenGL GPU 光线投射体绘制)。
- 当前测试机 GPU32 个着色器纹理单元(典型独显/中端)。
## 2. 数据特征(关键,决定一切)
多通道阵列 GPR一次采集一条"测线(line)"
- **道(trace)**:一个位置一根天线的垂直回波,深度方向 **821 采样**
- **通道(channel)**:阵列横向并排的多对天线,本数据 **14 通道****相邻通道横向间距 ≈ 10.5cm**(来自 `.ord` 文件真实偏移 -0.686…+0.686m,跨度 1.37m)。
- **沿测线道间距 ≈ 4.9cm**(比横向通道间距细 ~2 倍)。
- 一条测线:沿路 **~45305 道**,覆盖 **~2.2km**(一条南北向道路)。
- **共 20 条测线 = 同一条路来回扫 20 趟**(车载,每趟阵列覆盖约 1.4m 宽,多趟铺横向)。
**单条线 = 一个三维体**X=沿测线(~45305)、Y=通道(14)、Z=深度(821)。
**关键业务约束(来自现场专家)**
- 通道太稀10.5cm)→ 需**线内通道间插值**加密(相邻真实天线之间插,物理成立);
- **绝不做"测线之间"的插值**(车与车之间是真实物理空隙,插出来"信号全是假的",工程上不可接受);
- 多条测线"分开各自插值,渲染可以合到一起"。
GPR 数据的统计特征(实测,见 §6**~91% 体素近零(反射层之间是空的),但反射层横贯整个深度分布**(不是集中一坨)。
## 3. 已建成并验证可用的功能(不是问题所在)
1. **线内通道插值**:读 `.ord` 真实横向偏移,规则化到 2.5cm 网格、相邻通道线性插值(不跨线)。
实测 Y 由 14 加密到 **56**。有单元测试。
2. **多体单遍合成**20 条独立体(各自插值)作为一个 `vtkGPUVolumeRayCastMapper` 的多个端口注册进
`vtkMultiVolume`**单遍 ray-cast 合成**(而非每条体一遍)。已验证。
3. **纹理单元上限自动退避**:单个 multi-volume 同时挂的体数受 GPU 每着色器纹理单元上限制约
(每体约吃 4 个单元 → 32 单元机上**一个包最多 7 体**,第 8 体报错并丢体)。已实现"渲一帧→报错则
每包减 1 重建重渲"的自动退避(强制 K=12 → 自动退避到 7无丢体
4. **运行时换贴图边界**(确定性测试结论):给某端口**就地换贴图**——若**保持包围盒不变**(同范围、
只改 Y 密度)则 multi-volume 算得对;若**改包围盒**任意子区域、origin/范围/spacing 变)则破坏
其缓存 `TexToBBox` → 体断开/消失。
5. 通道维 LOD、统一传函、色标图例等。
## 4. 核心问题:性能
- **20 条密体Y=56总览交互极卡**:静止 ~1.7 fps旋转/缩放掉到 < 1 fpsGPU 100%)。
- 渲染**视觉正确**(雷达剖面纹理清晰、横向连续、合成无误),纯属性能。
- 现有提速手段都是**"交互时降质"**方向(降屏幕分辨率、加粗采样步长)——**损可见质量**,治标。
用户明确要求:"有没有不损可见质量的根本性提速(业界最佳实践)?"
## 5. 已分析/已试的所有方案(含理由与状态)
| # | 方案 | 理由 | 状态 / 结论 |
|---|---|---|---|
| A | multi-volume 单遍合成 | 把"N 体=N 遍 ray-cast"降到分包遍数 | ✅ 已实现。但单遍内每步仍要在重叠体里逐个采样 |
| B | 纹理单元自动退避分包 | 绕开 GPU 纹理单元上限、不丢体 | ✅ 已实现K=7/包20 条=3 包)。代价:**跨包重叠合成不正确(接缝)** |
| C | 交互降屏幕采样(ImageSampleDistance) | VTK AutoAdjust 标准手段 | ✅ 已做。**损质量**;且 AutoAdjust 只降屏幕、不降沿光线步长 |
| D | 交互手动加粗沿光线步长(SampleDistance) | 通道插值后 Y 密→自动步长极细→巨卡;这才是大头 | ✅ 已实现(`--sampleDist`/`--dragSampleMul`)。**损质量;且用户尚未实测**(见 §7 待定) |
| E | 通道维 LOD远疏近密换 Y 平面子集)| 保包围盒换贴图(#4 验证安全) | ✅ 已实现。但**只减纹理内存、不减每步重叠体采样次数 → 对此瓶颈几乎无效** |
| F | **装箱单体(binning)**:各线先逐线插值,再把**真实道**摆进一个总览网格体(空隙透明、不跨线插值)| 一个体一遍、每步采 1 次 → ~20×真实数据无假信号 | ⚠️ 技术可行、合规,但**用户否决**:装箱合并后**总览里分不出各线**,而用户要"一起渲染时仍能逐线区分/查看"→ 合并即失去意义 |
| G | **空体素跳过 ESS换 OSPRay/ANARI 后端)**:跳过透明背景块 | 业界对稀疏体的头号提速、不损质量 | ❌ **实测对本数据收益有限**(见 §6保质量阈值下仅 ~2×且**ESS 跳的是空区、不解决"重叠"**。VTK 库存 mapper 无自动 ESSOSPRay 本环境未编、vcpkg 无包 |
| H | 减少同屏体数(只渲选中 ≤7 条)| 真实工作流本就是选几条1 包 1 遍 | ✅ 免费、永远有效(使用方式,非技术) |
## 6. 关键实测数据
### 6.1 ESS空体素跳过潜力——零依赖实测决定要不要上 OSPRay
对一条真实测线密体5702×56×789按块算 min/max统计"整块落在近零透明带"的占比(=ESS 可跳块):
| 透明带半宽(相对半值域) | 8³ 块可跳占比 | 理论提速上限 1/(1占比) | 说明 |
|---|---|---|---|
| 5% | 8% | 1.1× | 极保守 |
| **10%(保质量,不丢弱反射)** | **52%** | **~2.1×** | — |
| 20% | 80% | ~4.9× | 开始把弱反射当背景丢 |
| 30%(激进)| 90% | ~10× | 明显损质量 |
- 体素层面 **91% 近零**但块层面ESS 实际粒度)保质量阈值下**只能跳 ~52% → 理论 ~2×**。
- 原因:**反射层横贯整个深度分布**,多数块里总混着信号、跳不掉。要 10× 须用激进阈值(损质量)。
- **更关键**ESS 跳"空区"**不减少"重叠"**——在有信号的块里仍要逐个采样所有重叠体。
### 6.2 其它实测
- multi-volume 纹理单元上限:本机 7 体/包32 单元),第 8 体报 "Hardware does not support the number of textures"。
- 体维度示例coarse 4 → ~11000×56×793/线coarse 32 → ~1400×56×780/线。
- 全 20 条 densecoarse 32底图 level 0、Y=56、分 3 包,**渲染正确**;交互 ~1.7fps**未加 D 方案的旧构建**)。
## 7. 关键点——已实测,结论修正(原假设两条都被推翻)
### 7.1 "重叠几层"——实测:**平均 ~8.7 层、最大 15 层(不是 ~23也不是 20**
纯几何测(各线世界 AABB 投到 X-Y 俯视 footprint、细网格统计每格覆盖层数`--overlapStat`
- footprint横向 X≈37m、沿路 Y≈2.2km
- **有体覆盖处平均重叠 8.74 层,最大 15 层**;穿 1214 个体的格子占 ~42%。
- **结论修正**:原"~23 层"假设**错**(开发团队和外部专家都猜偏了);**重叠是真实的大瓶颈**。
- **且这 9 层是冗余**20 趟是同一条路反复扫,同一地下点被测了 ~9 次 → 这 9 个重叠体在该处**都非空**
(同一地下结构)→ **ESS 在重叠区一个都跳不掉**(再次印证 ESS 不解决重叠)。
### 7.2 "采样 vs 重叠谁是大头"——实测:**采样瓶颈fps 线性正比于步长**
20 条密体、静止近景、离屏(步长越大越快越糙):
| sampleDist | fps | 相对 |
|---|---|---|
| 0.2(≈自动细)| 1.3 | 1× |
| 0.5 | 3.2 | 2.5× |
| 1.0 | 5.9 | 4.5× |
| 2.0 | 11.3 | 8.7× |
| 4.0 | 20.9 | 16× |
- **步长翻倍→fps 翻倍 → GPU 是采样瓶颈**。总开销 ≈ 光线数 × (光线长/步长) × ~9 个重叠体。
- D 方案(手动步长)**确实直接、强力提速**;但**保质量的步长(≈ Nyquist0.5×体素)下仍只 ~2 fps**
——因为 **9× 冗余重叠**把它乘了回去。要到交互级(10+fps) 得把步长粗到 ~2.0(欠采样、损 Z 薄层)。
### 7.3 合并诊断(两测合起来)
**慢 = 采样密度 × ~9 倍冗余重叠,两者都真实。**
- D 方案(粗化采样):提速强,但保质量步长下被 9× 重叠压回 ~2fps要交互须损质量。
- **唯一"保质量又快"的,是去掉那 9× 冗余重叠**(同路重扫的同一地下点):合并/装箱(取真实道、不跨线
插值)→ 一个体一遍 → ~9× 提速、且 Nyquist 步长下也能交互、**零质量损失**(冗余测量本就该合并降噪)。
- **但这与用户"保持 20 条可区分"直接冲突**——而这 9 层在物理上是**冗余测量**(同一地下结构扫了 9 遍),
保持它们"可区分"的工程价值存疑。
### 7.4 CPU OSPRay vs GPU仍未测
ESS 对本数据 ~2× 且不解决 9× 重叠OSPRay 主要 CPU、对手是 GPU 数千核。**很可能换 OSPRay 比现状还慢**
且为 ~2× 重编整个 VTK 投入产出极差。不建议在去掉冗余重叠之前考虑。
### 7.5 "多线为何卡"的根因确诊passcost决定架构——结论**不是固定开销,是没用 LOD**
> 背景:最初 P11/P12 是"各线独立 mapper + 视野 LOD",实测仍 0.5fps。需确诊卡在三个嫌疑哪个:
> ①LOD 选区没削小 ②N 遍固定开销 ③重叠没摊掉。`passcost` 命令N 个独立 GPU mapper 各渲一个 64³ 小体
> (模拟 LOD 削过的小区),分"铺开/不重叠"与"叠在一起/重叠",测离屏稳态 fps vs N。
| N | 铺开(不重叠) fps | 叠加(重叠) fps |
|---|---|---|
| 1 | 177 | 204 |
| 5 | 162 | 43 |
| 10 | 144 | 22 |
| **20** | **78** | **11** |
**判读(决定性):**
- **嫌疑 ②N 遍固定开销)排除**20 个独立 mapper 铺开仍 **78fps**177→78远非线性
**各线独立 mapper 架构上完全可行,固定开销温和。"multi-volume 单遍 ⊥ 视野 LOD"这个不可兼得不致命**——
放弃单遍、回独立 mapper 并不慢。
- **嫌疑 ③(重叠)真实但小体下可控**:叠加随 N ~1/N每条光线乘 N**20 层 64³ 叠加仍 11fps**(可用),
再叠屏幕降采样更快。
- **嫌疑 ①(选区没削小)= 真凶**passcost 小体 20 层叠加=11fps而真实 view-all 只 1.7fps——差距全在
**贴图大小**:当前渲的是**整卷底图**~11000×56×200 ≈ 上亿体素/条),**根本没用视野 LOD 把它削成小区**。
这是**最好结局:可修,不动地基,只需真正用上 LOD**。
**对架构的直接含义**:本会话引入的 **multi-volume 单遍是错误取舍**——为"单遍"关掉了 LOD、改固定整卷贴图
导致大贴图 × 9 层重叠 = 1.7fps。而 passcost 证明独立 mapper 够快,**根本不必为单遍牺牲 LOD**。
## 8. 部署约束(硬件不确定,跨厂商)
客户机配置未知(可能无独显,或 N卡/A卡/Intel。**没有任何单一渲染器能在 N 卡和 A 卡上都做"GPU+ESS"**——
GPU 体光追渲染器全厂商锁定N 卡→NVIDIA VisRTX/OptiX/IndeXIntel→OSPRay-GPUA 卡→基本无成熟方案)。
跨厂商唯一通用的是 **OSPRay-CPU免显卡、任意 x86****OpenGL任意 GPU、但无 ESS=现状)**
若上多后端,需"OSPRay-CPU 保底 + 探测到 N卡/Intel独显时升对应 GPU 后端 + OpenGL 终极兜底"。
## 9. 关键问题——大多已被实测回答
1. ~~有没有不损质量的根本性提速法~~ **有且是通用解LOD视野自适应多分辨率**——让 GPU 单帧实际
ray-cast 的体素量**与数据总量解耦、只与"屏幕能看清的量"挂钩**Task 12c 单体已验证 752/380fps
§7.5 passcost 证明它**对多线也成立**(独立 mapper 开销温和20 条铺开 78fps
2. ~~"卡"的主因~~ **已确诊§7.5):不是 N 遍固定开销(排除),是【当前根本没用 LOD、渲整卷大贴图】嫌疑①。**
本会话引入的 multi-volume 单遍为"单遍"关掉了 LOD → 大贴图 × 9 层重叠 → 1.7fps。
3. **9 层重叠的正确定位**(外部专家纠偏 + passcost 印证):它只是**这批数据(同路重扫 20 趟)的特例倍数**
**不是渲染本质问题**。本质是"单帧采样量 > GPU 吞吐",通用解是 LOD扛任意大数据无重叠但更大也能扛
9 层重叠在 LOD 之上降级为"一个被摊薄的常数因子"passcost20 层小体叠加仍 11fps
**不要让渲染架构围绕这个特例设计。**
4. ESS/OSPRay/多后端:**继续埋掉**——ESS 对本数据 ~2× 且不解决重叠、CPU 对手是 GPU且**它解决的是 LOD
已经解决的通用问题**,投入产出差。
## 10. 最终结论passcost 确诊后,架构清晰)
- **渲染架构 = LOD 中心(视野自适应、单帧量与总量解耦)。** 这是扛"任意大数据"的通用根本解,
Task 12c 单体已验证、§7.5 passcost 多线也成立。
- **本会话的 multi-volume 单遍是错误取舍**:为"单遍合成"牺牲了 LOD、改固定整卷大贴图正是当前 1.7fps 的
直接原因。passcost 证明独立 mapper 开销温和20 条 78fps**根本不必为单遍弃 LOD**
- **正解 = 各线独立 mapper + 视野 LOD逐线用 Task 12c 引擎)+ 停手才重建**(不每帧重建,避免 P11/P12
那种"20 条每帧重建上传"的 thrash——那才是 0.5fps 的另一半原因,与稳态 ray-cast 无关,已被 P13 思路解决)。
让每条线只渲视野内小区 → 即使 9 层叠加也可用。
- **9 层重叠 = LOD 之上的可选应用层优化**(对同路重扫冗余可"合并/降噪",顺带省 9×**不进渲染地基**。
用户要逐条区分就不合并(靠 LOD 摊薄),要纯总览就合并。
- **采样步长D 方案)= LOD 框架内的质量旋钮**,非独立根本解。
- **ESS/OSPRay/多后端:不做**(不解决 LOD 已解决的通用问题,对本数据收益差)。
**下一步(确诊已完成,可开工)**:把多线总览从"multi-volume 单遍固定整卷"改回"各线独立 mapper +
视野 LOD + 停手重建",让单帧渲染量随视野走、与 20 条总量解耦;实测多线总览是否达交互级。
这是顺着通用 LOD 框架、被 passcost 数据支撑的明确方向——不再围着 9 层重叠这个特例转。
---
### 附:相关已落地代码 / 诊断工具(如专家要复现)
- 通道插值:`src/io/gpr/GprGeometry.cpp::planChannelInterpolation` + `Gpr3dvVolumeBridge.cpp`
- 多体合成/退避/质量控制/通道 LOD`tools/gpr_poc/main.cpp::cmdViewAll`
- **诊断命令**`tools/gpr_poc/main.cpp`,可直接跑复现 §6/§7 的数):
- `gpr_poc ess-stat <dir> <line>`ESS 空块潜力§6.1
- `gpr_poc view-all <dir> <gps> --overlapStat`实测重叠层数§7.1
- `gpr_poc view-all <dir> <gps> --sampleDist D`步长↔fps§7.2
- `gpr_poc passcost --size 64 --overlap 0|1`N 遍开销 vs 重叠 隔离测§7.5
- 数据/插值口径 spec`docs/superpowers/specs/2026-06-25-gpr-line-channel-interpolation-and-multivolume.md`
- 多后端 ESS 架构 spec**结论:不做,见本文 §10**`docs/superpowers/specs/2026-06-26-gpr-multibackend-ess-rendering.md`
---
## 摘要(一页结论,供决策)
1. **现象**20 条通道插值密体总览,~1.7fps、交互更卡。视觉正确,纯性能。
2. **确诊**passcost 隔离测):**不是 N 遍固定开销**20 独立 mapper 铺开 78fps排除
是**当前根本没用视野 LOD、在渲整卷大贴图**× 9 层重叠)。本会话的 multi-volume 单遍为"单遍"
牺牲了 LOD是直接原因。
3. **通用根本解 = LOD**单帧渲染量与数据总量解耦扛任意大数据Task 12c 单体 752fps、passcost 多线
也成立。9 层重叠只是**本批数据的特例倍数**,是 LOD 之上一个可摊薄/可选合并的因子,**不是架构核心**。
4. **正解**:各线独立 mapper + 视野 LOD + 停手才重建(弃 multi-volume 单遍)。
5. **明确否定**ESS/OSPRay/多后端(对本数据 ~2×、不解决重叠、解决的是 LOD 已解决的通用问题)。

View File

@ -0,0 +1,132 @@
# ⚠️ 本 spec 已被实测推翻,勿照此实现
> **结论(见 `2026-06-26-gpr-3d-render-perf-ANALYSIS-for-review.md` §10ESS/OSPRay/多后端【不做】。**
> 实测ESS 对本数据 ~2× 且不解决重叠passcost 确诊"多线卡"的真因是【没用视野 LOD、渲整卷大贴图】
> 不是固定开销。**正解 = LOD 中心(各线独立 mapper + 视野 LOD + 停手重建),见下方实现计划。**
> 本文余下"多后端/ESS"内容仅作历史记录。
---
## 实现计划LOD 中心多线总览 — 已确诊、可执行)
**目标**:把 `cmdViewAll` 从"multi-volume 单遍 + 固定整卷大贴图"改为"各线独立 mapper + 视野 LOD +
停手才重建",使单帧渲染量随视野走、与 20 条总量解耦。
**改动步骤(`tools/gpr_poc/main.cpp::cmdViewAll`**
1. `PlacedSource` 加回**每线自己的 `vtkSmartVolumeMapper`**GPU 模式);删 multi-volume 用法
multiMapper/multiVol/port 不再需要,但可暂留不碍)。
2. **装配**:删 `buildBundles` + 退避(无 multi-volume 即无纹理单元上限);改为逐线
`mapper->SetInputData(baseImage)``volume->SetMapper(mapper)``ren->AddVolume(volume)`
各线 mapper 收进 `mappers` 向量(供质量控制)。
3. **开 LOD**`gViewAllBaseOnly = false`(启用引擎选区换图);引擎换的是"改包围盒的子区域"
各线独立 mapper 下**安全**(无 multi-volume 可破坏)。
4. **关 channel LOD**`gChanLod = false`——引擎金字塔已逐级降 Y无需单独抽 Y 平面。
5. `viewAllPickOneLine``ps.mapper->SetInputData(ps.currentImg); ps.mapper->Update();`(非 multiMapper 端口)。
6. **停手才重建(已有,确认接线)**:拖动中(`viewAllOnInteracting`)只降质+重置裁剪、**不提交引擎目标**
松手(`viewAllOnInteract`)/定时器 idle 才 `viewAllSubmitTargets`(提交 LOD 目标)+ `viewAllPickOneLine`
(拉就绪区域换上)。避免 P11/P12"每帧 20 条重建上传"thrash。
7. **质量旋钮保留**`--sampleDist`/`--maxImgSample`/`--dragSampleMul` 作 LOD 框架内的交互降质兜底。
8. **总览级别**:引擎 `selectLod` 按屏幕像素选层——拉远时全路映射到少量像素 → 自动选粗层(小贴图)→
20 条小贴图即可用;拉近 → 小区域细层。**确认 selectLod 多线下确实选到粗层**(若没有,调
selectLod 的屏幕像素阈值——这是 §7.5 嫌疑①的修复点)。
**验收**:离屏看各线底图 level 随相机距离变(远→粗/小、近→细/小区);真窗口测 20 条总览交互级 fps、
拖动跟手、松手清晰、过档位无明显卡顿(外部专家提示重点盯"停手重建过渡手感")。
---
# GPR 三维体渲染:多后端 + 空体素跳过(ESS) 加速架构 — Spec2026-06-26已废
> 解决"20 个重叠密体在 VTK 库存 OpenGL mapper 上又卡又只能降质"的根本性方案。
> 关联:`docs/superpowers/specs/2026-06-25-gpr-line-channel-interpolation-and-multivolume.md`(数据/插值口径)。
---
## 0. 一句话目标
用**空体素跳过(ESS)** 这一不损可见质量的业界技术,把"逐线分开的多个密体合并渲染"做到**不卡**
并以**多渲染后端 + 自动适配**覆盖客户侧未知/多厂商硬件(含无显卡)。
---
## 1. 问题与根因
- 现状渲染 = VTK `vtkGPUVolumeRayCastMapper`OpenGL。**任意 GPU 可跑(最通用),但无 ESS**。
- 20 个重叠半透明密体:每条光线每步在 20 体各采一次、光线又长 → 几十亿次纹理查找/帧 → 1.7fps、交互更卡。
- 通道插值后 Y 加密到 2.5cm,使自动沿光线步长更细 → 更卡。
- **已尝试的"交互降质"(屏幕降采样 + 沿光线步长加粗)是治标**,损可见质量。
## 2. 根本方案ESS不损质量
GPR 体 ~90% 是近零背景反射层之间空。ESS 用 min/max 加速结构**跳过"在传函里全透明"的块**
对稀疏数据常 550× 提速、**零质量损失**。但 **VTK 库存 GPU mapper 不做自动 ESS**(仅有受限的
`UseDepthPass` 等高线跳过)。→ 真 ESS 必须**换专业体渲染后端**(其底层自带 ESS + 正确合成多重叠体)。
## 3. 关键事实:跨厂商 GPU 加速不存在单一方案
GPU 体光追渲染器全是**厂商锁定**
| 后端 | 硬件 | ESS | 跨厂商 | 角色 |
|---|---|---|---|---|
| OpenGLvtkGPUVolumeRayCastMapper现状| 任意 GPU(N/A/Intel) | ❌ | ✅ | **终极兜底** |
| OSPRayCPUEmbree/ISPC| 任意 x86 CPU免显卡| ✅ | ✅ | **通用基线** |
| OSPRay-GPUSYCL/oneAPI| 仅 Intel Arc/数据中心卡 | ✅ | ❌ | Intel 独显 |
| ANARI + VisRTXOptiX| 仅 NVIDIA | ✅ | ❌ | N 卡 |
| AMD GPU 体渲染+ESS | — | — | ❌ 无成熟方案 | — |
**结论**:没有"同时 N/A 卡的 GPU-ESS"。**面向未知客户机OSPRay-CPU 是最稳通用选择**免显卡、ESS、质量不降
## 4. 渲染后端架构(多后端 + 自动适配 + 手动覆盖 + 兼容灰掉)
### 4.1 用户可见选项(按硬件/结果命名,不暴露库名)
| 用户看到 | 背后实现 | 适配硬件 |
|---|---|---|
| **自动(推荐)** | 探测后选下面之一 | — |
| GPU 加速N卡| ANARI + VisRTX | NVIDIA 独显 |
| GPU 加速Intel| OSPRay-GPU | Intel Arc 独显 |
| CPU通用免显卡| OSPRay-CPU | 任意 CPU |
| 通用 GPU兼容| OpenGL现状 mapper| 任意 GPU兜底|
### 4.2 自动探测逻辑
```
if NVIDIA 独显: → VisRTX(GPU)
elif Intel Arc 独显: → OSPRay-GPU
elif AMD(独显/核显) 或 Intel 核显 或 无显卡 或 探测失败:
→ OSPRay-CPU默认 // 核显一律走 CPU弱+共享内存+多不被GPU后端支持
// 强力 A 卡可手动选"通用 OpenGL"用其 GPU(无 ESS),但不作默认
```
- **手动覆盖**:用户可自选,但**只列出与当前硬件兼容的项**(不兼容灰掉,如 A 卡上禁 VisRTX
- **集显建议**:一律 OSPRay-CPUCPU+ESS 比让弱核显硬渲更稳更快)。
### 4.3 部署策略(一句话)
**OSPRay-CPU 保底通用;探测到 N卡/Intel Arc 时升对应 GPU 后端A 卡/核显吃 CPU 基线OpenGL 终极兜底。**
## 5. 上 ESS 后端后,废弃哪些
- ❌ **装箱单体(binning)**:当初为绕 OpenGL 无 ESS 才提(代价=丢"逐线分开"。ESS 后端让**逐线分开
的多体也快** → 不需要装箱。**逐线分开 + 不造假 + 不卡,三者兼得。**
- ❌ **交互降质权宜**(屏幕/沿光线步长加粗ESS 后端有自带的自适应/渐进式细化,基本不用;保留作任何后端的兜底。
- ❌ 自写 ESS shader / 预积分 / UseDepthPass后端自带无需自行实现。
- ✅ **保留**"选几条 ds(≤7)" 是使用方式(非技术),永远有效。
## 6. 实现要点(工程)
- VTK 需**重编**带:`RenderingRayTracing`(OSPRay)、`RenderingAnari`(ANARI/VisRTX) 模块 + 依赖
OSPRay/Embree/ISPC/OpenVKLANARI-SDK/VisRTX。用户已确认工程量无所谓。
- 渲染层抽象一个 `IVolumeRenderBackend`,运行时按 §4 选具体 mapper
`vtkGPUVolumeRayCastMapper`(OpenGL) / `vtkOSPRayPass`+volume / `vtkAnariPass`+volume。
- **数据不变**逐线密体含通道插值spec 前一份)原样喂各后端;多体合成由后端负责(无 K=7 分包)。
- 硬件探测GL_VENDOR / 平台 APIDXGI 枚举适配器)判 N/A/Intel + 独显/核显。
## 7. POC 计划(先验"CPU+ESS 够不够快"——最通用、风险最低)
1. **POC-1先做**:最小程序,用 **OSPRay-CPU** 渲一个 GPR 密体tmp/lines_all_dense 里一条/几条),
**实测普通 CPU 上对多体/密体的 fps + ESS 提速比**,对照现状 OpenGL。先确认 OSPRay 在本环境
可编可跑、CPU+ESS 实际够快——这是整套方案值不值得上的关键闸门。
2. **POC-2**:若有 N 卡ANARI+VisRTX 渲同一体,对照 GPU 提速。
3. POC 通过 → 才动手重编 VTK + 接后端抽象层。
## 8. 风险 / 待定
- OSPRay-GPUIntel较新、不如 CPU 路成熟ANARI/VisRTX 需 NVIDIA 驱动 + VisRTX 库。
- 各后端传函/外观与 OpenGL 有差异,需重新调一致。
- 本环境能否编出带光追/ANARI 的 VTKvcpkg/手动依赖)待 POC-1 验证。
- CPU+ESS 在低核机上的实际帧率待实测。
## 9. 验收
1. 客户机无论有无显卡/何种显卡自动选到可跑的后端并出图OSPRay-CPU 永远兜底)。
2. 20 条密体总览:逐线分开、不造假、**ESS 后端下不卡**(目标交互 ≥ 可用帧率,质量不降)。
3. 手动选项只列兼容项;集显默认 CPU。
4. 数据层(通道插值密体)零改动复用。

View File

@ -0,0 +1,144 @@
# 反演剖面三维体客户目标方法Surfer对照与客户端差距 — 2026-06-27
> 来源:客户提供的演示视频 `ScreenShot/9f67e80cb823170bb9374d779ec4c0cb.mp4`Golden Software Surfer13min
> 与参考成果图 `ScreenShot/Weixin Image_20260627080012_429_2117.png`
> 结论一句话:**客户用的就是"合并点云 + 3D 反距离加权(IDW) + 边界裁剪(Blanking) + 体绘制/等值面"。**
> 客户端插值内核已与之一致,差距只在「搜索半径 / 边界裁剪 / 等值面」三点。
---
## 0. 背景与一次认知纠偏
用户反馈:"选多个 dd_inversion_data江西理工四条井字相交测线做三维体得到的是四个剖面各自左右
拉伸的薄板,不连成完整体。"
排查中曾推荐"逐深度层各向异性插值"——看了客户演示视频后**确认是过度设计**。客户没有逐层做,
就是**朴素的三维 IDW**(对合并后的整团散点云一次性 3D 网格化)。本文以视频实证为准。
---
## 1. 客户目标方法Surfer 实测,逐帧为证)
| 步骤 | 操作 | 视频帧(时间戳)证据 |
|---|---|---|
| 1. 合并点云 | 所有测线/剖面反演单元合并成**一个 XYZC 文件** `combine-e_m_m.xyz`,列= **X, Y, Elevation, Resistivity, Conductivity, Sensitivity** | t48.7Grid Data 导入对话框Data Type=**XYZC** |
| 2. 三维网格化 | Grid Data → 方法 **Inverse Distance to a PowerIDW幂次** → XYZC 直接生成 **3D 网格体**;注脚明示"does not extrapolate beyond the range of data" | t48.7Gridding Method 选中 IDWt97.5Gridding 进度t195`.grd has been created` |
| 3. 边界裁剪 | 数字化**测区边界多边形**Base vector / Polyline**Blanking** 把体裁到测区真实足迹 | t292.5Polygon 工具数字化边界 |
| 4. 三维渲染 | 3D View → 3D Grid Volume`out-BAIHUA.vtk`**Volume render**(Sliced / Slice count 500 / Tri-linear / Alpha blending / Opacity 80%) + **Isosurface**(阈值 isovalue) + **Image slice**(YZ/Z) | t585Volume render 属性t682Isosurface(isovalue=1794.39)t633Image slice(YZ) |
要点:
- **真三维 IDW**,对合并点云一次成体(非逐层 2D
- IDW **不外推到数据范围外**;测区足迹靠 **Blanking 多边形**裁出(参考图那个不规则边界即来源于此)。
- 红色异常体 = **等值面抽取**Isosurface 按阈值)。
- 坐标为真实投影坐标 + 高程,可叠地形/影像底图。
---
## 2. 客户端现状(已实现部分)
生产路径:`Api3dRepository::createVolume``src/data/api/Api3dRepository.cpp`
→ 把所有选中 ds 的反演单元按测线真实几何配准合并成 `PointSet`
`buildVolume(pts, cellXY, cellZ, power, maxDist)``src/core/algo/VolumeBuilder.cpp`
**三维 IDW**`src/core/algo/IdwInterpolator.cpp``maxDist` 外置 NaN 留空。
即:**「合并点云 + 3D IDW」内核与 Surfer 一致**。参数见 `src/data/repo/VolumeBuildParams.hpp`
`cellXY=1.0, cellZ=0.5, power=2.0, maxDist=4.0`
渲染:`VoxelActor``src/render/actors/VoxelActor.hpp`)仅 GPU 体绘制NaN→透明**无等值面**
`GridContourActor`/`ContourBands` 是 2D 网格等值线,非 3D 等值面)。地形/影像/坐标轴/电极点已有
`TerrainActor`/`TileBasemap`/`AxesActor`/`ElectrodeActor`)。
---
## 3. 差距与修复(共 3 点)
> 不需改插值算法(内核已对);改的是搜索域、裁剪、与等值面。
### G1. 搜索半径 maxDist 太小 → "四块板"
`maxDist=4m` 远小于井字测线间距 → IDW 只填测线 ±4m 管套,线间留空 → 四块薄板。
**修复**:把搜索半径放大到覆盖测区(或提供"覆盖全域"选项),对齐 Surfer 默认搜索域行为。
### G2. 缺边界裁剪(Blanking) → 单纯放大半径只会"变粗"
**这是用户观察到"调大 maxDist 只是让体看起来更粗"的真正原因**:没有足迹裁剪,放大半径会把体
鼓满整个外接盒 → 粗大臃肿。Surfer 不粗,是因为 Blanking 把体裁到了测区真实多边形足迹。
**修复**:加**足迹掩膜**——
- 自动:散点平面**凸包 / alpha-shape**(或沿测线 buffer 并集);
- 或手动:支持用户数字化/导入边界多边形(对齐 Surfer Blanking
掩膜外体素整列置空NaN/透明)。
### G3. 缺 3D 等值面 → 出不来红色异常体
`VoxelActor` 只有体绘制。
**修复**:在体上加 **`vtkFlyingEdges3D` / `vtkContourFilter`** 抽等值面,阈值可调(对齐 Surfer Isosurface
---
## 4. 修复落地顺序
1. **G1+G2 一起做**(插值搜索域放大 + 足迹掩膜)→ 出满铺、裁到测区足迹的体。这是核心,先做。
2. **G3 等值面**(阈值可调)→ 出红色异常体。参考图第二主角,紧接着做。
3. 影像底图/坐标轴/电极点复用现有。
---
## 5. 必须先和客户对齐的预期(避免"又看起来不对"
参考图是**密集测网**(顶面可见很多条测线点阵);江西项目只有**四条井字线**。
- **形态可复刻**(满铺体 + 等值面 + 影像底图);
- 但**框内细节出不来**——参考图的细碎红异常源于密集采样,四条线之间只能给出平滑趋势,
等值面会是几个光滑大团。要那种精度需加密测线。
---
## 6. 非目标 / 说明
- 不改 IDW 内核算法本身(已与 Surfer 一致),不引入逐层各向异性(客户未用)。
- 各向异性搜索椭球可作为后续可选增强Surfer 亦为可选项,非默认),本期不做。
- Kriging 仍为占位(`VolumeBuildParams::Model::Kriging`core 未实现),本期不依赖。
---
## 7. 续:白化方式之争 + 体绘制边界「梯田」2026-06-28branch `fix/3d-volume-blanking-mask`
> 本节记录 §16 之后这一轮的来龙去脉、技术取舍与权威佐证,供后续决策不再反复。
### 7.1 前因后果(时间线)
客户原话:"**我们这个白化确实有问题,填色了,填了蓝色**"——无数据区被填成蓝色,而非 Surfer 那种透明白化。排查分三层、逐个修:
1. **切片填蓝**`vtkImagePlaneWidget` 会按【输入标量范围】(含哨兵)自动 window/level把哨兵顶到 LUT 最低色格(蓝)且不透明。
修复:`SliceTool` 钉死 `SetWindowLevel([vmin,vmax])` + `ColorLutBuilder` 预留 0 号"白化槽"(全透明),哨兵(<vmin)钳到该槽即透明实测证据`tests/spike/slice_alpha_probe.cpp` widget 离屏渲染 + 回读像素背板透出=透明成功)。**这条同时纠正了切片颜色映射**(之前 colorbar 被错误拉伸到切片局部范围)。
2. **早期"满屏蓝"是误判**:实为 `maxDist=0`(自动覆盖测区,`kDefMaxDist=0.0`)把整个凸包**填实**=真实低值数据(蓝)**不是空值**(见 §3 G1。日志实证请求体 `"maxDist":0`
3. **三维体渗蓝**:无数据格设哨兵 `vmin-1`、不透明度 0三线性插值在"哨兵↔真值"交界处插出低值(蓝)且不透明度非零 → 渗一圈蓝。为消除,给体绘制加**二值 mask**`VoxelActor::makeMaskLike` + `assembleVolume``vtkGPUVolumeRayCastMapper`+`SetMaskInput`+`SetMaskTypeToBinary`mask=0 体素被光线投射**硬跳过**)。
**mask 的副作用 = 用户 2026-06-28 截图的「竖条/梳齿/底边锯齿」**:硬跳过=边界不再三线性插值/羽化。`maxDist=0` 下体填满**凸包足迹**(带斜边多边形),斜边在 1m 规则网格上离散成**体素阶梯(staircase)**。以前(SmartVolumeMapper+哨兵→不透明度0 软消隐)斜边被羽化抹圆、看不出;加 mask 后变成逐体素硬台阶。**即使色阶不透明度=100% 也可见**——因 mask 在不透明度传函【之前】就把体素从光线上删了(两个并行子代理:渲染侧 + 数据/建体侧共同确诊)。
### 7.2 取舍的本质
| 方案 | 边界观感 | 数据诚实度 | 误判风险 |
|---|---|---|---|
| **二值 mask当前** | 体素梯田(难看) | ✅ 只画真数据、零假值 | 低(梯田明显是网格对齐的足迹边界,不像地质体) |
| 软消隐哨兵→不透明度0无 mask | 平滑 | ❌ 边界是插值出的假值 | **高** |
### 7.3 「细蓝边」是什么 + 为何【不能】回退软消隐(权威佐证)
软消隐回退后的"细蓝边" = 真数据格与哨兵格(`vmin-1`)三线性插值出的、**数据里根本不存在的假低阻值**。低阻在物探通常指含水/导电体,这圈蓝出现在测区最外缘会被**误读成"边界存在真实低阻(导电)异常带"**——真有数据分析歧义。
多个权威来源一致认定"插值进 NoData 产生边缘伪影"是错误做法、应 mask 排除:
- **ESRI 官方**:双线性/三次插值 "**interpolate incorrectly into the NoData** and background areas... producing **artifacts or black ridges**" —— 我们的"蓝脊"与之同源(哨兵钳到最低色=蓝)。<https://support.esri.com/en-us/knowledge-base/faq-why-do-bilinear-interpolation-and-cubic-convolution-000003271>
- **GDAL 官方**:正确做法是把 masked NoData 的 "**weights of contributing source pixels are set to zero to ignore them**" / "will not be used in interpolation"。<https://gdal.org/en/stable/programs/gdalwarp.html>
- **rasterio**bilinear 重采样在 NoData 边界产生 invalid 值(已知 issue #1721)。<https://github.com/rasterio/rasterio/issues/1721>
- **Golden Software Surfer**客户参照工具NoData "**removed from the neighborhood**",不跨它插值。<https://surferhelp.goldensoftware.com/gridmisc/Blanked_Nodes_Grid_Filter.htm>(定义 <https://surferhelp.goldensoftware.com/glossary/def_blanking.htm>
- **凸包外 = 外推**"**Extrapolated data is usually meaningless and misleading.**" <https://github.com/fatiando/fatiando/pull/44><https://www.spatialanalysisonline.com/HTML/gridding_and_interpolation_met.htm>
**结论:二值 mask = 业界标准的"把 NoData 排除出插值"做法,是对的**;梯田只是 mask 在斜足迹边界上的网格离散观感(诚实、不误导)。**不应回退软消隐**=让哨兵参与插值=以上权威明确反对的造假值做法)。
### 7.4 决策与待办(截至 2026-06-28本分支未提交
- ✅ **保留二值 mask**(数据诚实/合规,符合 ESRI/GDAL/Surfer 标准)。
- 梯田若要压平,走**不造假值**的路(二选一,**待用户/客户拍板**
- (a) **细化 XY 网格**`cellXY` 1m→0.5m阶梯缩到亚像素代价体素×4、耗时 ~3.5s→~14s、内存×4。
- (b) **接受梯田**:它诚实、且明显是足迹边界,不会被当成地质体。
- 渲染侧本轮其它已落地修复(排查"分层/稠密"时做、确认非主因但保留GPU 探测+CPU 回退(`setVolumeGpuSupported`)、细采样距离+`UseJittering`、`ScalarOpacityUnitDistance=对角/10`、去 `kMaxOpacity`(改由色阶「不透明度」单一控制、100%=实心)、移除工具条「透」滑块。
- **附带缺陷(待修)**`VoxelGenerateRequest::maxDist` 结构体默认 `4.0``src/data/dto/Vtk3dRequests.hpp:18`)与对话框 `kDefMaxDist=0.0``VolumeParamsDialog.cpp:34`)不一致——绕过对话框直建会拿到 4m → 退回"四块板/线间空隙"老问题,应统一为 0。
- 抽稀空间哈希 `(ix*p1)^(iy*p2)^(iz*p3)``VolumeBuilder.cpp` ~146-151为 XOR 非单射、有碰撞风险(与本症无关,但宜换 `(iz*ny+iy)*nx+ix` 线性键)。

View File

@ -0,0 +1,231 @@
# 数据集详情视图:架构详解 + 「新增一种 ds 类型详情页」扩展指南 — 2026-06-28
> 读者:将为**一种新数据集类型**开发详情页的同事。读完本文你应能**独立按现有架构扩展,无需口头交接**。
> 所有引用均为 `文件:行号`(可点击)。代码以 2026-06-28 `fix/3d-volume-blanking-mask` 分支为准。
---
## 0. 一句话架构
**详情页 = 数据驱动的"策略 + 页签引擎"**:每种 ds 类型(由 **`ddCode`** 标识)有一个**策略**声明它有哪些页签(每页签 = 一种视图 + 一个加载键);控制器按 `ddCode` 找策略 → 建页签 → 按加载键**异步**拉数据 → 解析成**类型擦除的 payload(`QVariant`)** → **工厂**按视图种类造视图 → 视图 `setPayload` 自解包渲染。**新增类型 = 加一个策略 + 一条加载键分发 + (必要时)一个视图****不动**列表/控制器/壳层。
---
## 1. 端到端数据流(双击一个数据集会发生什么)
```
[列表 QTreeWidget] 双击 item
main.cpp:1501 itemDoubleClicked → 读 item 的 data role
kDsIdRole / kDsDdCodeRole / kDsNameRole / kDsTmObjectIdRole
main.cpp:1509 detailCtrl.openDataset(dsId, ddCode, dsName, tmObjectId)
[编排层 DatasetDetailController]
DatasetDetailController.cpp:21 openDataset()
registry_.find(ddCode) → 找策略(找不到 → emit loadFailed "暂不支持该数据类型的预览",优雅降级)
strategy->tabs() → std::vector<TabSpec>
emit datasetOpened(dsId, ddCode, dsName, tmObjectId, tabs) ──┐ 建页
对每个【非 lazy】页签 loadTab(dsId, ddCode, i) │
│ │
▼ DatasetDetailController.cpp:46 loadTabImpl() │
repo_.loadAsync(spec.loaderKey, dsId, pageNo, pageSize) │ 按【loaderKey】异步加载
→ DetailLoad*(在飞句柄,存 inflight_[tabIndex]abort-and-replace
emit tabLoadStarted(dsId, i) → 页上盖「加载中」遮罩 │
│ DetailLoad::done(QVariant payload) │
▼ │
emit tabReady(dsId, i, payload) │
│ │
▼ ▼
[壳层 DatasetDetailPanel(QTabWidget每个 ds 一页) / DatasetDetailPage(单 ds多页签)]
Panel.onDatasetOpened (DatasetDetailPanel.cpp:38) ← datasetOpened
new DatasetDetailPage → 注入(repo/viewState/tmObjectId) → page.build(tabs)
Page.build (DatasetDetailPage.cpp:33)
对每个 TabSpecmakeDetailView(spec.kind, ...) 造视图 → 组装页签
Panel.onTabReady (DatasetDetailPanel.cpp:61) ← tabReady
page.setTabPayload(i, payload) (DatasetDetailPage.cpp:92)
→ views_[i]->setPayload(payload) ← 视图自解包 payload 渲染
```
懒加载 / 分页是**反向**信号(页 → 面板 → 控制器):
- **lazy 页签**首次点开 → `Page::tabNeeded``Panel::tabNeeded``detailCtrl.loadTab`main.cpp:1522
- **paginated 页签**翻页 → `DataTableView::pageRequested``Page::tabPageNeeded``Panel::tabPageNeeded``detailCtrl.loadTabPaged`main.cpp:1525
---
## 2. 五个核心抽象(必须先理解)
| 抽象 | 定义处 | 作用 |
|---|---|---|
| **`ViewKind`**(枚举) | `src/controller/DatasetDetailTab.hpp:10` | 视图渲染种类全集:`Scatter / FilledContour / Bar / LineProfile / PolylineMap / Table / WebMap` |
| **`TabSpec`** | `src/controller/DatasetDetailTab.hpp:13` | 页签描述符 `{QString title; ViewKind kind; QString loaderKey; bool lazy; bool paginated;}` |
| **`IDatasetChartStrategy`** + **`ChartStrategyRegistry`** | `src/controller/IDatasetChartStrategy.hpp` | 策略:`ddCode()` + `tabs()`;注册表按 `ddCode` 索引策略 |
| **`IDetailView`** | `src/app/panels/chart/IDetailView.hpp` | 视图统一接口:`QWidget* widget()` + `void setPayload(const QVariant&)` |
| **Payload 结构体**6 种)+ **`DetailLoad`/`ApiDetailLoad`** | `src/core/model/detail/DetailPayloads.hpp`、`src/data/api/DatasetLoadHandles.hpp` | 纯数据载荷(`QVariant` 类型擦除)+ 异步加载句柄 |
**关键契约**
- **`ddCode`** = 分发主键(后端给每个 ds 带的类型码,如 `dd_inversion_data`)。列表把它存进 item 的 `kDsDdCodeRole`,双击时透传给 `openDataset`
- **`loaderKey`** = 控制器与仓储之间的字符串契约(如 `"inversion.scatter"`)。控制器只认 `TabSpec.loaderKey`,仓储 `loadAsync` 按它分发到具体加载函数。
- **payload** 必须 `Q_DECLARE_METATYPE` + 用 `QVariant::fromValue(...)` 装、`qvariant_cast<T>(...)`(或 `.value<T>()`)解。
---
## 3. 现有 5 种 ds 类型对照表(照抄即模板)
| ddCode | 策略(`src/app/panels/chart/*Strategy.hpp` | 页签title / ViewKind / loaderKey / lazy / paginated | payload | 视图 |
|---|---|---|---|---|
| `dd_inversion_data` | `ErtInversionStrategy.hpp` | 原数据 / Scatter / `inversion.scatter` / 否 / 否;<br>网格数据 / FilledContour / `inversion.grid` / **lazy** / 否 | `ScatterPayload` / `ContourPayload` | RawDataChartView / GridDataChartView |
| `dd_ert_measurement_data` | `MeasurementStrategy.hpp` | 散点 / Scatter / `ert_measurement.scatter`;列表 / Table / `ert_measurement.rows` | `ScatterPayload` / `TablePayload` | RawDataChartView / DataTableView |
| `dd_ert_measurement_gr_data` | `GrMeasurementStrategy.hpp` | 柱状 / Bar / `gr.bar`;列表 / Table / `gr.rows` | `BarPayload` / `TablePayload` | BarChartView / DataTableView |
| `dd_trajectory_data` | `TrajectoryStrategy.hpp` | 列表 / Table / `traj.rows`;高程 / LineProfile / `traj.elev`;地图 / WebMap / `traj.map` | `TablePayload` / `LinePayload` / `MapPayload` | DataTableView / LineChartView / TrajectoryMapView |
| `dd_grid` | `GridStrategy.hpp` | 列表 / Table / `grid.rows` / 否 / **paginated** | `TablePayload`(分页) | DataTableView |
注册处(**唯一**集中点):`src/app/main.cpp:2378-2383`
```cpp
geopro::controller::ChartStrategyRegistry chartRegistry;
chartRegistry.add(std::make_unique<geopro::app::ErtInversionStrategy>());
chartRegistry.add(std::make_unique<geopro::app::MeasurementStrategy>());
// ... 共 5 个
geopro::controller::DatasetDetailController detailCtrl(datasetRepo, chartRegistry); // main.cpp:2384
```
---
## 4. 分层职责与关键文件(按数据流顺序)
1. **列表层** —— `QTreeWidget``DatasetListPanel::populateDatasetList``src/app/panels/DatasetListPanel.cpp:33`)。**通用、与类型无关**:从后端 ds 列表(`std::vector<data::DsRow>`)填树,每行把 `ddCode` 等写进 data role。role 常量定义在 `src/app/panels/DatasetListPanel.hpp:16-26`
`kDsIdRole=0x0100` / `kDsDdCodeRole=0x0104` / `kDsNameRole=0x0105` / `kDsTmObjectIdRole=0x0108`
**两个详情入口**(都按 `kDsDdCodeRole` 路由):
- 左下数据列表双击 → `main.cpp:1501-1510` 直接 `detailCtrl.openDataset(dsId, ddCode, dsName, tmObjectId)`
- 数据列分类区双击 → `CategorySection::detailRequested(dsId, ddCode, name)``src/app/panels/columns/CategorySection.cpp:124`)→ `CategoryAnalysisTab``columns/CategoryAnalysisTab.cpp:50`)→ `buildWorkbench``openDataset``main.cpp:1967`)。⚠️ 此入口只带 dsId/ddCode/name、**不带 tmObjectId**(白化模板列表可能为空——新类型若依赖 tmObjectId 需留意)。
**新增类型通常【不用动列表】**:只要后端把新 ds 带它的 `ddCode` 返回,它就会出现在树里、双击即按 `ddCode` 路由到你的策略。
2. **编排层** —— `DatasetDetailController``src/controller/DatasetDetailController.cpp``openDataset:21` / `loadTab:37` / `loadTabPaged:41` / `loadTabImpl:46`。只依赖 `IAsyncDatasetRepository` + `ChartStrategyRegistry`**不认识任何具体类型**。
3. **策略层** —— `src/app/panels/chart/*Strategy.hpp`(每个 ~15 行)。**新增类型主要在这里加一个文件**。
4. **数据层** —— `src/data/api/ApiDatasetRepository.cpp`
- 分发 `loadAsync:160``if (loaderKey == "...") return makeXxx(dsId);`,未知 key 抛 `runtime_error`→ 控制器兜成 `loadFailed`)。
- 加载函数 `makeXxx:175-247``new ApiDetailLoad(xxxBatch(api_, dsId), [](r){ return QVariant::fromValue(payload); })`。
- 批次 `xxxBatch:52-154`(匿名命名空间):`new net::ApiBatch({api.getAsync(端点), api.postJsonAsync(端点, body)...}, &isFailure)`——**唯一端点定义处**。
- 解析器 `parseXxxParts`(本文件匿名 ns`dto::parseXxx``src/data/dto/*Dto.cpp``QList<ApiResponse>` → payload。
5. **视图层** —— 工厂 `src/app/panels/chart/DetailViewFactory.cpp:15``ViewKind` → `IDetailView`,并注入 cmdRepo/colorTplRepo/viewState/getter各视图 `RawDataChartView/GridDataChartView/DataTableView/BarChartView/LineChartView/TrajectoryMapView`
6. **壳层** —— `DatasetDetailPage`(单 ds 多页签:`build:33`、`setTabPayload:92`、lazy 遮罩、分页冒泡);`DatasetDetailPanel`QTabWidget每 ds 一页,路由控制器信号:`onDatasetOpened:38` / `onTabReady:61`)。
7. **接线** —— `main.cpp`:面板创建+注入 `1390-1395`;控制器↔面板信号 `1513-1531`;策略注册+控制器 `2378-2384`
---
## 5. 扩展配方:新增一种 ds 类型的详情页
### 前置:先判断走 5A 还是 5B
- 你的页签能否**复用现有 `ViewKind`**(散点 / 等值面 / 表格 / 柱状 / 折线 / 地图)?
- **能** → 走 **5A**(最常见,多数"又一种表格/散点"属于此)。
- **不能**(要全新的图) → 走 **5B**5A + 新视图)。
### 5A. 复用现有视图4 步,全在已有文件里加)
**① 写策略** —— 新建 `src/app/panels/chart/FooStrategy.hpp`(照抄 `GridStrategy.hpp`
```cpp
#pragma once
#include <vector>
#include "IDatasetChartStrategy.hpp"
namespace geopro::app {
struct FooStrategy : controller::IDatasetChartStrategy {
std::string ddCode() const override { return "dd_foo"; } // ← 后端给的类型码
std::vector<controller::TabSpec> tabs() const override {
return {
{QStringLiteral("列表"), controller::ViewKind::Table,
QStringLiteral("foo.rows"), /*lazy*/ false, /*paginated*/ false},
// 慢的页签设 lazy=true服务端分页设 paginated=true视图须是 DataTableView
};
}
};
} // namespace geopro::app
```
**② 注册策略** —— `src/app/main.cpp:2383` 后加一行:
```cpp
chartRegistry.add(std::make_unique<geopro::app::FooStrategy>());
```
并在 main.cpp 顶部 include `panels/chart/FooStrategy.hpp`
**③ 加 loaderKey 分发 + 加载函数** —— `src/data/api/ApiDatasetRepository.cpp`
- 分发表 `loadAsync`:171 后):`if (loaderKey == "foo.rows") return makeFooRows(dsId);`
- 批次(匿名 ns:154 附近):
```cpp
net::ApiBatch* fooRowsBatch(net::ApiClient& api, const std::string& dsId) {
QList<net::IApiCall*> calls{
api.getAsync(QStringLiteral("/business/dd/foo/rows?dsObjectId=%1").arg(enc(dsId))),
};
return new net::ApiBatch(calls, &isFailure);
}
```
- 加载函数(:247 附近):
```cpp
DetailLoad* ApiDatasetRepository::makeFooRows(const std::string& dsId) {
return new ApiDetailLoad(fooRowsBatch(api_, dsId), [](const QList<net::ApiResponse>& r) {
return QVariant::fromValue(dto::parseFooTable(r[0].data)); // → 复用的 payload如 TablePayload
});
}
```
- 在 `src/data/api/ApiDatasetRepository.hpp` 声明 `makeFooRows`
**④ 写解析器** —— `src/data/dto/FooDto.{hpp,cpp}`(照抄 `MeasurementDto`/`GridDto``parseFooTable(const QJsonObject&) → core::TablePayload`。新增 .cpp 记得加进 `src/data/CMakeLists.txt`(或对应子目录的 CMakeLists
> 复用视图时**视图层、工厂、壳层、控制器、列表全不动**。
### 5B. 需要全新视图(在 5A 之上多 5 步)
**① 加 ViewKind** —— `src/controller/DatasetDetailTab.hpp:10` 枚举追加 `Foo`
**② 加 payload** —— `src/core/model/detail/DetailPayloads.hpp`:定义 `struct FooPayload {...};`,文件末尾加 `Q_DECLARE_METATYPE(geopro::core::FooPayload)`
**③ 写视图** —— `src/app/panels/chart/FooChartView.{hpp,cpp}`,实现 `IDetailView`
```cpp
class FooChartView : public QWidget, public IDetailView {
public:
QWidget* widget() override { return this; }
void setPayload(const QVariant& v) override {
const auto p = v.value<geopro::core::FooPayload>(); // 解包
/* 用 p 渲染QwtPlot / ECharts-via-QWebEngine / 自绘) */
}
};
```
**④ 加工厂分支** —— `src/app/panels/chart/DetailViewFactory.cpp:22``switch` 里加 `case controller::ViewKind::Foo: return std::unique_ptr<IDetailView>(new FooChartView(parent));`(需注入仓储就照 Scatter/Table 分支注入)。
**⑤ 注册 .cpp** —— `src/app/CMakeLists.txt``FooChartView.cpp`
然后照 **5A 的 ①②③④** 接策略/loaderKey/加载/解析。
---
## 6. 关键约定与坑(务必知道)
- **优雅降级**`ddCode` 未注册策略 → 控制器 `emit loadFailed "暂不支持该数据类型的预览"``DatasetDetailController.cpp:26`)。所以"详情打不开"先查**策略是否注册**。
- **未知 loaderKey** → 仓储 `loadAsync``std::runtime_error`,控制器 `loadTabImpl` 就地兜成 `loadFailed``:59-63`**不会**让遮罩永久悬挂。策略 loaderKey 必须与仓储分发表**字符串完全一致**。
- **payload 类型擦除**:忘了 `Q_DECLARE_METATYPE``QVariant::fromValue`/`.value<T>()` 不匹配 → 视图拿到空 payload、静默不渲染。
- **lazy**:数据慢的页签设 `lazy=true`——开页**不**加载,首次点开才发 `tabNeeded``DatasetDetailPage.cpp:80`),其间盖 `LoadingOverlay`
- **paginated**:服务端分页**只支持 `DataTableView`**`Page::build:61` 用 `qobject_cast<DataTableView*>``pageRequested``makeXxxRows` 要透传 `pageNo/pageSize`(见 `makeGridRows:238`)。
- **注入链**:视图所需仓储/getter`cmdRepo`/`colorTplRepo`/`projectIdGetter`/`tmObjectId`/`viewState`)由工厂在造视图时注入(`DetailViewFactory.cpp:15-65`),而面板/页**必须在 `build()` 之前**完成注入(`DatasetDetailPanel.cpp:45-48`)。新视图若要新仓储,沿 `makeDetailView` 形参 → `Page::build``Panel::onDatasetOpened``main.cpp` 注入这条链透传。
- **跨视图色阶联动(可选)**:若新视图涉及色阶且要和 2D/3D 联动,注入 `DatasetViewState* viewState`(单一真源,按 dsId不涉及就忽略传 nullptr 无副作用。详见 `src/controller/DatasetViewState.hpp`
- **并发正确性**:同一页签重复加载用 **abort-and-replace** + **句柄身份比对丢弃迟到信号**`DatasetDetailController.cpp:66,72`);退出时 abort 全部在飞句柄(`~DatasetDetailController:17`)。你写 `makeXxx` 不用操心,沿用 `ApiDetailLoad` 即可。
---
## 7. 你需要触碰的文件清单(速查)
| 场景 | 必改文件 |
|---|---|
| **5A 复用视图** | ① `FooStrategy.hpp`(新) ② `main.cpp`(注册+include) ③ `ApiDatasetRepository.{cpp,hpp}`(分发+批次+make) ④ `FooDto.{hpp,cpp}`(新解析) + 对应 `CMakeLists.txt` |
| **5B 全新视图** | 5A 全部 + `DatasetDetailTab.hpp`(ViewKind) + `DetailPayloads.hpp`(payload) + `FooChartView.{hpp,cpp}`(新) + `DetailViewFactory.cpp`(分支) + `src/app/CMakeLists.txt` |
| **永远不用动** | `DatasetDetailController`、`DatasetDetailPanel/Page`、列表 `QTreeWidget`、`IDetailView`、`DetailLoad` |
---
## 8. 自测建议
- **策略层单测**`FooStrategy{}.ddCode()` / `.tabs()` 返回符合预期(纯函数,易测)。
- **分发单测**`ApiDatasetRepository::loadAsync("foo.rows", ...)` 不抛、返回非空 `DetailLoad`
- **解析器单测**:喂一份真实后端 JSON 给 `dto::parseFooTable`,断言 payload 字段(参考现有 `tests/` 下 DTO 测试)。
- **联调**:构建 `build.bat app`,登录后在数据集树双击该类型 ds看页签/数据/遮罩是否正常;打不开先看桌面日志 `%LOCALAPPDATA%/Geomative/Geopro3/logs/geopro_*.log``[detail] openDataset ...``[detail] 未注册策略/loadAsync 失败`
---
_本文覆盖详情视图全链路列表视图为通用容器按后端 ds 列表 + ddCode 驱动),新增类型一般无需改它。如新类型在树中的归类/图标有特殊需求,再看列表填充处(`kDsDdCodeRole` 等 role 的写入点。_

View File

@ -124,6 +124,16 @@ if (-not $SkipDeploy) {
} finally { } finally {
if (-not $adsPreexisted) { Remove-Item $adsTmp -Force -ErrorAction SilentlyContinue } if (-not $adsPreexisted) { Remove-Item $adsTmp -Force -ErrorAction SilentlyContinue }
} }
# 中文化windeployqt --no-translations 不带翻译,单独拷 Qt 自带 zh_CNQMessageBox/QFileDialog
# 等标准按钮中文化app 启动按 exe 旁 translations\ 加载)。
$qtZh = Join-Path $QtBin '..\translations\qtbase_zh_CN.qm'
if (Test-Path $qtZh) {
$stageTr = Join-Path $StageDir 'translations'
New-Item -ItemType Directory -Force $stageTr | Out-Null
Copy-Item $qtZh $stageTr -Force
} else {
Warn "未找到 qtbase_zh_CN.qm$qtZh)—部署版标准按钮可能仍为英文"
}
} }
# --- 5.5 随包数据:本地样本演示数据 + PROJ 数据exe 旁布局,运行时相对定位)------- # --- 5.5 随包数据:本地样本演示数据 + PROJ 数据exe 旁布局,运行时相对定位)-------

View File

@ -4,30 +4,28 @@
#include "EmptyAwareComboBox.hpp" #include "EmptyAwareComboBox.hpp"
#include <QFormLayout> #include <QFormLayout>
#include <QJsonArray>
#include <QJsonObject>
#include <QLabel> #include <QLabel>
#include <QLineEdit> #include <QLineEdit>
#include <QPainter>
#include <QPen>
#include <QPixmap> #include <QPixmap>
#include <QPlainTextEdit> #include <QPlainTextEdit>
#include <QPointer>
#include <algorithm>
#include "FormKit.hpp" #include "FormKit.hpp"
#include "Theme.hpp" #include "Theme.hpp"
#include "repo/IDatasetCommandRepository.hpp"
namespace geopro::app { namespace geopro::app {
namespace {
// 异常类型 mock 列表label, id。真实 exceptionType 端点只读、后续接。
struct TypeItem { const char* label; const char* id; };
const TypeItem kMockTypes[] = {
{"断层", "mock-fault"},
{"破碎带", "mock-fracture"},
{"含水构造", "mock-water"},
{"其它", "mock-other"},
};
} // namespace
AnomalySaveDialog::AnomalySaveDialog(const QString& screenshotPath, int shotW, int shotH, AnomalySaveDialog::AnomalySaveDialog(const QString& screenshotPath, int shotW, int shotH,
geopro::data::IDatasetCommandRepository* cmdRepo,
const QString& projectId, int remarkSourceType,
QWidget* parent) QWidget* parent)
: QDialog(parent) { : QDialog(parent), cmdRepo_(cmdRepo), remarkSourceType_(remarkSourceType) {
setWindowTitle(QStringLiteral("保存异常")); setWindowTitle(QStringLiteral("保存异常"));
setModal(true); setModal(true);
@ -42,10 +40,17 @@ AnomalySaveDialog::AnomalySaveDialog(const QString& screenshotPath, int shotW, i
form->addRow(formkit::editLabel(QStringLiteral("名称")), name_); form->addRow(formkit::editLabel(QStringLiteral("名称")), name_);
type_ = new EmptyAwareComboBox(); type_ = new EmptyAwareComboBox();
for (const auto& t : kMockTypes) type_->setPlaceholderText(QStringLiteral("请选择异常类型")); // 空(如该形态平台无类型)时显灰占位+「暂无数据」
type_->addItem(QString::fromUtf8(t.label), QString::fromUtf8(t.id));
formkit::capField(type_); formkit::capField(type_);
form->addRow(formkit::editLabel(QStringLiteral("异常类型")), type_); form->addRow(formkit::editLabel(QStringLiteral("异常类型")), type_);
// 样式预览:选中类型的 legend 派生样式可视化(点=色球/线=线型/面=描边矩形)。
stylePreview_ = new QLabel(QStringLiteral(""));
stylePreview_->setMinimumWidth(geopro::app::scaledPx(92));
form->addRow(formkit::editLabel(QStringLiteral("样式")), stylePreview_);
// 选中类型变化 → 拉其平台样式(legend),使保存的异常按平台类型样式渲染 + 刷新预览。
connect(type_, qOverload<int>(&QComboBox::currentIndexChanged), this,
[this](int) { loadStyleForCurrent(); });
loadTypes(cmdRepo, projectId, remarkSourceType); // 异步拉平台异常类型填充(与平台一致)
remark_ = new QPlainTextEdit(); remark_ = new QPlainTextEdit();
remark_->setFixedHeight(geopro::app::scaledPx(60)); remark_->setFixedHeight(geopro::app::scaledPx(60));
@ -69,6 +74,78 @@ AnomalySaveDialog::AnomalySaveDialog(const QString& screenshotPath, int shotW, i
formkit::addDialogButtons(root, this); formkit::addDialogButtons(root, this);
} }
void AnomalySaveDialog::loadTypes(geopro::data::IDatasetCommandRepository* cmdRepo,
const QString& projectId, int remarkSourceType) {
if (cmdRepo == nullptr || projectId.isEmpty()) return; // 无仓储/项目 → 下拉留空(空态提示)
QPointer<AnomalySaveDialog> self(this);
cmdRepo->listExceptionTypes(
projectId, QString::number(remarkSourceType),
[self](bool ok, QJsonArray list, const QString&) {
if (!self || !ok) return; // 对话框已关 / 失败 → 留空
for (const QJsonValue& v : list) {
const QJsonObject o = v.toObject();
// 平台响应项:{label:类型名, value:类型id}(实测扁平 data 数组net 层已归一 value
self->type_->addItem(o.value(QStringLiteral("label")).toString(),
o.value(QStringLiteral("value")).toString());
}
self->loadStyleForCurrent(); // 首项自动选中 → 预取其样式
});
}
void AnomalySaveDialog::loadStyleForCurrent() {
if (cmdRepo_ == nullptr) return;
const QString typeId = type_->currentData().toString();
if (typeId.isEmpty()) return;
QPointer<AnomalySaveDialog> self(this);
cmdRepo_->getExceptionTypeDetail(typeId, [self](bool ok, QJsonObject detail, const QString&) {
if (!self || !ok) return;
const QJsonObject lg = detail.value(QStringLiteral("legend")).toObject();
// 按形态(1点/2线/3面)从 legend 派生样式:点用 pointColor线/面用 polyline*。
if (self->remarkSourceType_ == 1) {
self->styleColor_ = lg.value(QStringLiteral("pointColor")).toString();
} else {
self->styleColor_ = lg.value(QStringLiteral("polylineColor")).toString();
self->styleWidth_ = lg.value(QStringLiteral("polylineWidth")).toDouble();
self->styleDashed_ =
lg.value(QStringLiteral("polylineShape")).toString().contains(QStringLiteral("dash"));
}
self->updateStylePreview();
});
}
void AnomalySaveDialog::updateStylePreview() {
if (stylePreview_ == nullptr) return;
const QColor col(styleColor_);
if (!col.isValid()) { // 未取到样式 → 占位
stylePreview_->setPixmap(QPixmap());
stylePreview_->setText(QStringLiteral(""));
return;
}
const int w = geopro::app::scaledPx(92), h = geopro::app::scaledPx(22);
QPixmap pm(w, h);
pm.fill(Qt::transparent);
QPainter p(&pm);
p.setRenderHint(QPainter::Antialiasing, true);
QPen pen(col);
pen.setWidthF(std::clamp(styleWidth_ > 0.0 ? styleWidth_ : 2.0, 1.0, 4.0));
if (styleDashed_) pen.setStyle(Qt::DashLine);
if (remarkSourceType_ == 1) { // 点:实心色球
p.setPen(Qt::NoPen);
p.setBrush(col);
p.drawEllipse(QPointF(w / 2.0, h / 2.0), h * 0.3, h * 0.3);
} else if (remarkSourceType_ == 2) { // 线:按线宽/虚实画线
p.setPen(pen);
p.drawLine(QPointF(w * 0.1, h / 2.0), QPointF(w * 0.9, h / 2.0));
} else { // 面:描边矩形 + 淡填充
p.setPen(pen);
p.setBrush(QColor(col.red(), col.green(), col.blue(), 40));
p.drawRect(QRectF(w * 0.12, h * 0.22, w * 0.76, h * 0.56));
}
p.end();
stylePreview_->setText(QString());
stylePreview_->setPixmap(pm);
}
QString AnomalySaveDialog::anomalyName() const { QString AnomalySaveDialog::anomalyName() const {
const QString n = name_->text().trimmed(); const QString n = name_->text().trimmed();
return n.isEmpty() ? QStringLiteral("异常") : n; return n.isEmpty() ? QStringLiteral("异常") : n;

View File

@ -7,24 +7,52 @@ class QComboBox;
class QPlainTextEdit; class QPlainTextEdit;
class QLabel; class QLabel;
namespace geopro::data {
class IDatasetCommandRepository;
}
namespace geopro::app { namespace geopro::app {
// 异常保存对话框(#4b需求 R50名称 + 异常类型 + 备注 + 截图预览/大小。 // 异常保存对话框(#4b需求 R50名称 + 异常类型 + 备注 + 截图预览/大小。
// 异常类型本期 mock 列表(真实 exceptionType 端点只读、后续可接。accept 后取 name/typeName/typeId/remark。 // 异常类型从平台按标注形态(remarkSourceType)异步拉取,与平台保持一致。accept 后取 name/typeName/typeId/remark。
class AnomalySaveDialog : public QDialog { class AnomalySaveDialog : public QDialog {
Q_OBJECT Q_OBJECT
public: public:
// screenshotPath圈定结束截图的本地路径为空则不显示预览w/h截图像素尺寸R50「确定截图大小」 // screenshotPath圈定结束截图的本地路径为空则不显示预览w/h截图像素尺寸R50「确定截图大小」
AnomalySaveDialog(const QString& screenshotPath, int shotW, int shotH, QWidget* parent = nullptr); // cmdRepo/projectId异步拉取平台异常类型填充下拉与平台一致remarkSourceType标注形态 1点/2线/3面
// 决定查询哪一类平台异常类型。cmdRepo 为空则下拉留空(空态由 EmptyAwareComboBox 提示)。
AnomalySaveDialog(const QString& screenshotPath, int shotW, int shotH,
geopro::data::IDatasetCommandRepository* cmdRepo, const QString& projectId,
int remarkSourceType, QWidget* parent = nullptr);
QString anomalyName() const; QString anomalyName() const;
QString typeName() const; QString typeName() const;
QString typeId() const; QString typeId() const;
QString remark() const; QString remark() const;
// 选中类型的平台样式(从 legend 按形态派生与平台一致。styleColor 空 = 未取到,调用方用默认样式。
QString styleColor() const { return styleColor_; }
double styleWidth() const { return styleWidth_; }
bool styleDashed() const { return styleDashed_; }
private: private:
// 异步拉平台异常类型(label→显示, value→id)填充下拉;空/失败时下拉留空EmptyAwareComboBox 提示)。
void loadTypes(geopro::data::IDatasetCommandRepository* cmdRepo, const QString& projectId,
int remarkSourceType);
// 拉当前选中类型的详情 legend → 按形态(点/线/面)派生 styleColor/Width/Dashed。
void loadStyleForCurrent();
// 据当前样式按形态画预览(点=色球/线=线型/面=描边矩形),画到 stylePreview_。
void updateStylePreview();
geopro::data::IDatasetCommandRepository* cmdRepo_ = nullptr;
int remarkSourceType_ = 3;
QString styleColor_; // legend 派生线/点色(空=未取到)
double styleWidth_ = 0.0; // legend.polylineWidth
bool styleDashed_ = false; // legend.polylineShape 含 "dash"
QLineEdit* name_ = nullptr; QLineEdit* name_ = nullptr;
QComboBox* type_ = nullptr; QComboBox* type_ = nullptr;
QLabel* stylePreview_ = nullptr; // 选中类型样式预览(色块/线型)
QPlainTextEdit* remark_ = nullptr; QPlainTextEdit* remark_ = nullptr;
}; };

View File

@ -48,7 +48,7 @@ AxesSettingsPanel::AxesSettingsPanel(QWidget* parent) : QFrame(parent) {
setFrameShape(QFrame::StyledPanel); setFrameShape(QFrame::StyledPanel);
applyTokenizedStyleSheet( applyTokenizedStyleSheet(
this, QStringLiteral("QFrame{background:{{bg/panel}};border:1px solid {{border/default}};" this, QStringLiteral("QFrame{background:{{bg/panel}};border:1px solid {{border/default}};"
"border-radius:10px;}")); "border-radius:8px;}")); // radius/lg=8规范§3.2 画布浮窗)
setFixedWidth(320); setFixedWidth(320);
auto* v = new QVBoxLayout(this); auto* v = new QVBoxLayout(this);
@ -63,9 +63,12 @@ AxesSettingsPanel::AxesSettingsPanel(QWidget* parent) : QFrame(parent) {
close->setFixedSize(24, 24); close->setFixedSize(24, 24);
close->setCursor(Qt::PointingHandCursor); close->setCursor(Qt::PointingHandCursor);
// 显式覆盖全局 QPushButton 的 padding(6px 14px)/border——否则 24×24 容不下 padding× 被挤出不可见。 // 显式覆盖全局 QPushButton 的 padding(6px 14px)/border——否则 24×24 容不下 padding× 被挤出不可见。
close->setStyleSheet(QStringLiteral( // 颜色走 token避免深色模式失效
"QPushButton{border:none;background:transparent;padding:0;margin:0;font-size:16px;color:#888;}" applyTokenizedStyleSheet(
"QPushButton:hover{color:#2f6fed;}")); close, QStringLiteral(
"QPushButton{border:none;background:transparent;padding:0;margin:0;font-size:16px;"
"color:{{text/secondary}};}"
"QPushButton:hover{color:{{accent/primary}};}"));
connect(close, &QPushButton::clicked, this, &AxesSettingsPanel::closed); connect(close, &QPushButton::clicked, this, &AxesSettingsPanel::closed);
titleRow->addWidget(title); titleRow->addWidget(title);
titleRow->addStretch(1); titleRow->addStretch(1);
@ -100,7 +103,8 @@ AxesSettingsPanel::AxesSettingsPanel(QWidget* parent) : QFrame(parent) {
scaleSlider_->setSingleStep(1); scaleSlider_->setSingleStep(1);
scaleSlider_->setPageStep(1); // 点击轨道按 1 步移动(默认 pageStep=10 → 点一下直接跳到 10 的 bug scaleSlider_->setPageStep(1); // 点击轨道按 1 步移动(默认 pageStep=10 → 点一下直接跳到 10 的 bug
scaleLabel_ = new QLabel(QStringLiteral("1.0×"), this); scaleLabel_ = new QLabel(QStringLiteral("1.0×"), this);
scaleLabel_->setStyleSheet(QStringLiteral("border:none;color:#888;min-width:36px;")); applyTokenizedStyleSheet(scaleLabel_,
QStringLiteral("border:none;color:{{text/secondary}};min-width:36px;"));
connect(scaleSlider_, &QSlider::valueChanged, this, connect(scaleSlider_, &QSlider::valueChanged, this,
[this](int v) { scaleLabel_->setText(QStringLiteral("%1.0×").arg(v)); }); [this](int v) { scaleLabel_->setText(QStringLiteral("%1.0×").arg(v)); });
scaleRow->addWidget(scaleSlider_, 1); scaleRow->addWidget(scaleSlider_, 1);
@ -111,9 +115,7 @@ AxesSettingsPanel::AxesSettingsPanel(QWidget* parent) : QFrame(parent) {
auto* btns = new QHBoxLayout(); auto* btns = new QHBoxLayout();
auto* cancel = new QPushButton(QStringLiteral("取消"), this); auto* cancel = new QPushButton(QStringLiteral("取消"), this);
auto* apply = new QPushButton(QStringLiteral("应用"), this); auto* apply = new QPushButton(QStringLiteral("应用"), this);
apply->setStyleSheet(QStringLiteral( apply->setDefault(true); // 主按钮:走全局 QPushButton:default 样式(accent/primary,随主题),不再硬编码蓝
"QPushButton{background:#2f6fed;color:white;border:none;border-radius:6px;padding:6px 18px;}"
"QPushButton:hover{background:#2a63d4;}"));
connect(cancel, &QPushButton::clicked, this, &AxesSettingsPanel::closed); connect(cancel, &QPushButton::clicked, this, &AxesSettingsPanel::closed);
connect(apply, &QPushButton::clicked, this, [this] { connect(apply, &QPushButton::clicked, this, [this] {
auto rd = [](const Row& r) { auto rd = [](const Row& r) {
@ -147,7 +149,7 @@ AxesSettingsPanel::Row AxesSettingsPanel::addAxisRow(QVBoxLayout* col, const QSt
auto* range = new QHBoxLayout(); auto* range = new QHBoxLayout();
auto* loCol = new QVBoxLayout(); auto* loCol = new QVBoxLayout();
auto* loLbl = new QLabel(QStringLiteral("最小值"), this); auto* loLbl = new QLabel(QStringLiteral("最小值"), this);
loLbl->setStyleSheet(QStringLiteral("border:none;color:#888;")); applyTokenizedStyleSheet(loLbl, QStringLiteral("border:none;color:{{text/tertiary}};"));
loCol->addWidget(loLbl); loCol->addWidget(loLbl);
r.lo = new QDoubleSpinBox(this); r.lo = new QDoubleSpinBox(this);
r.lo->setRange(-1e6, 1e6); r.lo->setRange(-1e6, 1e6);
@ -156,7 +158,7 @@ AxesSettingsPanel::Row AxesSettingsPanel::addAxisRow(QVBoxLayout* col, const QSt
range->addLayout(loCol); range->addLayout(loCol);
auto* hiCol = new QVBoxLayout(); auto* hiCol = new QVBoxLayout();
auto* hiLbl = new QLabel(QStringLiteral("最大值"), this); auto* hiLbl = new QLabel(QStringLiteral("最大值"), this);
hiLbl->setStyleSheet(QStringLiteral("border:none;color:#888;")); applyTokenizedStyleSheet(hiLbl, QStringLiteral("border:none;color:{{text/tertiary}};"));
hiCol->addWidget(hiLbl); hiCol->addWidget(hiLbl);
r.hi = new QDoubleSpinBox(this); r.hi = new QDoubleSpinBox(this);
r.hi->setRange(-1e6, 1e6); r.hi->setRange(-1e6, 1e6);

View File

@ -41,6 +41,7 @@ add_executable(geopro_desktop WIN32
panels/DescriptionPanel.cpp panels/DescriptionPanel.cpp
panels/QuillDelta.cpp panels/QuillDelta.cpp
panels/chart/RawDataChartView.cpp panels/chart/RawDataChartView.cpp
panels/chart/ColorScaleProperties.cpp
panels/chart/InversionFormDialog.cpp panels/chart/InversionFormDialog.cpp
panels/chart/ScatterDataOps.cpp panels/chart/ScatterDataOps.cpp
panels/chart/SaveAsDialog.cpp panels/chart/SaveAsDialog.cpp
@ -64,6 +65,7 @@ add_executable(geopro_desktop WIN32
panels/chart/BarChartView.cpp panels/chart/BarChartView.cpp
panels/chart/LineChartView.cpp panels/chart/LineChartView.cpp
panels/chart/TrajectoryMapView.cpp panels/chart/TrajectoryMapView.cpp
panels/web/ProjectWebView.cpp
panels/chart/DetailViewFactory.cpp panels/chart/DetailViewFactory.cpp
resources/map/map.qrc resources/map/map.qrc
resources/keys.qrc resources/keys.qrc

View File

@ -205,11 +205,11 @@ ColorGradientDialog::ColorGradientDialog(const std::vector<Stop>& init, double m
// ── 整体透明度滑块0~1, step 0.01 ─────────────────────────────────── // ── 整体透明度滑块0~1, step 0.01 ───────────────────────────────────
{ {
auto* opRow = new QHBoxLayout(); auto* opRow = new QHBoxLayout();
opRow->addWidget(new QLabel(QStringLiteral("整体透明度:"))); opRow->addWidget(new QLabel(QStringLiteral("透明度:")));
opacitySlider_ = new QSlider(Qt::Horizontal, this); opacitySlider_ = new QSlider(Qt::Horizontal, this);
opacitySlider_->setRange(0, 100); opacitySlider_->setRange(0, 100);
opacitySlider_->setValue(static_cast<int>(opacity_ * 100 + 0.5)); opacitySlider_->setValue(static_cast<int>(opacity_ * 100 + 0.5));
opacityLabel_ = new QLabel(QString::number(opacity_, 'f', 2), this); opacityLabel_ = new QLabel(QString::number(opacity_ * 100, 'f', 0), this); // 0~100 显示
opRow->addWidget(opacitySlider_, 1); opRow->addWidget(opacitySlider_, 1);
opRow->addWidget(opacityLabel_); opRow->addWidget(opacityLabel_);
root->addLayout(opRow); root->addLayout(opRow);
@ -247,7 +247,7 @@ ColorGradientDialog::ColorGradientDialog(const std::vector<Stop>& init, double m
[this](double) { onMinMaxChanged(); }); [this](double) { onMinMaxChanged(); });
connect(opacitySlider_, &QSlider::valueChanged, this, [this](int v) { connect(opacitySlider_, &QSlider::valueChanged, this, [this](int v) {
opacity_ = v / 100.0; opacity_ = v / 100.0;
opacityLabel_->setText(QString::number(opacity_, 'f', 2)); opacityLabel_->setText(QString::number(v)); // 0~100 显示(内部仍存 0~1
}); });
// 配色方案:内置预设打底,再异步拉取后端 .clr 列表(若有 api // 配色方案:内置预设打底,再异步拉取后端 .clr 列表(若有 api

View File

@ -112,6 +112,7 @@ ColorScaleConfigDialog::ColorScaleConfigDialog(const geopro::core::ColorScale& i
resize(560, 420); resize(560, 420);
// 用初始色阶的升序断点填模型;空色阶兜底成 vmin/vmax 两端蓝红。 // 用初始色阶的升序断点填模型;空色阶兜底成 vmin/vmax 两端蓝红。
globalOpacity_ = init.globalOpacity(); // 回显真实整体透明度(两级第二级),不再硬编码 1
for (const auto& [value, color] : init.stops()) rows_.push_back({value, color}); for (const auto& [value, color] : init.stops()) rows_.push_back({value, color});
if (rows_.empty()) { if (rows_.empty()) {
rows_.push_back({vmin_, geopro::core::Rgba{0, 0, 255, 255}}); rows_.push_back({vmin_, geopro::core::Rgba{0, 0, 255, 255}});
@ -354,15 +355,15 @@ void ColorScaleConfigDialog::onColorScheme() {
std::vector<GradientEditWidget::Stop> seed; std::vector<GradientEditWidget::Stop> seed;
for (const auto& r : rows_) seed.push_back({(r.value - lo) / span, r.color}); for (const auto& r : rows_) seed.push_back({(r.value - lo) / span, r.color});
ColorGradientDialog dlg(seed, lo, hi, vmin_, vmax_, samples_, 1.0, tplRepo_, projectId_, this); ColorGradientDialog dlg(seed, lo, hi, vmin_, vmax_, samples_, globalOpacity_, tplRepo_, projectId_,
this);
if (dlg.exec() != QDialog::Accepted) return; if (dlg.exec() != QDialog::Accepted) return;
const auto grad = dlg.stops(); const auto grad = dlg.stops();
if (grad.size() < 2) return; if (grad.size() < 2) return;
const double opacity = dlg.opacity(); globalOpacity_ = dlg.opacity(); // 两级第二级:整体透明度单独存,不烘焙进每色 alpha
const unsigned char alpha = static_cast<unsigned char>(opacity * 255.0 + 0.5);
// 在新渐变上按各层级位置连续采样回填颜色(复刻 mapColors + addAlphaToColor 整体透明度)。 // 在新渐变上按各层级位置连续采样回填颜色(复刻 mapColors,含每色自有 alpha)。
auto sampleGrad = [&](double pos) -> geopro::core::Rgba { auto sampleGrad = [&](double pos) -> geopro::core::Rgba {
if (pos <= grad.front().pos) return grad.front().color; if (pos <= grad.front().pos) return grad.front().color;
if (pos >= grad.back().pos) return grad.back().color; if (pos >= grad.back().pos) return grad.back().color;
@ -372,11 +373,8 @@ void ColorScaleConfigDialog::onColorScheme() {
const double t = (x1 > x0) ? (pos - x0) / (x1 - x0) : 0.0; const double t = (x1 > x0) ? (pos - x0) / (x1 - x0) : 0.0;
return lerp(grad[i].color, grad[i + 1].color, t); return lerp(grad[i].color, grad[i + 1].color, t);
}; };
for (auto& r : rows_) { // 只回填颜色(含每色自有 alpha整体透明度单独存于 globalOpacity_、渲染时才相乘两级
geopro::core::Rgba c = sampleGrad((r.value - lo) / span); for (auto& r : rows_) r.color = sampleGrad((r.value - lo) / span);
if (opacity < 1.0) c.a = alpha; // 整体透明度覆盖 alpha
r.color = c;
}
rebuildTable(); rebuildTable();
} }
@ -559,6 +557,7 @@ void ColorScaleConfigDialog::onOpen() {
geopro::core::ColorScale ColorScaleConfigDialog::colorScale() const { geopro::core::ColorScale ColorScaleConfigDialog::colorScale() const {
geopro::core::ColorScale cs; geopro::core::ColorScale cs;
for (const auto& row : rows_) cs.addStop(row.value, row.color); // 内部按 value 升序 for (const auto& row : rows_) cs.addStop(row.value, row.color); // 内部按 value 升序
cs.setGlobalOpacity(globalOpacity_); // 整体透明度独立带出(渲染时与每色 alpha 相乘)
return cs; return cs;
} }

View File

@ -44,6 +44,11 @@ public:
// 线形/标注配置(线形⚙ 编辑后2D 消费3D 忽略)。 // 线形/标注配置(线形⚙ 编辑后2D 消费3D 忽略)。
ContourLineConfig lineConfig() const { return lineCfg_; } ContourLineConfig lineConfig() const { return lineCfg_; }
// 层级方案透传字段(复刻原版 properties保存色阶时一并写回避免覆盖清空 web 设过的值)。
QString lvlSchemeType() const { return lvlSchemeType_; }
int logLinesCount() const { return logLinesCount_; }
int equalAreaLayerCount() const { return equalAreaLayerCount_; }
private: private:
struct Row { struct Row {
double value; double value;
@ -70,6 +75,7 @@ private:
QTableWidget* table_ = nullptr; QTableWidget* table_ = nullptr;
std::vector<Row> rows_; // 始终按 value 升序维护 std::vector<Row> rows_; // 始终按 value 升序维护
double globalOpacity_ = 1.0; // 整体透明度(两级第二级,独立存储,不烘焙进 rows_ 的 alpha
double vmin_ = 0.0; double vmin_ = 0.0;
double vmax_ = 0.0; double vmax_ = 0.0;
std::vector<double> samples_; // 数据原始标量(等积分层 + 直方图) std::vector<double> samples_; // 数据原始标量(等积分层 + 直方图)

View File

@ -23,12 +23,14 @@ EmptyAwareComboBox::EmptyAwareComboBox(QWidget* parent) : QComboBox(parent) {
int EmptyAwareComboBox::realItemCount() const { int EmptyAwareComboBox::realItemCount() const {
int n = 0; int n = 0;
auto* m = model();
for (int i = 0; i < count(); ++i) { for (int i = 0; i < count(); ++i) {
// 排除临时「暂无数据」占位项。 // 排除临时「暂无数据」占位项。
if (itemData(i, kEmptyHintRole).toBool()) continue; if (itemData(i, kEmptyHintRole).toBool()) continue;
// 排除不可选项(禁用 / NoItemFlags它们不构成「真实可选数据」。 // 排除不可选项(禁用 / NoItemFlags。用 model()->flags() 正确取项标志——
if (!(itemData(i, Qt::UserRole - 1).value<Qt::ItemFlags>() & Qt::ItemIsSelectable)) // 原 itemData(i, UserRole-1) 不是 Qt 的 flags 角色,对正常项恒返回不可选 →
continue; // realItemCount 恒 0 → 有真实项也误插「暂无数据」(用户实测:异常区下方多一条暂无数据)。
if (m && !(m->flags(m->index(i, modelColumn())) & Qt::ItemIsSelectable)) continue;
++n; ++n;
} }
return n; return n;

View File

@ -55,9 +55,11 @@ QVBoxLayout* dialogRoot(QDialog* dlg);
// 与「数据详情 / 属性面板」同款的卡片面。返回 QFrame其 layout() 即 QVBoxLayout向内 addSection/addLayout。 // 与「数据详情 / 属性面板」同款的卡片面。返回 QFrame其 layout() 即 QVBoxLayout向内 addSection/addLayout。
QFrame* formCard(QWidget* parent); QFrame* formCard(QWidget* parent);
QVBoxLayout* cardBody(QFrame* card); // 取 formCard 的内层 QVBoxLayout便捷器 QVBoxLayout* cardBody(QFrame* card); // 取 formCard 的内层 QVBoxLayout便捷器
// 标准底部按钮栏QDialogButtonBox(Ok|Cancel),已接 accept/rejectokText/cancelText 可定制文案。 // 标准底部按钮栏QDialogButtonBox(Ok|Cancel),已接 accept/reject。
QDialogButtonBox* addDialogButtons(QVBoxLayout* root, QDialog* dlg, const QString& okText = QString(), // 默认中文「确定/取消」(不依赖 Qt 翻译是否就位);调用方可覆盖(如「生成/取消」)。
const QString& cancelText = QString()); QDialogButtonBox* addDialogButtons(QVBoxLayout* root, QDialog* dlg,
const QString& okText = QStringLiteral("确定"),
const QString& cancelText = QStringLiteral("取消"));
// ── 可编辑表单§7.0 统一度量DynamicFormEditor 与各参数对话框共用,单一真相)────── // ── 可编辑表单§7.0 统一度量DynamicFormEditor 与各参数对话框共用,单一真相)──────
QFormLayout* makeEditForm(); // 右对齐标签 + 标准行距/列距 QFormLayout* makeEditForm(); // 右对齐标签 + 标准行距/列距

View File

@ -1,13 +1,27 @@
#include "SliceExport.hpp" #include "SliceExport.hpp"
#include <algorithm>
#include <cmath>
#include <fstream> #include <fstream>
#include <QColor>
#include <QImage>
#include <QPainter>
#include <QPainterPath>
#include <QPainterPathStroker>
#include <QPointF>
#include <QPolygonF>
#include <QRect>
#include <vtkCamera.h>
#include <vtkDataArray.h> #include <vtkDataArray.h>
#include <vtkImageData.h> #include <vtkImageData.h>
#include <vtkNew.h> #include <vtkNew.h>
#include <vtkPNGWriter.h> #include <vtkPNGWriter.h>
#include <vtkPointData.h> #include <vtkPointData.h>
#include <vtkRenderWindow.h> #include <vtkRenderWindow.h>
#include <vtkRenderer.h>
#include <vtkRendererCollection.h>
#include <vtkWindowToImageFilter.h> #include <vtkWindowToImageFilter.h>
namespace geopro::app { namespace geopro::app {
@ -41,6 +55,178 @@ bool captureRenderWindowPng(vtkRenderWindow* win, const std::string& path, int&
return writer->GetErrorCode() == 0; return writer->GetErrorCode() == 0;
} }
bool captureFramedRegionPng(vtkRenderWindow* win, const double regionBounds[6], double padFactor,
double minExtent, const std::string& path, int& outW, int& outH) {
outW = outH = 0;
if (win == nullptr || path.empty()) return false;
vtkRenderer* ren =
win->GetRenderers() ? win->GetRenderers()->GetFirstRenderer() : nullptr;
vtkCamera* cam = ren ? ren->GetActiveCamera() : nullptr;
if (ren == nullptr || cam == nullptr)
return captureRenderWindowPng(win, path, outW, outH); // 无渲染器 → 退回整窗
// 1) 区域包围盒minExtent 兜底(点零体积/共面零厚度) → padFactor 以中心外扩留边距。
double b[6];
for (int i = 0; i < 3; ++i) {
const double lo = regionBounds[2 * i], hi = regionBounds[2 * i + 1];
const double c = 0.5 * (lo + hi);
double half = 0.5 * (hi - lo);
if (2.0 * half < minExtent) half = 0.5 * minExtent; // 退化轴兜底
half *= padFactor; // 外扩边距
b[2 * i] = c - half;
b[2 * i + 1] = c + half;
}
// 2) 存相机现场ResetCamera 改 position/focalPoint/clipping/parallelScale
double pos[3], fp[3], up[3], clip[2];
cam->GetPosition(pos);
cam->GetFocalPoint(fp);
cam->GetViewUp(up);
cam->GetClippingRange(clip);
const double va = cam->GetViewAngle();
const double ps = cam->GetParallelScale();
// 3) 重构图:保持视角方向,仅推近/缩放框住外扩区域。
ren->ResetCamera(b);
// 4) 截图(后台缓冲 + 关交换 → 屏幕不闪)。
vtkNew<vtkWindowToImageFilter> w2i;
w2i->SetInput(win);
w2i->ReadFrontBufferOff();
w2i->Update();
if (auto* img = w2i->GetOutput()) {
int dims[3];
img->GetDimensions(dims);
outW = dims[0];
outH = dims[1];
}
vtkNew<vtkPNGWriter> writer;
writer->SetFileName(path.c_str());
writer->SetInputConnection(w2i->GetOutputPort());
writer->Write();
const bool ok = writer->GetErrorCode() == 0;
// 5) 还原相机 + 重绘回原视图。
cam->SetPosition(pos);
cam->SetFocalPoint(fp);
cam->SetViewUp(up);
cam->SetViewAngle(va);
cam->SetParallelScale(ps);
cam->SetClippingRange(clip);
win->Render();
return ok;
}
bool captureAnomalyShotFromSlice(vtkImageData* colorImg, const double o[3], const double p1[3],
const double p2[3],
const std::vector<std::array<double, 3>>& worldPts, int markType,
const std::string& outlineHex, const std::string& path, int& outW,
int& outH) {
outW = outH = 0;
if (colorImg == nullptr || worldPts.empty() || path.empty()) return false;
int dims[3];
colorImg->GetDimensions(dims);
const int nx = dims[0], ny = dims[1];
if (nx < 2 || ny < 2) return false;
// 平面两轴(image i↔e1=p1-o, j↔e2=p2-o);世界点 → 归一(u,v) → 像素(QImage 顶左原点,需翻 j)。
const double e1[3] = {p1[0] - o[0], p1[1] - o[1], p1[2] - o[2]};
const double e2[3] = {p2[0] - o[0], p2[1] - o[1], p2[2] - o[2]};
const double L1 = e1[0] * e1[0] + e1[1] * e1[1] + e1[2] * e1[2];
const double L2 = e2[0] * e2[0] + e2[1] * e2[1] + e2[2] * e2[2];
if (L1 < 1e-12 || L2 < 1e-12) return false;
QPolygonF poly;
poly.reserve(static_cast<int>(worldPts.size()));
for (const auto& P : worldPts) {
const double d[3] = {P[0] - o[0], P[1] - o[1], P[2] - o[2]};
const double u = (d[0] * e1[0] + d[1] * e1[1] + d[2] * e1[2]) / L1;
const double v = (d[0] * e2[0] + d[1] * e2[1] + d[2] * e2[2]) / L2;
poly << QPointF(u * (nx - 1), (ny - 1) - v * (ny - 1)); // 翻 j 到 QImage 坐标
}
// 缓冲半径:异常包围盒对角的 15%,最小取图长边 4%(点/小异常也有可见外扩)。
const QRectF pb = poly.boundingRect();
const double diag = std::hypot(pb.width(), pb.height());
const double buffer = std::max(0.04 * std::max(nx, ny), 0.15 * diag);
// 按形态构 buffer 后的裁剪形状:点→圆、线→胶囊带、面→外扩多边形(填充 描边)。
QPainterPath shape;
if (markType == 1 || poly.size() == 1) {
shape.addEllipse(poly.first(), buffer, buffer);
} else if (markType == 2) {
QPainterPath line;
line.moveTo(poly.first());
for (int i = 1; i < poly.size(); ++i) line.lineTo(poly[i]);
QPainterPathStroker st;
st.setWidth(2.0 * buffer);
st.setCapStyle(Qt::RoundCap);
st.setJoinStyle(Qt::RoundJoin);
shape = st.createStroke(line);
} else {
QPainterPath fill;
fill.addPolygon(poly);
fill.closeSubpath();
QPainterPath outline = fill;
QPainterPathStroker st;
st.setWidth(2.0 * buffer);
st.setJoinStyle(Qt::RoundJoin);
shape = fill.united(st.createStroke(outline)); // 向外扩 buffer
}
// 裁剪区 = 形状包围盒(夹到图内)。
const QRect crop = shape.boundingRect().toAlignedRect().intersected(QRect(0, 0, nx, ny));
if (crop.width() < 1 || crop.height() < 1) return false;
// 切片着色图(vtk, j=0 在底) → QImage(顶左原点,翻行)。RGBA 保留外区透明(消除血缘外蓝边)。
const int comps = colorImg->GetNumberOfScalarComponents();
const bool rgba = comps >= 4;
QImage src(nx, ny, rgba ? QImage::Format_RGBA8888 : QImage::Format_RGB888);
for (int j = 0; j < ny; ++j) {
uchar* row = src.scanLine(ny - 1 - j);
for (int i = 0; i < nx; ++i) {
const auto* px = static_cast<unsigned char*>(colorImg->GetScalarPointer(i, j, 0));
if (rgba) {
row[i * 4] = px[0];
row[i * 4 + 1] = px[1];
row[i * 4 + 2] = px[2];
row[i * 4 + 3] = px[3];
} else {
row[i * 3] = px[0];
row[i * 3 + 1] = px[1];
row[i * 3 + 2] = px[2];
}
}
}
// 输出buffer 形状内贴剖面像素(外透明),再描异常轮廓。
QImage out(crop.size(), QImage::Format_ARGB32);
out.fill(Qt::transparent);
QPainter pr(&out);
pr.setRenderHint(QPainter::Antialiasing, true);
pr.translate(-crop.topLeft());
pr.setClipPath(shape);
pr.drawImage(0, 0, src);
pr.setClipping(false);
QColor oc(QString::fromStdString(outlineHex));
if (!oc.isValid()) oc = QColor(255, 48, 48);
QPen pen(oc);
pen.setWidthF(2.0);
pr.setPen(pen);
pr.setBrush(Qt::NoBrush);
if (markType == 1 || poly.size() == 1)
pr.drawEllipse(poly.first(), 4.0, 4.0); // 点:小标记
else if (markType == 2)
pr.drawPolyline(poly);
else
pr.drawPolygon(poly);
pr.end();
if (!out.save(QString::fromStdString(path), "PNG")) return false;
outW = out.width();
outH = out.height();
return true;
}
bool exportSliceDat(vtkImageData* slice, const std::string& path) { bool exportSliceDat(vtkImageData* slice, const std::string& path) {
if (slice == nullptr || path.empty()) return false; if (slice == nullptr || path.empty()) return false;
vtkDataArray* arr = slice->GetPointData() ? slice->GetPointData()->GetScalars() : nullptr; vtkDataArray* arr = slice->GetPointData() ? slice->GetPointData()->GetScalars() : nullptr;

View File

@ -1,5 +1,7 @@
#pragma once #pragma once
#include <array>
#include <string> #include <string>
#include <vector>
class vtkImageData; class vtkImageData;
class vtkRenderWindow; class vtkRenderWindow;
@ -12,6 +14,26 @@ bool exportSliceImagePng(vtkImageData* colorImage, const std::string& path);
// 截整个渲染窗口为 PNG异常标识截图需求 R88成功返回 true并填回截图像素宽高。 // 截整个渲染窗口为 PNG异常标识截图需求 R88成功返回 true并填回截图像素宽高。
bool captureRenderWindowPng(vtkRenderWindow* win, const std::string& path, int& outW, int& outH); bool captureRenderWindowPng(vtkRenderWindow* win, const std::string& path, int& outW, int& outH);
// 「相机重构图」截图方案A把相机临时重新取景到 regionBounds圈定范围外扩后的区域
// 使异常框在画面中央带周边语境,再截图、还原相机。业界 frame/zoom-to-fit selection 范式。
// regionBounds: {xmin,xmax,ymin,ymax,zmin,zmax} 世界系圈定包围盒;
// padFactor: 以盒中心外扩的倍数1.4≈异常占画面~70%
// minExtent: 退化兜底(点=零体积、线/面共面=某轴零厚度)时各轴的最小世界尺寸。
// 视角方向不变(仅推近/缩放);屏幕无闪(后台缓冲+关交换)。失败回退整窗截图。
bool captureFramedRegionPng(vtkRenderWindow* win, const double regionBounds[6], double padFactor,
double minExtent, const std::string& path, int& outW, int& outH);
// 异常截图(正确做法):**只从切片那张 2D 剖面彩图**上,按异常几何**向外缓冲(buffer)一圈后裁剪**输出。
// 业界范式 = GIS「几何缓冲 + 按掩膜裁剪栅格」:点→圆、线→胶囊带、面→外扩多边形;缓冲外透明。
// colorImgselectedSliceColorImage() 的剖面 RGB 图o/p1/p2该切片平面三点(image i↔p1-o, j↔p2-o)
// worldPts异常顶点(世界系,落在该平面)markType1点/2线/3面outlineHex在裁图上描异常轮廓的颜色。
// 成功返回 true填回输出像素宽高。失败(无图/几何退化)返回 false调用方可回退。
bool captureAnomalyShotFromSlice(vtkImageData* colorImg, const double o[3], const double p1[3],
const double p2[3],
const std::vector<std::array<double, 3>>& worldPts, int markType,
const std::string& outlineHex, const std::string& path, int& outW,
int& outH);
// 把切片重采样 2D 标量影像写为 .dat 文本网格(行=j、列=i空格分隔每格取标量首分量成功返回 true。 // 把切片重采样 2D 标量影像写为 .dat 文本网格(行=j、列=i空格分隔每格取标量首分量成功返回 true。
bool exportSliceDat(vtkImageData* slice, const std::string& path); bool exportSliceDat(vtkImageData* slice, const std::string& path);

View File

@ -232,9 +232,11 @@ void TileBasemap::refineTile(int z, int x, int y, std::set<long long>& out, int&
const double cx = (sw.x + ne.x) * 0.5, cy = (sw.y + ne.y) * 0.5; // 瓦片中心(局部米) 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)); // 瓦片地面尺寸(米) const double g = std::max(std::abs(ne.x - sw.x), std::abs(ne.y - sw.y)); // 瓦片地面尺寸(米)
// 距离上限(按剖面范围动态):数据中心在局部原点(0,0);瓦片离它太远则不加载——远裁剪面有界 // 距离上限(按剖面范围动态):以覆盖中心(相机焦点 cenX_,cenY_)为心,瓦片离它太远则不加载——
// (剖面不被近裁剪面切),也避免拉远无限铺。叶块本身可大于此距离(其近端仍在范围内即保留)。 // 远裁剪面有界(剖面不被近裁剪面切),也避免拉远无限铺。叶块本身可大于此距离(近端仍在范围内即保留)。
if (std::sqrt(cx * cx + cy * cy) - g * 0.5 > maxTileDist_) return; // 心改用焦点而非原点(0,0):否则 frame 锚在别处数据(如深圳)时,看台湾数据全被剔除→底图空。
const double rx = cx - cenX_, ry = cy - cenY_;
if (std::sqrt(rx * rx + ry * ry) - g * 0.5 > maxTileDist_) return;
// 该瓦片投影到屏幕的近似像素尺寸 > 阈值且未到最大层级 → 细分为 4 子块(近处更细)。 // 该瓦片投影到屏幕的近似像素尺寸 > 阈值且未到最大层级 → 细分为 4 子块(近处更细)。
double screenPx; double screenPx;
@ -286,6 +288,10 @@ void TileBasemap::refresh() {
if (pl[0] * fp[0] + pl[1] * fp[1] + pl[2] * fp[2] + pl[3] < 0.0) 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]; for (int k = 0; k < 4; ++k) pl[k] = -pl[k];
} }
// 底图覆盖中心 = 相机焦点(用户正看处)的局部 XY而非世界原点frame 锚在首个数据集,看远处别处
// 数据时原点离视野很远会把全部瓦片距离剔除→底图空。焦点为心则底图随视野走(同 frame 仍与数据对齐)。
cenX_ = fp[0];
cenY_ = fp[1];
// 底图最大距离按当前剖面合并范围动态定(随勾选增删自动伸缩);无数据用下限。 // 底图最大距离按当前剖面合并范围动态定(随勾选增删自动伸缩);无数据用下限。
maxTileDist_ = kRangeFloor; maxTileDist_ = kRangeFloor;
@ -294,10 +300,10 @@ void TileBasemap::refresh() {
if (r > 0.0) maxTileDist_ = std::clamp(r * kRangeFactor, kRangeFloor, kRangeCeil); if (r > 0.0) maxTileDist_ = std::clamp(r * kRangeFactor, kRangeFloor, kRangeCeil);
} }
// 四叉树:从数据中心一圈粗根块出发,按屏幕误差细分 → 近细远粗、铺满视野,无单层级盲区。 // 四叉树:从覆盖中心(相机焦点经纬)一圈粗根块出发,按屏幕误差细分 → 近细远粗、铺满视野,无盲区。
desired_.clear(); desired_.clear();
int count = 0; int count = 0;
const auto c = frame_->toLatLon(0.0, 0.0); // 数据中心 const auto c = frame_->toLatLon(cenX_, cenY_); // 覆盖中心 = 相机焦点(非世界原点)
const geopro::render::TileXY root = geopro::render::lonLatToTile(c.lon, c.lat, kRootZoom); const geopro::render::TileXY root = geopro::render::lonLatToTile(c.lon, c.lat, kRootZoom);
for (int dy = -1; dy <= 1; ++dy) for (int dy = -1; dy <= 1; ++dy)
for (int dx = -1; dx <= 1; ++dx) for (int dx = -1; dx <= 1; ++dx)

View File

@ -84,6 +84,10 @@ private:
int satMaxZoom_ = 18; int satMaxZoom_ = 18;
// 四叉树当前帧相机参数(refresh 写, refineTile 读):相机位置 + 投影系数 + 视锥 6 面。 // 四叉树当前帧相机参数(refresh 写, refineTile 读):相机位置 + 投影系数 + 视锥 6 面。
double camX_ = 0, camY_ = 0, camZ_ = 0; double camX_ = 0, camY_ = 0, camZ_ = 0;
// 底图覆盖中心(相机焦点的局部 XY):四叉树根块取此处经纬、距离剔除以此为心。
// 关键——不能用世界原点(0,0)frame 锚在首个数据集(如深圳),看远处别处数据(如台湾,相距数百公里)时
// 原点离视野数百公里→全部瓦片被距离剔除→底图空。改用焦点→底图随视野走(瓦片与数据同 frame 仍对齐)。
double cenX_ = 0, cenY_ = 0;
double projK_ = 1.0; double projK_ = 1.0;
bool projParallel_ = false; bool projParallel_ = false;
double frustum_[24] = {0}; // 6 个视锥平面(内法向)AABB 全在某面外则剔除 double frustum_[24] = {0}; // 6 个视锥平面(内法向)AABB 全在某面外则剔除

View File

@ -119,48 +119,6 @@ QPixmap renderAvatar(const QString& initials, int px, const QColor& bg, const QC
} }
// ── 四个菜单(结构对齐需求;叶子项当前为静态占位,后续接真实页面)── // ── 四个菜单(结构对齐需求;叶子项当前为静态占位,后续接真实页面)──
QMenu* buildViewMenu(QWidget* p)
{
auto* m = new QMenu(QStringLiteral("视图"), p);
m->addAction(QStringLiteral("分析视图"));
m->addAction(QStringLiteral("大屏视图"));
return m;
}
QMenu* buildProjectMenu(QWidget* p)
{
auto* m = new QMenu(QStringLiteral("项目管理"), p);
m->addAction(QStringLiteral("数据视图"));
auto* cfg = m->addMenu(QStringLiteral("项目配置"));
cfg->addAction(QStringLiteral("基本信息"));
cfg->addAction(QStringLiteral("项目结构"));
cfg->addAction(QStringLiteral("视图配置"));
m->addAction(QStringLiteral("数据管理"));
auto* biz = m->addMenu(QStringLiteral("业务管理"));
biz->addAction(QStringLiteral("异常管理"));
biz->addAction(QStringLiteral("异常体管理"));
auto* mon = m->addMenu(QStringLiteral("在线监测"));
mon->addAction(QStringLiteral("项目设备"));
mon->addAction(QStringLiteral("在线任务管理"));
auto* doc = m->addMenu(QStringLiteral("项目资料管理"));
doc->addAction(QStringLiteral("项目资料管理"));
doc->addAction(QStringLiteral("报告列表"));
auto* tools = m->addMenu(QStringLiteral("工具组件"));
tools->addAction(QStringLiteral("装置与脚本"));
tools->addAction(QStringLiteral("色阶配置"));
tools->addAction(QStringLiteral("异常类型管理"));
tools->addAction(QStringLiteral("模型管理"));
auto* exp = m->addMenu(QStringLiteral("批量导出"));
exp->addAction(QStringLiteral("文件导出"));
exp->addAction(QStringLiteral("报告导出"));
auto* alarm = m->addMenu(QStringLiteral("告警管理"));
alarm->addAction(QStringLiteral("设备告警"));
alarm->addAction(QStringLiteral("告警查询"));
m->addAction(QStringLiteral("自动任务"));
m->addAction(QStringLiteral("模板管理"));
return m;
}
QMenu* buildToolsMenu(QWidget* p) QMenu* buildToolsMenu(QWidget* p)
{ {
auto* m = new QMenu(QStringLiteral("业务工具"), p); auto* m = new QMenu(QStringLiteral("业务工具"), p);
@ -259,8 +217,8 @@ TopBar::TopBar(QWidget* parent) : QWidget(parent) {
// 一级菜单 → 工具条按钮(视图/项目管理/业务工具/设备),纯文字 + 下拉箭头。 // 一级菜单 → 工具条按钮(视图/项目管理/业务工具/设备),纯文字 + 下拉箭头。
// 复用原菜单构造器;菜单作为 popup 挂到按钮(按钮文字取菜单标题)。 // 复用原菜单构造器;菜单作为 popup 挂到按钮(按钮文字取菜单标题)。
lay->addWidget(makeMenuButton(this, buildViewMenu(this))); lay->addWidget(makeMenuButton(this, buildViewMenu()));
lay->addWidget(makeMenuButton(this, buildProjectMenu(this))); lay->addWidget(makeMenuButton(this, buildProjectMenu()));
lay->addWidget(makeMenuButton(this, buildToolsMenu(this))); lay->addWidget(makeMenuButton(this, buildToolsMenu(this)));
lay->addWidget(makeMenuButton(this, buildDeviceMenu(this))); lay->addWidget(makeMenuButton(this, buildDeviceMenu(this)));
@ -332,6 +290,49 @@ TopBar::TopBar(QWidget* parent) : QWidget(parent) {
lay->addWidget(userRow_); lay->addWidget(userRow_);
} }
// 视图菜单。「分析视图」=默认工作台emit analysisViewRequested中央区从 web 整窗切回工作台);
// 「大屏视图」当前为占位。
QMenu* TopBar::buildViewMenu() {
auto* m = new QMenu(QStringLiteral("视图"), this);
QObject::connect(m->addAction(QStringLiteral("分析视图")), &QAction::triggered, this,
[this] { emit analysisViewRequested(); });
m->addAction(QStringLiteral("大屏视图"));
return m;
}
// 项目管理菜单。仅保留需「直接嵌入」的 web 页Excel「单个项目」页签第 10~21 行带嵌入地址者):
// 在线监测 / 工具组件 / 批量导出 / 告警管理,点击 emit webPageRequested由 main 独立整窗加载。
// 其余全部隐藏(数据视图、项目配置、数据管理、业务管理、项目资料管理、自动任务、模板管理 …)。
QMenu* TopBar::buildProjectMenu() {
auto* m = new QMenu(QStringLiteral("项目管理"), this);
// web 叶子项:携带 target 路径,点击发信号。
auto addWeb = [this](QMenu* parent, const QString& title, const QString& target) {
auto* a = parent->addAction(title);
QObject::connect(a, &QAction::triggered, this,
[this, title, target] { emit webPageRequested(title, target); });
};
auto* mon = m->addMenu(QStringLiteral("在线监测"));
addWeb(mon, QStringLiteral("项目设备"), QStringLiteral("/projectSpace/onlineMonitor/projectDevice"));
addWeb(mon, QStringLiteral("在线任务管理"), QStringLiteral("/projectSpace/onlineMonitor/onlineTask"));
auto* tools = m->addMenu(QStringLiteral("工具组件"));
addWeb(tools, QStringLiteral("装置与脚本"), QStringLiteral("/projectSpace/toolComponent/deviceScript"));
addWeb(tools, QStringLiteral("色阶配置"), QStringLiteral("/projectSpace/toolComponent/levelConfigure"));
addWeb(tools, QStringLiteral("异常类型管理"), QStringLiteral("/projectSpace/toolComponent/exceptionType"));
addWeb(tools, QStringLiteral("模型管理"), QStringLiteral("/projectSpace/toolComponent/modelManage"));
auto* exp = m->addMenu(QStringLiteral("批量导出"));
addWeb(exp, QStringLiteral("文件导出"), QStringLiteral("/projectSpace/bulkExport/fileExport"));
addWeb(exp, QStringLiteral("报告导出"), QStringLiteral("/projectSpace/bulkExport/templateExport"));
auto* alarm = m->addMenu(QStringLiteral("告警管理"));
addWeb(alarm, QStringLiteral("设备告警"), QStringLiteral("/projectSpace/alarmManage/deviceAlarm"));
addWeb(alarm, QStringLiteral("告警查询"), QStringLiteral("/projectSpace/alarmManage/alarmQuery"));
return m;
}
bool TopBar::eventFilter(QObject* obj, QEvent* event) { bool TopBar::eventFilter(QObject* obj, QEvent* event) {
if (obj == userRow_ && event->type() == QEvent::MouseButtonRelease) { if (obj == userRow_ && event->type() == QEvent::MouseButtonRelease) {
if (userMenu_) if (userMenu_)

View File

@ -29,8 +29,14 @@ signals:
void allProjectsRequested(); // 点击"全部项目…" void allProjectsRequested(); // 点击"全部项目…"
void logoutRequested(); // 头像菜单「退出登录」 void logoutRequested(); // 头像菜单「退出登录」
void settingsRequested(); // 点击齿轮图标 → 打开设置 void settingsRequested(); // 点击齿轮图标 → 打开设置
// 项目管理菜单中「直接嵌入」的 web 页被点击title=窗口标题target=嵌入页 target 路径。
void webPageRequested(const QString& title, const QString& target);
void analysisViewRequested(); // 视图菜单「分析视图」→ 中央区切回默认工作台
private: private:
QMenu* buildViewMenu(); // 视图菜单(成员:「分析视图」需 emit 信号)
QMenu* buildProjectMenu(); // 项目管理菜单成员webview 叶子项需 emit 信号)
QToolButton* wsBtn_ = nullptr; QToolButton* wsBtn_ = nullptr;
QToolButton* projBtn_ = nullptr; QToolButton* projBtn_ = nullptr;
QWidget* userRow_ = nullptr; // 用户区整块(头像+姓名/职务+箭头) QWidget* userRow_ = nullptr; // 用户区整块(头像+姓名/职务+箭头)

View File

@ -31,7 +31,7 @@ namespace {
constexpr double kDefCellXY = 1.0; constexpr double kDefCellXY = 1.0;
constexpr double kDefCellZ = 0.5; constexpr double kDefCellZ = 0.5;
constexpr double kDefPower = 2.0; constexpr double kDefPower = 2.0;
constexpr double kDefMaxDist = 4.0; constexpr double kDefMaxDist = 0.0; // 0=自动「覆盖测区」(全数据 IDW + 凸包足迹裁剪,对齐 Surfer)
constexpr int kRoleDsId = Qt::UserRole + 1; // 源树项存 dsId constexpr int kRoleDsId = Qt::UserRole + 1; // 源树项存 dsId
constexpr int kRoleMountId = Qt::UserRole + 1; // 生成位置树项存 id constexpr int kRoleMountId = Qt::UserRole + 1; // 生成位置树项存 id
constexpr int kRoleMountConfType = Qt::UserRole + 2; // 生成位置树项存 confType constexpr int kRoleMountConfType = Qt::UserRole + 2; // 生成位置树项存 confType
@ -187,11 +187,14 @@ VolumeParamsDialog::VolumeParamsDialog(const QVector<VolumeSourceItem>& sources,
cellXY_ = makeSpin(kDefCellXY, 0.01, 1000.0, 0.5, 2); cellXY_ = makeSpin(kDefCellXY, 0.01, 1000.0, 0.5, 2);
cellZ_ = makeSpin(kDefCellZ, 0.01, 1000.0, 0.5, 2); cellZ_ = makeSpin(kDefCellZ, 0.01, 1000.0, 0.5, 2);
power_ = makeSpin(kDefPower, 0.5, 6.0, 0.5, 1); power_ = makeSpin(kDefPower, 0.5, 6.0, 0.5, 1);
maxDist_ = makeSpin(kDefMaxDist, 0.1, 10000.0, 1.0, 2); maxDist_ = makeSpin(kDefMaxDist, 0.0, 10000.0, 1.0, 2);
// maxDist=0=最小值)→ 显示「自动」:全数据 IDW + 凸包足迹裁剪填满测区(对齐客户 Surfer
// >0 → 局部 IDW 半径(剖面附近清晰、跨大空隙可能填不满)。
maxDist_->setSpecialValueText(QStringLiteral("自动 (覆盖测区)"));
form2->addRow(formkit::editLabel(QStringLiteral("水平间距 (米)")), cellXY_); form2->addRow(formkit::editLabel(QStringLiteral("水平间距 (米)")), cellXY_);
form2->addRow(formkit::editLabel(QStringLiteral("竖向间距 (米)")), cellZ_); form2->addRow(formkit::editLabel(QStringLiteral("竖向间距 (米)")), cellZ_);
form2->addRow(formkit::editLabel(QStringLiteral("IDW 幂次")), power_); form2->addRow(formkit::editLabel(QStringLiteral("IDW 幂次")), power_);
form2->addRow(formkit::editLabel(QStringLiteral("最大影响距离 (米)")), maxDist_); form2->addRow(formkit::editLabel(QStringLiteral("最大影响距离 (米, 0=自动)")), maxDist_);
cardLay->addLayout(form2); cardLay->addLayout(form2);
cols->addWidget(card, 1); cols->addWidget(card, 1);

View File

@ -40,7 +40,9 @@ VolumePropertiesDialog::VolumePropertiesDialog(const QString& name, const Volume
.row(QStringLiteral("网格间距"), QStringLiteral("XY=%1 m Z=%2 m") .row(QStringLiteral("网格间距"), QStringLiteral("XY=%1 m Z=%2 m")
.arg(info.params.cellXY, 0, 'f', 2) .arg(info.params.cellXY, 0, 'f', 2)
.arg(info.params.cellZ, 0, 'f', 2)) .arg(info.params.cellZ, 0, 'f', 2))
.row(QStringLiteral("超距"), QStringLiteral("%1 m").arg(info.params.maxDist, 0, 'f', 2)) .row(QStringLiteral("超距"), info.params.maxDist > 0.0
? QStringLiteral("%1 m").arg(info.params.maxDist, 0, 'f', 2)
: QStringLiteral("自动 (覆盖测区)"))
.row(QStringLiteral("色阶来源"), .row(QStringLiteral("色阶来源"),
info.params.colorScaleId.empty() ? QStringLiteral("首个源数据集") info.params.colorScaleId.empty() ? QStringLiteral("首个源数据集")
: QString::fromStdString(info.params.colorScaleId)); : QString::fromStdString(info.params.colorScaleId));

View File

@ -11,11 +11,17 @@
#include <vtkActor.h> #include <vtkActor.h>
#include <vtkProperty.h> #include <vtkProperty.h>
#include <vtkBoundingBox.h> #include <vtkBoundingBox.h>
#include <vtkCellPicker.h>
#include <vtkCubeAxesActor.h> #include <vtkCubeAxesActor.h>
#include <vtkNew.h>
#include <vtkProp.h> #include <vtkProp.h>
#include <vtkPiecewiseFunction.h>
#include <vtkColorTransferFunction.h>
#include <vtkGPUVolumeRayCastMapper.h>
#include <vtkRenderWindow.h> #include <vtkRenderWindow.h>
#include <vtkRenderer.h> #include <vtkRenderer.h>
#include <vtkVolume.h> #include <vtkVolume.h>
#include <vtkVolumeProperty.h>
#include "CameraPreset.hpp" #include "CameraPreset.hpp"
#include "Scene.hpp" #include "Scene.hpp"
@ -80,12 +86,14 @@ void VtkSceneView::removeProps(std::vector<vtkSmartPointer<vtkProp>>& props) {
} }
bool VtkSceneView::computeDataBounds(double out[6]) const { bool VtkSceneView::computeDataBounds(double out[6]) const {
// 仅计「可见」prop二维分析下 3D 体/帘面已隐藏,取景/坐标轴/底图范围都应只围当前可见维度,
// 否则二维取景被隐藏的远处 3D 体撑歪、坐标轴框错维度。
vtkBoundingBox bb; vtkBoundingBox bb;
for (const auto& kv : dsProps_) for (const auto& kv : dsProps_)
for (const auto& p : kv.second) for (const auto& p : kv.second)
if (p) { if (double* b = p->GetBounds()) bb.AddBounds(b); } if (p && p->GetVisibility()) { if (double* b = p->GetBounds()) bb.AddBounds(b); }
for (const auto& p : miscProps_) for (const auto& p : miscProps_)
if (p) { if (double* b = p->GetBounds()) bb.AddBounds(b); } if (p && p->GetVisibility()) { if (double* b = p->GetBounds()) bb.AddBounds(b); }
if (!bb.IsValid()) return false; if (!bb.IsValid()) return false;
bb.GetBounds(out); bb.GetBounds(out);
return true; return true;
@ -102,6 +110,8 @@ void VtkSceneView::clear() {
// 只移除数据 prop按 ds 跟踪)+ 杂项(地形/测线)+ 坐标轴;不动底图(TileBasemap 自管)→ 重建不丢图。 // 只移除数据 prop按 ds 跟踪)+ 杂项(地形/测线)+ 坐标轴;不动底图(TileBasemap 自管)→ 重建不丢图。
for (auto& kv : dsProps_) removeProps(kv.second); for (auto& kv : dsProps_) removeProps(kv.second);
dsProps_.clear(); dsProps_.clear();
mapLineDs_.clear(); // 2D 足迹维度记录随数据图元一并清(模式标志保留)
selectedMapLines_.clear(); // 选中态随图元清(actor 已销毁)Z 偏移 mapLineZOffset_ 保留→重建后复位高度
removeProps(miscProps_); removeProps(miscProps_);
clearAnomalies(); // 异常 actor 随清场一并移除 clearAnomalies(); // 异常 actor 随清场一并移除
if (currentAxes_) { if (currentAxes_) {
@ -118,6 +128,11 @@ void VtkSceneView::clear() {
void VtkSceneView::setVerticalExaggeration(double ve) { verticalExaggeration_ = ve; } void VtkSceneView::setVerticalExaggeration(double ve) { verticalExaggeration_ = ve; }
void VtkSceneView::setVolumeOpacity(double /*maxOpacity*/) {
// 已退役:体不透明度统一由【色阶「不透明度」】控制(每单位 = 单色alpha × 色阶不透明度100%=实心)。
// 旧工具条「透明度」滑块移除;保留空实现仅为满足接口(无调用方)。
}
void VtkSceneView::addSurveyLine(const geopro::core::Grid& grid) { void VtkSceneView::addSurveyLine(const geopro::core::Grid& grid) {
auto line = geopro::render::buildSurveyLine(grid, *frame_); auto line = geopro::render::buildSurveyLine(grid, *frame_);
if (line) { if (line) {
@ -149,6 +164,7 @@ void VtkSceneView::addCurtain(const std::string& dsId, const geopro::core::Grid&
auto curtain = geopro::render::buildCurtain(grid, cs, *frame_); auto curtain = geopro::render::buildCurtain(grid, cs, *frame_);
if (curtain) { if (curtain) {
curtain->SetScale(1.0, 1.0, verticalExaggeration_); // 纵向夸张成墙 curtain->SetScale(1.0, 1.0, verticalExaggeration_); // 纵向夸张成墙
curtain->SetVisibility(analysisMode2D_ ? 0 : 1); // 帘面=3D内容二维分析下隐藏
scene_.addActor(curtain); scene_.addActor(curtain);
dsProps_[dsId].push_back(curtain); dsProps_[dsId].push_back(curtain);
} }
@ -156,6 +172,29 @@ void VtkSceneView::addCurtain(const std::string& dsId, const geopro::core::Grid&
void VtkSceneView::addVolume(const std::string& dsId, const geopro::data::VolumeGrid& vol, void VtkSceneView::addVolume(const std::string& dsId, const geopro::data::VolumeGrid& vol,
const geopro::core::ColorScale& cs) { const geopro::core::ColorScale& cs) {
// 首次建体时一次性探测 GPU 体绘制支持(此刻 widget 已显示、GL 上下文就绪):不支持则全局回退
// SmartVolumeMapper(CPU),避免无独显/软件 GL/远程桌面上整个体渲不出(空值仍靠传函透明)。
static bool gpuProbed = false;
if (!gpuProbed && renderWindow_) {
gpuProbed = true;
// 关键addVolume 在普通 Qt 槽里跑GL 上下文未必 current → 先 MakeCurrent否则 IsRenderSupported
// 误判为不支持、把有独显的机器错误回退到 CPU体变稠密/分层)。再给真实传函属性供其判定。
renderWindow_->MakeCurrent();
vtkNew<vtkGPUVolumeRayCastMapper> probe;
vtkNew<vtkVolumeProperty> prop;
vtkNew<vtkColorTransferFunction> ctf;
ctf->AddRGBPoint(0.0, 1, 1, 1);
ctf->AddRGBPoint(1.0, 1, 1, 1);
vtkNew<vtkPiecewiseFunction> otf;
otf->AddPoint(0.0, 0.0);
otf->AddPoint(1.0, 1.0);
prop->SetColor(ctf);
prop->SetScalarOpacity(otf);
const bool ok = probe->IsRenderSupported(renderWindow_, prop) != 0;
geopro::render::setVolumeGpuSupported(ok);
qInfo().noquote() << "[volrender] GPU volume ray cast supported=" << ok
<< (ok ? "(GPU+mask 干净白化)" : "(回退 CPU SmartVolumeMapper边缘有细渗色)");
}
// 纵向夸张烤进 image 的 z 原点/间距(与帘面 SetScale 同倍,保证纵向一致)。 // 纵向夸张烤进 image 的 z 原点/间距(与帘面 SetScale 同倍,保证纵向一致)。
// 用暴露 image 的 buildVoxel 重载:保留 currentVolumeImage_ 供 P3 切片附着(几何含 VE // 用暴露 image 的 buildVoxel 重载:保留 currentVolumeImage_ 供 P3 切片附着(几何含 VE
vtkSmartPointer<vtkImageData> image; vtkSmartPointer<vtkImageData> image;
@ -167,6 +206,7 @@ void VtkSceneView::addVolume(const std::string& dsId, const geopro::data::Volume
// 体 actor 不参与拾取切片选中靠点中切片平面widget 交互/拾取)。否则点击落到体内部时 // 体 actor 不参与拾取切片选中靠点中切片平面widget 交互/拾取)。否则点击落到体内部时
// picker 命中体、worldPoint 落体内 → nearestSlice 按平面距离选错切片(用户 ④ 串选)。 // picker 命中体、worldPoint 落体内 → nearestSlice 按平面距离选错切片(用户 ④ 串选)。
volume->PickableOff(); volume->PickableOff();
volume->SetVisibility(analysisMode2D_ ? 0 : 1); // 体=3D内容二维分析下隐藏
scene_.addViewProp(volume); scene_.addViewProp(volume);
dsProps_[dsId].push_back(volume); dsProps_[dsId].push_back(volume);
currentVolumeImage_ = image; currentVolumeImage_ = image;
@ -174,11 +214,36 @@ void VtkSceneView::addVolume(const std::string& dsId, const geopro::data::Volume
currentVmin_ = vol.vmin; currentVmin_ = vol.vmin;
currentVmax_ = vol.vmax; currentVmax_ = vol.vmax;
volumeOwnerDs_ = dsId; volumeOwnerDs_ = dsId;
volumes_[dsId] = VolumeRec{image, cs, vol.vmin, vol.vmax}; // 多体并发:登记本体 image volumes_[dsId] = VolumeRec{image, cs, vol.vmin, vol.vmax, volume}; // 多体并发:登记本体 image+actor
// G3 等值面:在值域高段(0.7)抽不透明实心异常体(参考图红块)。挂同一 dsProps_ → 随体一并移除。
const double isoVal = vol.vmin + 0.7 * (vol.vmax - vol.vmin);
auto iso = geopro::render::buildIsosurface(image, cs, vol.vmin, vol.vmax, isoVal);
if (iso) {
iso->PickableOff(); // 不参与拾取(同体 actor避免串选
iso->SetVisibility(analysisMode2D_ ? 0 : 1); // 3D 内容:二维分析下隐藏
scene_.addActor(iso);
dsProps_[dsId].push_back(iso);
}
if (onVolumeChanged) onVolumeChanged(); if (onVolumeChanged) onVolumeChanged();
} }
} }
bool VtkSceneView::updateVolumeColorInPlace(const std::string& dsId,
const geopro::core::ColorScale& cs) {
auto it = volumes_.find(dsId);
if (it == volumes_.end() || !it->second.volume) return false; // 未渲染 → 调用方回退 remove+add
// 仅换传函image 不变)→ 切片基底保持有效、不被关闭。等值面随阈值色变化较小,暂不重抽。
geopro::render::updateVolumeColors(it->second.volume, cs, it->second.vmin, it->second.vmax);
it->second.cs = cs;
currentColorScale_ = cs;
// onVolumeChanged → InteractionManager.setVolumeImage(同 image, 新 cs):检测 image 未变 → 不关切片,
// 仅更新体色阶并让该体下未保存切片跟随改色(见 InteractionManager::setVolumeImage
if (onVolumeChanged) onVolumeChanged();
if (renderWindow_) renderWindow_->Render();
return true;
}
void VtkSceneView::addMapLine(const std::string& dsId, const geopro::data::MapLine& line, void VtkSceneView::addMapLine(const std::string& dsId, const geopro::data::MapLine& line,
double worldZ) { double worldZ) {
// 2D 足迹:经共享 frame 投影到世界 XY、Z=worldZ。按 dsId 跟踪(与帘面同 dsProps_ → removeDataset 复用)。 // 2D 足迹:经共享 frame 投影到世界 XY、Z=worldZ。按 dsId 跟踪(与帘面同 dsProps_ → removeDataset 复用)。
@ -187,8 +252,12 @@ void VtkSceneView::addMapLine(const std::string& dsId, const geopro::data::MapLi
anchorFrameIfNeeded(line.lat, line.lon, static_cast<int>(line.lat.size())); anchorFrameIfNeeded(line.lat, line.lon, static_cast<int>(line.lat.size()));
auto actor = geopro::render::buildMapLine(line.lat, line.lon, worldZ, *frame_); auto actor = geopro::render::buildMapLine(line.lat, line.lon, worldZ, *frame_);
if (actor) { if (actor) {
actor->SetVisibility(analysisMode2D_ ? 1 : 0); // 足迹=2D内容仅二维分析下显示
auto off = mapLineZOffset_.find(dsId); // B 期:复用持久 Z 偏移(全量重建后仍在该高度)
if (off != mapLineZOffset_.end()) actor->AddPosition(0.0, 0.0, off->second);
scene_.addActor(actor); scene_.addActor(actor);
dsProps_[dsId].push_back(actor); dsProps_[dsId].push_back(actor);
mapLineDs_.insert(dsId); // 记录此 ds 为 2D 足迹(切 tab 按维度翻可见)
} }
} }
@ -206,6 +275,10 @@ void VtkSceneView::removeDataset(const std::string& dsId) {
if (it == dsProps_.end()) return; if (it == dsProps_.end()) return;
removeProps(it->second); removeProps(it->second);
dsProps_.erase(it); dsProps_.erase(it);
mapLineDs_.erase(dsId); // 若是 2D 足迹则同步去除维度记录
// 场景已无任何数据图元 → 复位重锚标志:下个数据(可能在别处)重新把 frame 锚到它,底图随之归位。
// 否则删到空再加远处新数据时,新数据按旧锚点投到偏远世界坐标、底图仍贴在旧位置 → 底图"消失"。
if (dsProps_.empty()) frameAnchoredToData_ = false;
const bool wasVolume = volumes_.erase(dsId) > 0; const bool wasVolume = volumes_.erase(dsId) > 0;
if (volumeOwnerDs_ == dsId) { // 移除的是"当前体" → currentImage 回退到剩余某体,无则置空 if (volumeOwnerDs_ == dsId) { // 移除的是"当前体" → currentImage 回退到剩余某体,无则置空
if (!volumes_.empty()) { if (!volumes_.empty()) {
@ -303,6 +376,137 @@ void VtkSceneView::fitView() {
if (onCameraChanged) onCameraChanged(); // 取景后 → 底图按新视锥重算覆盖(治首帧部分瓦片不出) if (onCameraChanged) onCameraChanged(); // 取景后 → 底图按新视锥重算覆盖(治首帧部分瓦片不出)
} }
void VtkSceneView::setAnalysisMode2D(bool is2D) {
if (is2D == analysisMode2D_) return; // 幂等:同模式重复切不做事
analysisMode2D_ = is2D;
if (!is2D) clearMapLineSelection(); // 离开二维分析:清足迹选中(三维下不可拖 Z)Z 偏移仍持久
// ① 按维度翻可见标志(不清空、不重建→切换瞬时)2D 足迹↔3D 帘面/体;异常属 3D。
// 地形/测线(miscProps_)与底图(TileBasemap 自管)两边常驻、不动。
for (auto& kv : dsProps_) {
const bool is2dContent = mapLineDs_.count(kv.first) > 0;
const bool vis = is2D ? is2dContent : !is2dContent;
for (auto& p : kv.second)
if (p) p->SetVisibility(vis ? 1 : 0);
}
for (auto& kv : anomalyProps_)
if (kv.second) kv.second->SetVisibility(is2D ? 0 : 1); // 异常=3D内容
// ② 取景 + 坐标轴 + 渲染统一走 render():朝向按 analysisMode2D_(已设)选近俯视/自由透视;
// ResetCamera 到"可见"数据包围盒(computeDataBounds 只计可见 prop)rebuildAxes 在二维下自移除;
// 末尾 Render + onCameraChanged(底图按新视锥重算)。不再用相机快照(陈旧易错),每次按可见内容取景。
render(/*is2D ViewMode=*/false, /*resetCamera=*/true);
}
// ── 二维分析改造 B 期:选中 2D 足迹沿高程 Z 拖动 ───────────────────────────────────
void VtkSceneView::applyMapLineSelectionVisual() {
for (auto& kv : dsProps_) {
if (!mapLineDs_.count(kv.first)) continue;
const bool sel = selectedMapLines_.count(kv.first) > 0;
for (auto& p : kv.second) {
auto* a = vtkActor::SafeDownCast(p);
if (!a) continue;
if (sel) { // 选中:黄高亮 + 加粗
a->GetProperty()->SetColor(1.0, 0.85, 0.2);
a->GetProperty()->SetLineWidth(6.0);
} else { // 未选:复原 buildMapLine 默认(橙 3.0)
a->GetProperty()->SetColor(0.95, 0.55, 0.10);
a->GetProperty()->SetLineWidth(3.0);
}
}
}
}
void VtkSceneView::clearMapLineSelection() {
if (selectedMapLines_.empty()) return;
selectedMapLines_.clear();
applyMapLineSelectionVisual();
if (renderWindow_) renderWindow_->Render();
if (onMapLineSelectionChanged) onMapLineSelectionChanged(); // VTK→列表同步清空
}
std::vector<std::string> VtkSceneView::selectedMapLines() const {
return std::vector<std::string>(selectedMapLines_.begin(), selectedMapLines_.end());
}
void VtkSceneView::setSelectedMapLines(const std::vector<std::string>& dsIds) {
// 列表→VTK按 dsId 设选中(仅已渲染足迹),高亮+渲染;不回调 onMapLineSelectionChanged(防回环)。
selectedMapLines_.clear();
for (const auto& id : dsIds)
if (mapLineDs_.count(id)) selectedMapLines_.insert(id);
applyMapLineSelectionVisual();
if (renderWindow_) renderWindow_->Render();
}
bool VtkSceneView::pickMapLineAt(int screenX, int screenY, bool additive) {
auto* ren = scene_.renderer();
if (!ren) return false;
// 只在"可见足迹"中拾取(PickFromList):避免地形/底图/隐藏的 3D 体抢命中。
vtkNew<vtkCellPicker> picker;
picker->SetTolerance(0.012);
picker->PickFromListOn();
bool any = false;
for (auto& kv : dsProps_) {
if (!mapLineDs_.count(kv.first)) continue;
for (auto& p : kv.second)
if (p && p->GetVisibility()) { picker->AddPickList(p); any = true; }
}
if (!any) return false; // 无可见足迹 → 不拦截(交由平移)
if (!picker->Pick(screenX, screenY, 0.0, ren)) {
if (!additive) clearMapLineSelection(); // 点空白(非多选)→ 取消选中
return false;
}
vtkProp* hit = picker->GetViewProp();
std::string hitDs;
for (auto& kv : dsProps_) {
if (!mapLineDs_.count(kv.first)) continue;
for (auto& p : kv.second)
if (p.Get() == hit) { hitDs = kv.first; break; }
if (!hitDs.empty()) break;
}
if (hitDs.empty()) {
if (!additive) clearMapLineSelection();
return false;
}
if (additive) { // Ctrl 多选:切换该足迹
if (selectedMapLines_.count(hitDs)) selectedMapLines_.erase(hitDs);
else selectedMapLines_.insert(hitDs);
} else if (!selectedMapLines_.count(hitDs)) { // 单击未选中的线 → 替换为它
selectedMapLines_.clear();
selectedMapLines_.insert(hitDs);
}
// 单击已选中的线(可能为多选之一):保持当前选中集 → 起手即可整体拖动,不塌缩为单选。
applyMapLineSelectionVisual();
if (renderWindow_) renderWindow_->Render();
if (onMapLineSelectionChanged) onMapLineSelectionChanged(); // VTK→列表同步选中
return !selectedMapLines_.empty(); // 有选中 → 交互样式进入 Z 拖动
}
void VtkSceneView::nudgeSelectedMapLinesZ(double worldDz) {
if (selectedMapLines_.empty() || worldDz == 0.0) return;
for (const auto& dsId : selectedMapLines_) {
mapLineZOffset_[dsId] += worldDz; // 持久累计(全量重建后 addMapLine 复用)
auto it = dsProps_.find(dsId);
if (it == dsProps_.end()) continue;
for (auto& p : it->second) {
auto* a = vtkActor::SafeDownCast(p);
if (a) a->AddPosition(0.0, 0.0, worldDz); // 仅改 Z锁 XY
}
}
if (scene_.renderer()) scene_.renderer()->ResetCameraClippingRange(); // Z 抬升后防被裁剪面切
if (renderWindow_) renderWindow_->Render();
}
double VtkSceneView::selectedMapLineZ() const {
if (selectedMapLines_.empty()) return 0.0;
// 代表性 Z = 任一选中足迹 actor 的包围盒中心 Z(含 placement worldZ + 已累计偏移)。
auto it = dsProps_.find(*selectedMapLines_.begin());
if (it == dsProps_.end()) return 0.0;
for (const auto& p : it->second)
if (p) { if (double* b = p->GetBounds()) return 0.5 * (b[4] + b[5]); }
return 0.0;
}
void VtkSceneView::rebuildAxes() { void VtkSceneView::rebuildAxes() {
// 先移除上一次的坐标轴 proprender 可能在一次 rebuild 内多次调用(末尾统一 render + // 先移除上一次的坐标轴 proprender 可能在一次 rebuild 内多次调用(末尾统一 render +
// 异步回灌 render不先移除会叠加坐标轴评审 HIGH。移除后再算 bounds仅数据图元 // 异步回灌 render不先移除会叠加坐标轴评审 HIGH。移除后再算 bounds仅数据图元
@ -310,6 +514,9 @@ void VtkSceneView::rebuildAxes() {
scene_.renderer()->RemoveViewProp(currentAxes_); scene_.renderer()->RemoveViewProp(currentAxes_);
currentAxes_ = nullptr; currentAxes_ = nullptr;
} }
// 二维分析无立体坐标轴:任何渲染路径(全量/增量/切模式)走到此都不重建坐标轴,
// 保证切到二维分析后坐标轴消失、且后续增量渲染不把它带回来。
if (analysisMode2D_) return;
// 坐标轴随数据包围盒重建:仅按数据图元算 bounds(不含底图,否则被~公里级底图撑大) // 坐标轴随数据包围盒重建:仅按数据图元算 bounds(不含底图,否则被~公里级底图撑大)
// 再造 vtkCubeAxesActor 入场。None 模式或无数据 → buildAxes 返回 nullptr场景无坐标轴。 // 再造 vtkCubeAxesActor 入场。None 模式或无数据 → buildAxes 返回 nullptr场景无坐标轴。
double bounds[6]; double bounds[6];
@ -340,14 +547,18 @@ void VtkSceneView::render(bool is2D, bool resetCamera) {
// 坐标轴仅三维视图显示2D 俯视测线不需要立体坐标轴)。 // 坐标轴仅三维视图显示2D 俯视测线不需要立体坐标轴)。
if (!is2D) rebuildAxes(); if (!is2D) rebuildAxes();
// 相机预设(朝向)只在取景时应用——保留相机的重建(改放大系数)不重设朝向,否则也会跳视角。 // 相机预设(朝向)只在取景时应用——保留相机的重建(改放大系数)不重设朝向,否则也会跳视角。
// 朝向优先看二维分析模式(本期 A):处二维分析→近俯视;否则按 ViewMode(旧 Map2D 正俯视/三维自由)。
// 这样 VE 改/项目切等全量重建在二维分析下仍保持近俯视,不跳回三维自由视角。
if (resetCamera) { if (resetCamera) {
if (is2D) if (analysisMode2D_)
geopro::render::applyNearTop2D(scene_.renderer());
else if (is2D)
geopro::render::applyTop2D(scene_.renderer()); geopro::render::applyTop2D(scene_.renderer());
else else
geopro::render::applyFree3D(scene_.renderer()); geopro::render::applyFree3D(scene_.renderer());
double bounds[6]; double bounds[6];
if (computeDataBounds(bounds)) if (computeDataBounds(bounds))
scene_.renderer()->ResetCamera(bounds); // 取景到数据(不含底图,否则数据缩成小点) scene_.renderer()->ResetCamera(bounds); // 取景到"可见"数据(不含底图,否则数据缩成小点)
else else
scene_.renderer()->ResetCamera(); scene_.renderer()->ResetCamera();
} }

View File

@ -2,6 +2,7 @@
#include <functional> #include <functional>
#include <map> #include <map>
#include <memory> #include <memory>
#include <set>
#include <string> #include <string>
#include <vector> #include <vector>
@ -18,6 +19,7 @@ class vtkRenderer;
class vtkRenderWindow; class vtkRenderWindow;
class vtkProp; class vtkProp;
class vtkActor; class vtkActor;
class vtkVolume;
namespace geopro::app { namespace geopro::app {
@ -33,12 +35,15 @@ public:
void clear() override; void clear() override;
void setVerticalExaggeration(double ve) override; void setVerticalExaggeration(double ve) override;
void setVolumeOpacity(double maxOpacity) override; // 运行时调已渲染体 + 后续新体的最大不透明度
double zRefElev() const override { return zRefElev_; } double zRefElev() const override { return zRefElev_; }
void addSurveyLine(const geopro::core::Grid& grid) override; void addSurveyLine(const geopro::core::Grid& grid) override;
void addCurtain(const std::string& dsId, const geopro::core::Grid& grid, void addCurtain(const std::string& dsId, const geopro::core::Grid& grid,
const geopro::core::ColorScale& cs) override; const geopro::core::ColorScale& cs) override;
void addVolume(const std::string& dsId, const geopro::data::VolumeGrid& vol, void addVolume(const std::string& dsId, const geopro::data::VolumeGrid& vol,
const geopro::core::ColorScale& cs) override; const geopro::core::ColorScale& cs) override;
bool updateVolumeColorInPlace(const std::string& dsId,
const geopro::core::ColorScale& cs) override;
void addMapLine(const std::string& dsId, const geopro::data::MapLine& line, void addMapLine(const std::string& dsId, const geopro::data::MapLine& line,
double worldZ) override; double worldZ) override;
void addTerrain(const geopro::data::TerrainPaths& paths) override; void addTerrain(const geopro::data::TerrainPaths& paths) override;
@ -82,6 +87,29 @@ public:
// 相机程序化变化(取景/预设/缩放)后回调,供底图按新视锥重算覆盖(否则首帧部分瓦片要手动微动才出)。 // 相机程序化变化(取景/预设/缩放)后回调,供底图按新视锥重算覆盖(否则首帧部分瓦片要手动微动才出)。
std::function<void()> onCameraChanged; std::function<void()> onCameraChanged;
// ── 二维分析改造 A 期:一场景两相机 ──────────────────────────────────────────
// 切「二维分析」(is2D=true):相机锁近俯视、显 2D 足迹/隐 3D(体/帘面/异常);切回反之。
// 只翻 actor 可见标志(不清空、不重建)→ 切换瞬时、零重插值。地形/底图常驻不动。
// 切片显隐 + 交互锁由 InteractionManager::setMode2D 配合(上层在同一处调两者)。
void setAnalysisMode2D(bool is2D);
bool isAnalysisMode2D() const { return analysisMode2D_; }
// ── 二维分析改造 B 期:选中 2D 足迹沿高程 Z 拖动 ───────────────────────────────
// 仅二维分析下用。pickMapLineAt在屏幕(x,y)拾取足迹(只考虑可见足迹,不被地形/底图干扰);命中则
// 选中(additive=Ctrl 多选切换,否则单选替换)并高亮,返回是否有选中(交互样式据此决定 Z 拖动/平移)。
// nudgeSelectedMapLinesZ选中足迹世界 Z += worldDz(锁 XY);偏移按 dsId 持久(切走再回/全量重建保留)。
// selectedMapLineZ代表性当前世界 Z(高程读数浮层用);无选中返回 0。
bool pickMapLineAt(int screenX, int screenY, bool additive);
void clearMapLineSelection();
bool hasMapLineSelection() const { return !selectedMapLines_.empty(); }
void nudgeSelectedMapLinesZ(double worldDz);
double selectedMapLineZ() const;
// 双向选择联动列表↔VTK。selectedMapLines 取当前选中 dsIdsetSelectedMapLines 由列表设置选中
// (高亮,不回调,避免环)。VTK 内拾取改变选中时触发 onMapLineSelectionChanged → 上层同步列表。
std::vector<std::string> selectedMapLines() const;
void setSelectedMapLines(const std::vector<std::string>& dsIds);
std::function<void()> onMapLineSelectionChanged;
private: private:
// 首个带经纬数据(剖面/足迹)到达时把共享 frame 重锚到其 lat/lon 包围盒中心:使数据落在世界原点近旁 // 首个带经纬数据(剖面/足迹)到达时把共享 frame 重锚到其 lat/lon 包围盒中心:使数据落在世界原点近旁
// (否则样本默认原点可能离真实数据数百公里→图元在视锥外、移动视角也找不到)。已锚或无经纬则跳过。 // (否则样本默认原点可能离真实数据数百公里→图元在视锥外、移动视角也找不到)。已锚或无经纬则跳过。
@ -127,6 +155,7 @@ private:
vtkSmartPointer<vtkImageData> image; vtkSmartPointer<vtkImageData> image;
geopro::core::ColorScale cs; geopro::core::ColorScale cs;
double vmin = 0.0, vmax = 0.0; double vmin = 0.0, vmax = 0.0;
vtkSmartPointer<vtkVolume> volume; // 体 actor运行时调不透明度改其 property 的不透明度传递函数)
}; };
std::map<std::string, VolumeRec> volumes_; std::map<std::string, VolumeRec> volumes_;
@ -146,6 +175,16 @@ private:
std::vector<vtkSmartPointer<vtkProp>> miscProps_; std::vector<vtkSmartPointer<vtkProp>> miscProps_;
std::string volumeOwnerDs_; // 当前 currentVolumeImage_ 归属的 ds其被移除时置空切片源 std::string volumeOwnerDs_; // 当前 currentVolumeImage_ 归属的 ds其被移除时置空切片源
std::map<std::string, vtkSmartPointer<vtkActor>> anomalyProps_; // 异常 id → 3D actor std::map<std::string, vtkSmartPointer<vtkActor>> anomalyProps_; // 异常 id → 3D actor
// ── 二维分析改造 A 期 ──
// 哪些 dsProps_ 条目是 2D 足迹(addMapLine):切 tab 按此区分维度翻可见(其余 dsProps_=帘面/体=3D)。
std::set<std::string> mapLineDs_;
bool analysisMode2D_ = false; // 当前是否处二维分析(默认三维启动在「三维分析」tab)
// B 期:选中的足迹 dsId(Z 拖动目标) + 各足迹累计 Z 偏移(持久,全量重建后 addMapLine 复用)。
std::set<std::string> selectedMapLines_;
std::map<std::string, double> mapLineZOffset_;
void applyMapLineSelectionVisual(); // 选中足迹加粗变亮、其余复原(橙 3.0)
}; };
} // namespace geopro::app } // namespace geopro::app

View File

@ -1,7 +1,11 @@
#include "VtkViewToolbar.hpp" #include "VtkViewToolbar.hpp"
#include <QFrame> #include <QFrame>
#include <QHBoxLayout>
#include <QLabel>
#include <QPoint>
#include <QSize> #include <QSize>
#include <QSlider>
#include <QToolButton> #include <QToolButton>
#include <QVBoxLayout> #include <QVBoxLayout>
@ -61,11 +65,12 @@ VtkViewToolbar::VtkViewToolbar(QWidget* parent) : QWidget(parent) {
{"", ViewDir::Bottom}, {"", ViewDir::Left}, {"", ViewDir::Right}}; {"", ViewDir::Bottom}, {"", ViewDir::Left}, {"", ViewDir::Right}};
for (const V& v : views) { for (const V& v : views) {
const ViewDir d = v.d; const ViewDir d = v.d;
connect(textBtn(QString::fromUtf8(v.t)), &QToolButton::clicked, this, auto* b = textBtn(QString::fromUtf8(v.t));
[this, d] { emit viewRequested(d); }); connect(b, &QToolButton::clicked, this, [this, d] { emit viewRequested(d); });
viewDirButtons_.push_back(b); // 二维分析下禁用(会改朝向、破坏近俯视锁定)
} }
sep(); sep();
// ── 段3缩放 / 复位 ── // ── 段3缩放 / 复位 ──(三维体不透明度已移交色阶「不透明度」,旧「透」滑块退役移除)
connect(iconBtn(Glyph::Plus, QStringLiteral("放大")), &QToolButton::clicked, this, connect(iconBtn(Glyph::Plus, QStringLiteral("放大")), &QToolButton::clicked, this,
&VtkViewToolbar::zoomInRequested); &VtkViewToolbar::zoomInRequested);
connect(iconBtn(Glyph::Minus, QStringLiteral("缩小")), &QToolButton::clicked, this, connect(iconBtn(Glyph::Minus, QStringLiteral("缩小")), &QToolButton::clicked, this,
@ -84,4 +89,12 @@ VtkViewToolbar::VtkViewToolbar(QWidget* parent) : QWidget(parent) {
adjustSize(); adjustSize();
} }
void VtkViewToolbar::setAnalysisMode2D(bool is2D) {
for (auto* b : viewDirButtons_) {
if (!b) continue;
b->setEnabled(!is2D);
b->setToolTip(is2D ? QStringLiteral("二维分析下不可用(已锁定近俯视)") : QString());
}
}
} // namespace geopro::app } // namespace geopro::app

View File

@ -1,7 +1,12 @@
#pragma once #pragma once
#include <QWidget> #include <QWidget>
#include <vector>
#include "I3dSceneView.hpp" // geopro::controller::ViewDir #include "I3dSceneView.hpp" // geopro::controller::ViewDir
class QToolButton;
class QSlider;
class QLabel;
namespace geopro::app { namespace geopro::app {
// VTK 画布竖排工具条spec §9全局视图控制——设置(坐标轴)/前后上下左右/放大缩小复位。 // VTK 画布竖排工具条spec §9全局视图控制——设置(坐标轴)/前后上下左右/放大缩小复位。
@ -11,12 +16,20 @@ class VtkViewToolbar : public QWidget {
public: public:
explicit VtkViewToolbar(QWidget* parent = nullptr); explicit VtkViewToolbar(QWidget* parent = nullptr);
public slots:
// 二维分析激活时禁用不适用的工具6 向快捷视图会改相机朝向→破坏二维近俯视锁定,故二维下禁用;
// 缩放/适配/坐标轴设置(含 VE)仍可用。切回三维恢复。
void setAnalysisMode2D(bool is2D);
signals: signals:
void axesSettingsRequested(); // 设置 → 弹 AxesSettingsDialog void axesSettingsRequested(); // 设置 → 弹 AxesSettingsDialog
void viewRequested(geopro::controller::ViewDir dir); // 前/后/上/下/左/右 void viewRequested(geopro::controller::ViewDir dir); // 前/后/上/下/左/右
void zoomInRequested(); void zoomInRequested();
void zoomOutRequested(); void zoomOutRequested();
void fitRequested(); // 复位=适配 void fitRequested(); // 复位=适配
private:
std::vector<QToolButton*> viewDirButtons_; // 6 向快捷视图按钮:二维分析下禁用
}; };
} // namespace geopro::app } // namespace geopro::app

View File

@ -100,7 +100,8 @@ LoginWindow::LoginWindow(geopro::net::AuthService& auth, QWidget* parent)
"#brandSubtitle { color: rgba(255,255,255,0.82); font-size: %3px; }" "#brandSubtitle { color: rgba(255,255,255,0.82); font-size: %3px; }"
"#fieldLabel { color: {{text/secondary}}; font-size: %4px; font-weight: %5; }" "#fieldLabel { color: {{text/secondary}}; font-size: %4px; font-weight: %5; }"
// 输入框已 Ela 化(ElaLineEdit 自绘 Fluent + 自动明暗),不再写 QLineEdit QSS。 // 输入框已 Ela 化(ElaLineEdit 自绘 Fluent + 自动明暗),不再写 QLineEdit QSS。
"#captchaImg { border: 1px solid {{border/strong}}; border-radius: 8px; background: {{bg/hover}}; }") // 验证码容器固定白底:后端验证码图是浅底,白底贴合图边(两种主题皆然,故用白字面值)。
"#captchaImg { border: 1px solid {{border/strong}}; border-radius: 8px; background: #FFFFFF; }")
.arg(scaledPx(type::kDisplay)) .arg(scaledPx(type::kDisplay))
.arg(type::kWeightBold) .arg(type::kWeightBold)
.arg(scaledPx(type::kCaption)) .arg(scaledPx(type::kCaption))

View File

@ -50,10 +50,12 @@
#include <QListWidget> #include <QListWidget>
#include <QListWidgetItem> #include <QListWidgetItem>
#include <QJsonObject> #include <QJsonObject>
#include <QLibraryInfo>
#include <QMenu> #include <QMenu>
#include <QMessageBox> #include <QMessageBox>
#include <QPoint> #include <QPoint>
#include <QSet> #include <QSet>
#include <QTranslator>
#include <QToolButton> #include <QToolButton>
#include <QKeySequence> #include <QKeySequence>
#include <QProcess> #include <QProcess>
@ -64,6 +66,7 @@
#include <QStringList> #include <QStringList>
#include <QTabWidget> #include <QTabWidget>
#include <QMainWindow> #include <QMainWindow>
#include <QStackedWidget>
#include <QStatusBar> #include <QStatusBar>
#include <QStyle> #include <QStyle>
#include <QSurfaceFormat> #include <QSurfaceFormat>
@ -104,6 +107,7 @@
#include "SlicePropertiesDialog.hpp" #include "SlicePropertiesDialog.hpp"
#include "SliceExport.hpp" #include "SliceExport.hpp"
#include "ToastOverlay.hpp" #include "ToastOverlay.hpp"
#include "panels/LoadingOverlay.hpp"
#include "TopBar.hpp" #include "TopBar.hpp"
#include "VolumeParamsDialog.hpp" #include "VolumeParamsDialog.hpp"
#include "VolumePropertiesDialog.hpp" #include "VolumePropertiesDialog.hpp"
@ -111,7 +115,9 @@
#include "ProjectListDialog.hpp" #include "ProjectListDialog.hpp"
#include "ObjectFormDialog.hpp" #include "ObjectFormDialog.hpp"
#include "ImportDatasetDialog.hpp" #include "ImportDatasetDialog.hpp"
#include "panels/web/ProjectWebView.hpp"
#include "WorkbenchNavController.hpp" #include "WorkbenchNavController.hpp"
#include "DatasetViewState.hpp"
#include "VtkSceneController.hpp" #include "VtkSceneController.hpp"
#include "VtkSceneView.hpp" #include "VtkSceneView.hpp"
#include "api/NavRequest.hpp" #include "api/NavRequest.hpp"
@ -150,6 +156,7 @@
#include "Scene.hpp" #include "Scene.hpp"
#include "VoxelFromScatters.hpp" #include "VoxelFromScatters.hpp"
#include "interact/InteractionManager.hpp" #include "interact/InteractionManager.hpp"
#include "interact/PickInteractorStyle.hpp"
#include "interact/SlicePlaneMath.hpp" #include "interact/SlicePlaneMath.hpp"
#include "actors/AnomalyActor.hpp" #include "actors/AnomalyActor.hpp"
#include "actors/CurtainActor.hpp" #include "actors/CurtainActor.hpp"
@ -174,6 +181,8 @@
#include <vtkCameraInterpolator.h> #include <vtkCameraInterpolator.h>
#include <vtkGenericOpenGLRenderWindow.h> #include <vtkGenericOpenGLRenderWindow.h>
#include <vtkLookupTable.h> #include <vtkLookupTable.h>
#include <vtkObjectFactory.h>
#include <vtkOutputWindow.h>
#include <vtkProperty.h> #include <vtkProperty.h>
#include <vtkImageData.h> #include <vtkImageData.h>
#include <vtkDataArray.h> #include <vtkDataArray.h>
@ -194,6 +203,8 @@ public:
{ {
host_->installEventFilter(this); host_->installEventFilter(this);
} }
// overlay 定位/置顶后,再把这些控件 raise 到 overlay 之上(如工具条/提示常驻最上层)。
void setRaiseAfter(std::vector<QWidget*> w) { raiseAfter_ = std::move(w); }
void reposition() void reposition()
{ {
overlay_->adjustSize(); overlay_->adjustSize();
@ -206,9 +217,19 @@ public:
// 偏移取非负:保证浮层矩形始终 ⊆ host 矩形(不跑到 host 外)。 // 偏移取非负:保证浮层矩形始终 ⊆ host 矩形(不跑到 host 外)。
const int dx = std::max(0, (h.width() - o.width()) / 2); const int dx = std::max(0, (h.width() - o.width()) / 2);
const int dy = std::max(0, (h.height() - o.height()) / 2); const int dy = std::max(0, (h.height() - o.height()) / 2);
if (overlay_->parentWidget() == host_) {
// overlay 是 host 的子级:本地坐标居中。须 raise 到 GL 之上才可见QVTKOpenGLStereoWidget
// 的子控件 lower 会落到 GL 之下→不可见),再把工具条/提示 raise 回它之上→工具条永在最上层。
overlay_->move(dx, dy);
overlay_->raise();
for (QWidget* w : raiseAfter_)
if (w) w->raise();
} else {
// overlay 与 host 同级:换算到共同父坐标系并置顶。
overlay_->move(host_->x() + dx, host_->y() + dy); overlay_->move(host_->x() + dx, host_->y() + dy);
overlay_->raise(); overlay_->raise();
} }
}
protected: protected:
bool eventFilter(QObject* obj, QEvent* e) override bool eventFilter(QObject* obj, QEvent* e) override
@ -221,6 +242,7 @@ protected:
private: private:
QWidget* overlay_; QWidget* overlay_;
QWidget* host_; QWidget* host_;
std::vector<QWidget*> raiseAfter_; // 定位后再 raise 到 overlay 之上的常驻控件(工具条/提示)
}; };
// 取 vector 中位数(用于由测线 lat/lon 推世界系原点)。空则返回 0。 // 取 vector 中位数(用于由测线 lat/lon 推世界系原点)。空则返回 0。
@ -249,7 +271,8 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
geopro::data::IColorTemplateRepository& colorTplRepo, geopro::data::IColorTemplateRepository& colorTplRepo,
geopro::data::IDatasetCommandRepository& cmdRepo, geopro::data::IDatasetCommandRepository& cmdRepo,
geopro::controller::WorkbenchNavController& nav, geopro::controller::WorkbenchNavController& nav,
geopro::controller::DatasetDetailController& detailCtrl) geopro::controller::DatasetDetailController& detailCtrl,
const QString& sessionToken)
{ {
// ── 世界系:启动取一次 grid1 的 lat/lon用中位数作 GeoLocalFrame 原点 ── // ── 世界系:启动取一次 grid1 的 lat/lon用中位数作 GeoLocalFrame 原点 ──
// 全项目共享shared_ptr 持有):所有帘面用同一 frame 投影,保证多条测线空间配准。 // 全项目共享shared_ptr 持有):所有帘面用同一 frame 投影,保证多条测线空间配准。
@ -279,6 +302,10 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
auto* sceneCtrl = new geopro::controller::VtkSceneController(repo, *scene3dRepo, *sceneView, auto* sceneCtrl = new geopro::controller::VtkSceneController(repo, *scene3dRepo, *sceneView,
vtkWidget); vtkWidget);
sceneCtrl->setVerticalExaggeration(kVerticalExaggeration); sceneCtrl->setVerticalExaggeration(kVerticalExaggeration);
// 跨视图色阶真源统一同步机制2D 详情/3D 帘面/体 共用一份按 dsId 的色阶;编辑→真源→各视图跟随。
// parent=vtkWidget → 随窗口销毁清理;须早于详情面板创建以便注入。
auto* viewState = new geopro::controller::DatasetViewState(vtkWidget);
sceneCtrl->setViewState(viewState);
// ── P3 切片交互编排InteractionManager───────────────────────────────── // ── P3 切片交互编排InteractionManager─────────────────────────────────
// interactor 由 QVTK 在 setRenderWindow 后提供renderWindow->GetInteractor())。 // interactor 由 QVTK 在 setRenderWindow 后提供renderWindow->GetInteractor())。
@ -322,7 +349,11 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
ads::CDockManager::setAutoHideConfigFlags(ads::CDockManager::AutoHideFlags()); // 禁用自动隐藏 ads::CDockManager::setAutoHideConfigFlags(ads::CDockManager::AutoHideFlags()); // 禁用自动隐藏
auto* dockManager = new ads::CDockManager(&window); auto* dockManager = new ads::CDockManager(&window);
window.setCentralWidget(dockManager); // 中央区用 QStackedWidget 承载page0=工作台(dockManager默认「分析视图」)
// page1=项目管理 web 页(点项目管理菜单整窗加载,视图菜单「分析视图」切回 page0
auto* centralStack = new QStackedWidget(&window);
centralStack->addWidget(dockManager); // index 0工作台
window.setCentralWidget(centralStack);
// 覆盖 ADS 自带分隔条样式:其 default.css 用 palette(dark) 把面板间分隔画成深灰粗线, // 覆盖 ADS 自带分隔条样式:其 default.css 用 palette(dark) 把面板间分隔画成深灰粗线,
// 且设在管理器自身、选择器更具体(优先级高于全局主题)。在其后追加同选择器、按主题着色的极淡分隔覆盖。 // 且设在管理器自身、选择器更具体(优先级高于全局主题)。在其后追加同选择器、按主题着色的极淡分隔覆盖。
@ -389,6 +420,13 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
split->addWidget(vtkWidget); split->addWidget(vtkWidget);
split->setStretchFactor(0, 0); split->setStretchFactor(0, 0);
split->setStretchFactor(1, 1); split->setStretchFactor(1, 1);
// 折叠/展开抽屉 → 同步 QSplitter 尺寸:收起时把抽屉栏压到按钮宽(18)、余量全给画布(否则残留空白区);
// 展开恢复约 280。setSizes 为相对比例splitter 按 min/max 钳制后铺满。
QObject::connect(drawer, &geopro::app::ColumnDrawer::collapsedChanged, split,
[split](bool collapsed) {
split->setSizes(collapsed ? QList<int>{18, 100000}
: QList<int>{280, 100000});
});
centerLayout->addWidget(viewHeader); centerLayout->addWidget(viewHeader);
centerLayout->addWidget(split, 1); centerLayout->addWidget(split, 1);
// 工具条悬浮于画布左上角overlay左上固定画布 resize 无需重定位)。 // 工具条悬浮于画布左上角overlay左上固定画布 resize 无需重定位)。
@ -396,6 +434,78 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
viewToolbar->move(12, 12); viewToolbar->move(12, 12);
viewToolbar->raise(); viewToolbar->raise();
viewToolbar->show(); viewToolbar->show();
// 异常绘制操作提示:右上角 QLabel 浮层VTK 内置字体不含中文字形,故用 Qt 渲染中文 + QSS 美化)。
// 深底 + accent 描边;不挡画布鼠标事件;绘制开始显示、结束/取消隐藏(见 onSliceContextMenuRequested
auto* anomalyHint = new QLabel(vtkWidget);
anomalyHint->setObjectName(QStringLiteral("anomalyHint"));
anomalyHint->setAttribute(Qt::WA_TransparentForMouseEvents);
// 方角 + 不透明深底:避免「圆角外三角区露白底」与「半透明在 GL 子控件上渲染成灰」两个坑。
geopro::app::applyTokenizedStyleSheet(
anomalyHint,
QStringLiteral("QLabel#anomalyHint{background:#0E1A2D;color:#E6ECF5;"
"border:1px solid {{accent/primary}};padding:8px 12px;}"));
anomalyHint->hide();
// 保存三维体等待蒙版(公共组件 LoadingOverlay挂 centerWidget → showOver 铺满整个「VTK视图」
// 子视图(表头+左树+画布)并 raise 到顶层;保存阶段显示,完成即撤。复用于其他需要整窗等待的场景。
auto* vtkLoading = new geopro::app::LoadingOverlay(centerWidget);
// ── 二维分析 B 期:高程 Z 拖动读数浮层(顶部居中)+ 足迹拾取/拖动回调注入交互样式 ──────────
// 拖动选中足迹时显示其当前世界 Z松开隐藏不挡画布鼠标。深底方角同异常提示坑规避
auto* elevHint = new QLabel(vtkWidget);
elevHint->setObjectName(QStringLiteral("elevHint"));
elevHint->setAttribute(Qt::WA_TransparentForMouseEvents);
geopro::app::applyTokenizedStyleSheet(
elevHint, QStringLiteral("QLabel#elevHint{background:#0E1A2D;color:#E6ECF5;"
"border:1px solid {{accent/primary}};padding:6px 12px;}"));
elevHint->hide();
// 滚轮升降时读数浮层 1.2s 后自动隐藏(拖动则在松开时隐藏)。
auto* zHideTimer = new QTimer(vtkWidget);
zHideTimer->setSingleShot(true);
QObject::connect(zHideTimer, &QTimer::timeout, elevHint, [elevHint]() { elevHint->hide(); });
auto showZReadout = std::make_shared<std::function<void()>>([sceneView, elevHint, vtkWidget]() {
elevHint->setText(
QStringLiteral("高程 Z%1 m").arg(sceneView->selectedMapLineZ(), 0, 'f', 1));
elevHint->adjustSize();
elevHint->move((vtkWidget->width() - elevHint->width()) / 2, 12); // 顶部居中
elevHint->show();
elevHint->raise();
});
if (auto* style = interactionMgr->pickStyle()) {
// 命中可见足迹→选中(Ctrl 多选)并返回是否进入 Z 拖动;未命中(返回 false)→交互样式回退平移。
style->onPick2D = [sceneView](int x, int y, bool additive) {
return sceneView->pickMapLineAt(x, y, additive);
};
// 拖动中:施加世界 Z 增量(仅改 Z),并把选中足迹当前高程显示在顶部读数浮层。
style->onDrag2D = [sceneView, showZReadout](double worldDz) {
sceneView->nudgeSelectedMapLinesZ(worldDz);
(*showZReadout)();
};
style->onDrag2DEnd = [elevHint]() { elevHint->hide(); };
// 滚轮升降:有选中足迹则施加 Z 增量并显示读数(1.2s 后自动隐藏),返回 true 消费滚轮;否则缩放。
style->onWheel2D = [sceneView, showZReadout, zHideTimer](double worldDz) {
if (!sceneView->hasMapLineSelection()) return false;
sceneView->nudgeSelectedMapLinesZ(worldDz);
(*showZReadout)();
zHideTimer->start(1200);
return true;
};
}
// 双向选择联动:列表行选中 ↔ VTK 足迹高亮。两向各自屏蔽回环(setSelectedMapLines 不回调、
// setSelectedDsIds 屏蔽信号),故无需额外守卫。
QObject::connect(drawer->col2D(), &geopro::app::Column2DDataset::selectedDatasetsChanged, &window,
[sceneView](const QStringList& ids) {
std::vector<std::string> v;
for (const QString& s : ids) v.push_back(s.toStdString());
sceneView->setSelectedMapLines(v);
});
sceneView->onMapLineSelectionChanged = [sceneView, drawer]() {
QStringList ids;
for (const std::string& s : sceneView->selectedMapLines())
ids << QString::fromStdString(s);
drawer->col2D()->setSelectedDsIds(ids);
};
// 坐标轴设置抽屉面板:叠加 vtkWidget、工具条右侧滑出默认隐藏点设置 toggle // 坐标轴设置抽屉面板:叠加 vtkWidget、工具条右侧滑出默认隐藏点设置 toggle
auto* axesPanel = new geopro::app::AxesSettingsPanel(vtkWidget); auto* axesPanel = new geopro::app::AxesSettingsPanel(vtkWidget);
axesPanel->hide(); axesPanel->hide();
@ -420,9 +530,14 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
} }
for (const std::string& id : *checkedSliceIds) { for (const std::string& id : *checkedSliceIds) {
geopro::data::I3dSceneRepository::SliceSpec sp; geopro::data::I3dSceneRepository::SliceSpec sp;
if (scene3dRepo->sliceSpec(id, sp) && interactionMgr->isVolumeRendered(sp.volumeDsId)) if (scene3dRepo->sliceSpec(id, sp) && interactionMgr->isVolumeRendered(sp.volumeDsId)) {
interactionMgr->showSavedSlice(id, sp.axis, sp.origin, sp.point1, sp.point2, interactionMgr->showSavedSlice(id, sp.axis, sp.origin, sp.point1, sp.point2,
sp.volumeDsId); sp.volumeDsId);
// 已保存切片用自己的色阶(颜色+不透明度无则跟随三维体兜底showSavedSlice 已用体色阶)。
geopro::core::ColorScale scs;
if (scene3dRepo->sliceColorScale(id, scs))
interactionMgr->setSliceColorScaleByDsId(id, scs);
}
} }
}; };
@ -503,15 +618,30 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
// 保存=按"未保存/已保存"分派(新建+链接+自动勾选 / 覆盖位姿)导出统一为「导出▸图片·dat」 // 保存=按"未保存/已保存"分派(新建+链接+自动勾选 / 覆盖位姿)导出统一为「导出▸图片·dat」
// 正视/翻转/关闭=接现有交互(关闭已保存切片→onSliceClosed 取消列表勾选);创建异常=占位(#4)。 // 正视/翻转/关闭=接现有交互(关闭已保存切片→onSliceClosed 取消列表勾选);创建异常=占位(#4)。
interactionMgr->onSliceContextMenuRequested = interactionMgr->onSliceContextMenuRequested =
[&window, interactionMgr, sceneView, scene3dRepo, refreshAnalysis, refreshAnomalies, drawer, [&window, &cmdRepo, &nav, interactionMgr, sceneView, scene3dRepo, refreshAnalysis,
anomalyDrawTool, renderWindowPtr]() { refreshAnomalies, drawer, anomalyDrawTool, renderWindowPtr, anomalyHint, vtkWidget]() {
QMenu menu(&window); QMenu menu(&window);
QAction* aAnomaly = menu.addAction(QStringLiteral("创建异常")); QMenu* anomMenu = menu.addMenu(QStringLiteral("创建异常")); // → 点/线/面 子菜单
QAction* aSave = menu.addAction(QStringLiteral("保存")); QAction* aAnoPoint = anomMenu->addAction(QStringLiteral(""));
QAction* aAnoLine = anomMenu->addAction(QStringLiteral("线"));
QAction* aAnoFace = anomMenu->addAction(QStringLiteral(""));
// 「保存」仅对未保存(临时)切片显示——已保存切片定稿锁定、不可再改/再存(用户要求)。
QAction* aSave = interactionMgr->selectedSliceDsId().empty()
? menu.addAction(QStringLiteral("保存"))
: nullptr;
QMenu* expMenu = menu.addMenu(QStringLiteral("导出")); QMenu* expMenu = menu.addMenu(QStringLiteral("导出"));
QAction* aImg = expMenu->addAction(QStringLiteral("图片")); QAction* aImg = expMenu->addAction(QStringLiteral("图片"));
QAction* aDat = expMenu->addAction(QStringLiteral("dat")); QAction* aDat = expMenu->addAction(QStringLiteral("dat"));
menu.addSeparator(); menu.addSeparator();
// 「不透明度」放入视觉分组(正视图上方),仅对未保存切片显示(已保存切片改不透明度走列表右键
// 「色阶」)。颜色映射始终跟随三维体;这里只设总不透明度。
QAction *aOp100 = nullptr, *aOpPlus50 = nullptr, *aOpFollow = nullptr;
if (interactionMgr->selectedSliceDsId().empty()) {
QMenu* opMenu = menu.addMenu(QStringLiteral("不透明度"));
aOp100 = opMenu->addAction(QStringLiteral("100%"));
aOpPlus50 = opMenu->addAction(QStringLiteral("三维体+50%"));
aOpFollow = opMenu->addAction(QStringLiteral("跟随三维体"));
}
QAction* aFace = menu.addAction(QStringLiteral("正视图")); QAction* aFace = menu.addAction(QStringLiteral("正视图"));
QAction* aFlip = menu.addAction(QStringLiteral("视图翻转")); QAction* aFlip = menu.addAction(QStringLiteral("视图翻转"));
QAction* aClose = menu.addAction(QStringLiteral("关闭")); QAction* aClose = menu.addAction(QStringLiteral("关闭"));
@ -521,27 +651,61 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
if (chosen == aFace) { interactionMgr->faceSelected(); return; } if (chosen == aFace) { interactionMgr->faceSelected(); return; }
if (chosen == aFlip) { interactionMgr->flipView(); return; } if (chosen == aFlip) { interactionMgr->flipView(); return; }
if (chosen == aClose) { interactionMgr->closeSelected(); return; } // →onSliceClosed→取消列表勾选 if (chosen == aClose) { interactionMgr->closeSelected(); return; } // →onSliceClosed→取消列表勾选
if (chosen == aAnomaly) { if (aOp100 && chosen == aOp100) {
// 在选中切片平面上启动圈定(左键逐点、右键/回车闭合、Esc 取消)。 interactionMgr->setSelectedSliceOpacity(geopro::render::interact::SliceOpacityMode::Full);
vtkWidget->update();
return;
}
if (aOpPlus50 && chosen == aOpPlus50) {
interactionMgr->setSelectedSliceOpacity(
geopro::render::interact::SliceOpacityMode::VolumePlus50);
vtkWidget->update();
return;
}
if (aOpFollow && chosen == aOpFollow) {
interactionMgr->setSelectedSliceOpacity(
geopro::render::interact::SliceOpacityMode::FollowVolume);
vtkWidget->update();
return;
}
if (chosen == aAnoPoint || chosen == aAnoLine || chosen == aAnoFace) {
// 形态(1点/2线/3面):同时决定绘制工具 mode、a.markType、对话框查平台类型的 remarkSourceType。
// core::AnomalyMarkType 与 remarkSourceType 同值(Point=1/Polyline=2/Polygon=3),用一个 shape 贯通。
namespace ri = geopro::render::interact; namespace ri = geopro::render::interact;
using DM = ri::AnomalyDrawTool::DrawMode;
const int shape = (chosen == aAnoPoint) ? 1 : (chosen == aAnoLine) ? 2 : 3;
const DM mode =
(chosen == aAnoPoint) ? DM::Point : (chosen == aAnoLine) ? DM::Line : DM::Face;
int axis = 3; int axis = 3;
ri::Vec3 o{}, p1{}, p2{}; ri::Vec3 o{}, p1{}, p2{};
if (!interactionMgr->selectedSlicePlane(axis, o, p1, p2)) return; if (!interactionMgr->selectedSlicePlane(axis, o, p1, p2)) return;
const ri::Vec3 e1{{p1[0] - o[0], p1[1] - o[1], p1[2] - o[2]}}; const ri::Vec3 e1{{p1[0] - o[0], p1[1] - o[1], p1[2] - o[2]}};
const ri::Vec3 e2{{p2[0] - o[0], p2[1] - o[1], p2[2] - o[2]}}; const ri::Vec3 e2{{p2[0] - o[0], p2[1] - o[1], p2[2] - o[2]}};
const ri::Vec3 normal = ri::normalize(ri::cross(e1, e2)); const ri::Vec3 normal = ri::normalize(ri::cross(e1, e2));
// 操作提示浮层(右上角):按形态显示结束方式;绘制结束/取消隐藏。
anomalyHint->setText(
shape == 1 ? QStringLiteral("标注点\n左键单击落点即完成\nEsc 取消")
: shape == 2
? QStringLiteral("标注线\n左键逐点 · 双击结束\nBackspace 撤点 · Esc 取消")
: QStringLiteral("标注面\n左键逐点 · 点回起点闭合\nBackspace 撤点 · Esc 取消"));
anomalyHint->adjustSize();
anomalyHint->move(vtkWidget->width() - anomalyHint->width() - 12, 12); // 右上角
anomalyHint->show();
anomalyHint->raise();
// 多体并发:异常挂到"选中切片所属体"(非 currentVolume无选中切片回退当前体。 // 多体并发:异常挂到"选中切片所属体"(非 currentVolume无选中切片回退当前体。
std::string volId = interactionMgr->selectedSliceVolumeDsId(); std::string volId = interactionMgr->selectedSliceVolumeDsId();
if (volId.empty()) volId = sceneView->currentVolumeDsId(); if (volId.empty()) volId = sceneView->currentVolumeDsId();
// 异常归属spec §8当前选中切片已保存selectedSliceDsId 非空)→挂该切片;临时切片→挂体。 // 异常归属spec §8当前选中切片已保存selectedSliceDsId 非空)→挂该切片;临时切片→挂体。
const std::string savedSliceId = interactionMgr->selectedSliceDsId(); const std::string savedSliceId = interactionMgr->selectedSliceDsId();
anomalyDrawTool->start( anomalyDrawTool->start(
o, normal, mode, o, normal,
[&window, sceneView, scene3dRepo, renderWindowPtr, refreshAnomalies, refreshAnalysis, [&window, &cmdRepo, &nav, interactionMgr, sceneView, scene3dRepo, renderWindowPtr,
volId, savedSliceId, normal, o](const std::vector<ri::Vec3>& worldPts) { refreshAnomalies, refreshAnalysis, volId, savedSliceId, normal, o, p1, p2, shape,
anomalyHint](const std::vector<ri::Vec3>& worldPts) {
anomalyHint->hide(); // 绘制结束 → 隐藏操作提示
// 草稿异常:先临时渲染(让用户在对话框前看到所画,且截图含异常)。 // 草稿异常:先临时渲染(让用户在对话框前看到所画,且截图含异常)。
geopro::core::Anomaly a; geopro::core::Anomaly a;
a.markType = geopro::core::AnomalyMarkType::Polygon; a.markType = static_cast<geopro::core::AnomalyMarkType>(shape);
a.remarkSourceId = a.remarkSourceId =
geopro::core::resolveAnomalyMount(!savedSliceId.empty(), savedSliceId, volId); geopro::core::resolveAnomalyMount(!savedSliceId.empty(), savedSliceId, volId);
a.lineColor = "#ff3030"; a.lineColor = "#ff3030";
@ -554,12 +718,40 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
a.id = draftId; a.id = draftId;
sceneView->addAnomaly(a); sceneView->addAnomaly(a);
renderWindowPtr->Render(); renderWindowPtr->Render();
// 截图(含异常)→ 临时文件。
const QString shot = const QString shot =
QDir(QDir::tempPath()).filePath(QStringLiteral("geopro_anomaly_shot.png")); QDir(QDir::tempPath()).filePath(QStringLiteral("geopro_anomaly_shot.png"));
int sw = 0, sh = 0; int sw = 0, sh = 0;
geopro::app::captureRenderWindowPng(renderWindowPtr, shot.toStdString(), sw, sh); // 截图(正确做法):只从切片那张 2D 剖面彩图、按异常几何向外缓冲一圈裁剪(GIS buffer+掩膜)。
geopro::app::AnomalySaveDialog dlg(shot, sw, sh, &window); std::vector<std::array<double, 3>> wpts;
wpts.reserve(worldPts.size());
for (const auto& p : worldPts) wpts.push_back({p[0], p[1], p[2]});
vtkSmartPointer<vtkImageData> sliceColor = interactionMgr->selectedSliceColorImage();
const double oo[3] = {o[0], o[1], o[2]};
const double pp1[3] = {p1[0], p1[1], p1[2]};
const double pp2[3] = {p2[0], p2[1], p2[2]};
bool shotOk = sliceColor && geopro::app::captureAnomalyShotFromSlice(
sliceColor, oo, pp1, pp2, wpts, shape,
a.lineColor, shot.toStdString(), sw, sh);
if (!shotOk) { // 回退:无切片图时退回相机框景(整窗外扩),至少有图。
double rb[6] = {worldPts[0][0], worldPts[0][0], worldPts[0][1],
worldPts[0][1], worldPts[0][2], worldPts[0][2]};
for (const auto& p : worldPts) {
rb[0] = std::min(rb[0], p[0]); rb[1] = std::max(rb[1], p[0]);
rb[2] = std::min(rb[2], p[1]); rb[3] = std::max(rb[3], p[1]);
rb[4] = std::min(rb[4], p[2]); rb[5] = std::max(rb[5], p[2]);
}
auto vlen = [](double x, double y, double z) {
return std::sqrt(x * x + y * y + z * z);
};
const double e1 = vlen(p1[0] - o[0], p1[1] - o[1], p1[2] - o[2]);
const double e2 = vlen(p2[0] - o[0], p2[1] - o[1], p2[2] - o[2]);
const double minExt = 0.25 * std::min(e1, e2);
geopro::app::captureFramedRegionPng(renderWindowPtr, rb, 1.4, minExt,
shot.toStdString(), sw, sh);
}
// 异常类型按标注形态(shape=1点/2线/3面)拉对应平台类型,与平台一致。
geopro::app::AnomalySaveDialog dlg(shot, sw, sh, &cmdRepo,
nav.currentProjectId(), shape, &window);
if (dlg.exec() != QDialog::Accepted) { if (dlg.exec() != QDialog::Accepted) {
sceneView->removeAnomaly(draftId); sceneView->removeAnomaly(draftId);
renderWindowPtr->Render(); renderWindowPtr->Render();
@ -570,6 +762,12 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
a.typeName = dlg.typeName().toStdString(); a.typeName = dlg.typeName().toStdString();
a.exceptionTypeId = dlg.typeId().toStdString(); a.exceptionTypeId = dlg.typeId().toStdString();
a.remark = dlg.remark().toStdString(); a.remark = dlg.remark().toStdString();
// 平台样式:选中异常类型的 legend 派生(与平台一致);未取到则保留上面的默认样式。
if (!dlg.styleColor().isEmpty()) {
a.lineColor = dlg.styleColor().toStdString();
if (dlg.styleWidth() > 0.0) a.lineWidth = dlg.styleWidth();
a.dashed = dlg.styleDashed();
}
scene3dRepo->saveAnomaly( scene3dRepo->saveAnomaly(
a, shot.toStdString(), a, shot.toStdString(),
[sceneView, renderWindowPtr, refreshAnomalies, refreshAnalysis, [sceneView, renderWindowPtr, refreshAnomalies, refreshAnalysis,
@ -584,10 +782,10 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
QString::fromStdString(m)); QString::fromStdString(m));
}); });
}, },
[]() { /* onCancel放弃无需处理 */ }); [anomalyHint]() { anomalyHint->hide(); }); // 取消(Esc)→隐藏提示
return; return;
} }
if (chosen == aSave) { if (aSave != nullptr && chosen == aSave) {
int axis = 3; int axis = 3;
geopro::render::interact::Vec3 o{}, p1{}, p2{}; geopro::render::interact::Vec3 o{}, p1{}, p2{};
if (!interactionMgr->selectedSlicePlane(axis, o, p1, p2)) return; if (!interactionMgr->selectedSlicePlane(axis, o, p1, p2)) return;
@ -621,10 +819,14 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
QLineEdit::Normal, QLineEdit::Normal,
QStringLiteral("切片"), &ok); QStringLiteral("切片"), &ok);
if (!ok) return; if (!ok) return;
// 保存时快照切片自己的色阶对象:颜色继承当时三维体 + 不透明度取当前具体值(并入 globalOpacity
const geopro::core::ColorScale sliceCs =
interactionMgr->selectedSliceColorScaleSnapshot();
scene3dRepo->createSlice( scene3dRepo->createSlice(
spec, name.isEmpty() ? std::string("切片") : name.toStdString(), spec, name.isEmpty() ? std::string("切片") : name.toStdString(),
[interactionMgr, refreshAnalysis, drawer](std::string newId) { [interactionMgr, refreshAnalysis, drawer, scene3dRepo, sliceCs](std::string newId) {
interactionMgr->tagSelectedSlice(newId); // 链接当前切片 → 新数据集(不重绘) interactionMgr->tagSelectedSlice(newId); // 链接当前切片 → 新数据集(不重绘)
scene3dRepo->setSliceColorScale(newId, sliceCs); // 存切片独立色阶mock
refreshAnalysis(); // 新行进列表 refreshAnalysis(); // 新行进列表
// 新切片自动勾选 → 列表打勾 + 保持渲染refreshAnalysis 已重建列表,故在其后勾选)。 // 新切片自动勾选 → 列表打勾 + 保持渲染refreshAnalysis 已重建列表,故在其后勾选)。
if (auto* sec = drawer->analysisTab()->section("voxel")) if (auto* sec = drawer->analysisTab()->section("voxel"))
@ -676,6 +878,7 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
&geopro::controller::VtkSceneController::zoomOut); &geopro::controller::VtkSceneController::zoomOut);
QObject::connect(viewToolbar, &geopro::app::VtkViewToolbar::fitRequested, sceneCtrl, QObject::connect(viewToolbar, &geopro::app::VtkViewToolbar::fitRequested, sceneCtrl,
&geopro::controller::VtkSceneController::fit); &geopro::controller::VtkSceneController::fit);
// (三维体不透明度已移交色阶「不透明度」;旧工具条「透」滑块退役。)
// 设置(⚙)→ 工具条右侧 toggle 抽屉面板(非模态弹窗)。 // 设置(⚙)→ 工具条右侧 toggle 抽屉面板(非模态弹窗)。
QObject::connect(viewToolbar, &geopro::app::VtkViewToolbar::axesSettingsRequested, &window, QObject::connect(viewToolbar, &geopro::app::VtkViewToolbar::axesSettingsRequested, &window,
[axesPanel, viewToolbar]() { [axesPanel, viewToolbar]() {
@ -717,8 +920,8 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
}); });
// 段头「+新增三维体」:弹参数对话框 → 组装真实 VoxelGenerateRequest → createVolume(mock) → 刷新。 // 段头「+新增三维体」:弹参数对话框 → 组装真实 VoxelGenerateRequest → createVolume(mock) → 刷新。
QObject::connect(analysisTab, &geopro::app::CategoryAnalysisTab::generateVolumeRequested, &window, QObject::connect(analysisTab, &geopro::app::CategoryAnalysisTab::generateVolumeRequested, &window,
[&window, &nav, scene3dRepo, refreshAnalysis, lastSourceRows, [&window, &nav, scene3dRepo, refreshAnalysis, lastSourceRows, lastStructNodes,
lastStructNodes](const QString& /*dsTypeCode*/, const QStringList& sourceIds) { analysisTab, vtkLoading](const QString& /*dsTypeCode*/, const QStringList& sourceIds) {
if (sourceIds.isEmpty()) return; if (sourceIds.isEmpty()) return;
// 源 dsid,名称,结构归属):名称/structParentId 从最近拉取的行查(缺则用 id // 源 dsid,名称,结构归属):名称/structParentId 从最近拉取的行查(缺则用 id
QVector<geopro::app::VolumeSourceItem> sources; QVector<geopro::app::VolumeSourceItem> sources;
@ -754,8 +957,47 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
req.power = p.power; req.power = p.power;
req.maxDist = p.maxDist; req.maxDist = p.maxDist;
req.colorScaleId = p.colorScaleId; req.colorScaleId = p.colorScaleId;
scene3dRepo->createVolume(req); // 保存(目前 mock瞬时同步):直接建体+入树不弹等待蒙版——mock 没有耗时,蒙版
refreshAnalysis(); // 会 showOver 后立刻 hide 在渲染区一闪而过。等待蒙版只在「真有耗时的后端保存」
// 才有意义:接真实异步保存端点后,在那个调用外用 LoadingOverlay 包蒙版即可。
// 保存阶段VTK 整体加等待蒙版(挂 centerWidget→盖整个 VTK 子视图)。singleShot 让
// 蒙版先绘出再干活,避免 mock 即时完成时蒙版根本没画出来。
vtkLoading->showOver(QStringLiteral("正在保存三维体…"));
QTimer::singleShot(0, &window, [=]() {
const std::string newId = scene3dRepo->createVolume(req); // 保存 + 注册(mock)
{
// refreshAnalysis 重建列表会让各段重发"勾选变化"→ 触发场景重算 → 已渲染
// 剖面被删了又加。保存时屏蔽 analysisTab 渲染信号 → 渲染区一动不动。
const QSignalBlocker block(analysisTab);
refreshAnalysis(); // 入三维体树(仅刷列表,不触发渲染)
}
vtkLoading->hide(); // 保存阶段结束 → 撤蒙版
const QString qid = QString::fromStdString(newId);
// 渲染阶段无蒙版:自动勾选新体 → 增量加体(剖面不动) + 标题前等待 spinner
// 渲染完成由 volumeRendered 撤 spinner、失败由 loadFailed 兜底。
analysisTab->setItemChecked(qid, true);
analysisTab->setItemBusy(qid, true);
analysisTab->scrollItemToTop(qid); // 新三维体行尽量滚到分析栏顶部
});
});
// 任一数据集(剖面/体)异步加载开始 → 列表项复选框转等待 spinner渲染完成 → 复原复选框。
// 覆盖非三维体:勾选剖面首次渲染较慢时也有等待反馈(用户反馈)。
QObject::connect(sceneCtrl, &geopro::controller::VtkSceneController::datasetLoading, analysisTab,
[analysisTab](const QString& dsId) { analysisTab->setItemBusy(dsId, true); });
QObject::connect(sceneCtrl, &geopro::controller::VtkSceneController::datasetRendered, analysisTab,
[analysisTab](const QString& dsId) { analysisTab->setItemBusy(dsId, false); });
// 根因修复异步建体的渲染发生在后台线程触发的投递事件里renderWindow->Render() 渲到离屏 FBO
// 但 QVTKOpenGLStereoWidget 把 FBO「呈现到屏」绑定 Qt 的 paint建体完成后 app 空闲、无后续 paint
// FBO 渲好却没贴到屏 → 体偶发不可见(动鼠标产生 paint 才出来)。显式请求一次 Qt 重绘补上呈现步骤。
QObject::connect(sceneCtrl, &geopro::controller::VtkSceneController::volumeRendered, vtkWidget,
[vtkWidget](const QString&) { vtkWidget->update(); });
// 加载失败 → 兜底撤回所有 spinner避免标题卡在等待态
QObject::connect(sceneCtrl, &geopro::controller::VtkSceneController::loadFailed, analysisTab,
[analysisTab, &window](const QString& msg) {
analysisTab->clearAllBusy();
// 明确提示而非静默:源数据加载失败(如后端 502)时用户能区分"后端没给数据"与"渲染问题"。
geopro::app::showToast(
&window, QStringLiteral("数据加载失败,未生成三维体:%1").arg(msg));
}); });
// 双击数据详情dd_slice→切片属性dd_voxel→三维体属性同 colAnalysis 详情口径)。 // 双击数据详情dd_slice→切片属性dd_voxel→三维体属性同 colAnalysis 详情口径)。
QObject::connect(analysisTab, &geopro::app::CategoryAnalysisTab::detailRequested, &window, QObject::connect(analysisTab, &geopro::app::CategoryAnalysisTab::detailRequested, &window,
@ -783,9 +1025,20 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
// 三维体段右键删除切片→deleteSlice / 异常→deleteAnomaly删后刷新树。 // 三维体段右键删除切片→deleteSlice / 异常→deleteAnomaly删后刷新树。
// 异常删除须同时 refreshAnomalies重载异常 actor——否则列表行没了但场景里异常仍渲染技术债已修 // 异常删除须同时 refreshAnomalies重载异常 actor——否则列表行没了但场景里异常仍渲染技术债已修
QObject::connect(analysisTab, &geopro::app::CategoryAnalysisTab::deleteDatasetRequested, &window, QObject::connect(analysisTab, &geopro::app::CategoryAnalysisTab::deleteDatasetRequested, &window,
[scene3dRepo, refreshAnalysis, refreshAnomalies](const QString& dsId, [scene3dRepo, refreshAnalysis, refreshAnomalies, &window](const QString& dsId,
const QString& ddCode) { const QString& ddCode) {
const std::string id = dsId.toStdString(); const std::string id = dsId.toStdString();
// 删除前确认(不可撤销):明确中文「删除/取消」按钮。
const QString what =
ddCode == QStringLiteral("dd_slice") ? QStringLiteral("切片")
: QStringLiteral("异常");
QMessageBox box(QMessageBox::Warning, QStringLiteral("删除%1").arg(what),
QStringLiteral("确定删除该%1吗此操作不可撤销。").arg(what),
QMessageBox::NoButton, &window);
QPushButton* del = box.addButton(QStringLiteral("删除"), QMessageBox::AcceptRole);
box.addButton(QStringLiteral("取消"), QMessageBox::RejectRole);
box.exec();
if (box.clickedButton() != del) return; // 取消 → 不删
if (ddCode == QStringLiteral("dd_slice")) { if (ddCode == QStringLiteral("dd_slice")) {
scene3dRepo->deleteSlice( scene3dRepo->deleteSlice(
id, [refreshAnalysis]() { refreshAnalysis(); }, id, [refreshAnalysis]() { refreshAnalysis(); },
@ -883,8 +1136,30 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
// 色阶(三维体/切片):复刻原版「色阶配置」对话框,确定后体素 + 其切片随新色阶重渲染。 // 色阶(三维体/切片):复刻原版「色阶配置」对话框,确定后体素 + 其切片随新色阶重渲染。
// 仅对当前已渲染的三维体生效(切片色阶继承体色阶,经 InteractionManager 重建)。 // 仅对当前已渲染的三维体生效(切片色阶继承体色阶,经 InteractionManager 重建)。
QObject::connect(analysisTab, &geopro::app::CategoryAnalysisTab::colorScaleRequested, &window, QObject::connect(analysisTab, &geopro::app::CategoryAnalysisTab::colorScaleRequested, &window,
[&window, &colorTplRepo, &nav, sceneCtrl, sceneView](const QString& qid) { [&window, &colorTplRepo, &nav, sceneCtrl, sceneView, scene3dRepo,
interactionMgr](const QString& qid) {
const std::string dsId = qid.toStdString(); const std::string dsId = qid.toStdString();
// 已保存切片dd_slice→ 编辑切片自己的色阶(颜色+不透明度),不走三维体路径。
if (scene3dRepo->isSliceDataset(dsId)) {
geopro::core::ColorScale scs;
if (!scene3dRepo->sliceColorScale(dsId, scs)) {
QMessageBox::information(&window, QStringLiteral("色阶"),
QStringLiteral("该切片暂无独立色阶。"));
return;
}
const auto stops = scs.stopValues();
const double vmin = stops.empty() ? 0.0 : stops.front();
const double vmax = stops.empty() ? 1.0 : stops.back();
geopro::app::ColorScaleConfigDialog dlg(scs, vmin, vmax, {}, {},
&colorTplRepo,
nav.currentProjectId(), QString(),
&window);
if (dlg.exec() != QDialog::Accepted) return;
const auto newCs = dlg.colorScale();
scene3dRepo->setSliceColorScale(dsId, newCs); // 存切片独立色阶mock
interactionMgr->setSliceColorScaleByDsId(dsId, newCs); // 若在渲染则即时改色
return;
}
// 多体并发:编辑"该体"(任一已渲染体,不限当前体)的色阶。 // 多体并发:编辑"该体"(任一已渲染体,不限当前体)的色阶。
const auto* vol = sceneView->volume(dsId); const auto* vol = sceneView->volume(dsId);
if (!vol) { if (!vol) {
@ -932,19 +1207,35 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
[sceneView, interactionMgr, renderWindowPtr](const QString& dsId, [sceneView, interactionMgr, renderWindowPtr](const QString& dsId,
const QString& ddCode) { const QString& ddCode) {
const std::string id = dsId.toStdString(); const std::string id = dsId.toStdString();
if (ddCode == QStringLiteral("dd_anomaly")) // 选中项决定高亮:异常↔切片互斥,选其它对象两者都清(否则切到别的对象后切片/异常仍高亮)。
if (ddCode == QStringLiteral("dd_anomaly")) {
sceneView->setSelectedAnomaly(id); sceneView->setSelectedAnomaly(id);
else if (ddCode == QStringLiteral("dd_slice")) interactionMgr->deselectSlice();
} else if (ddCode == QStringLiteral("dd_slice")) {
sceneView->setSelectedAnomaly(std::string{});
interactionMgr->selectSavedSlice(id); // 选中已渲染的该切片(高亮) interactionMgr->selectSavedSlice(id); // 选中已渲染的该切片(高亮)
} else {
sceneView->setSelectedAnomaly(std::string{});
interactionMgr->deselectSlice();
}
renderWindowPtr->Render(); renderWindowPtr->Render();
}); });
// 反向 VTK→list在 VTK 里点中/选中一张切片 → 在三维体段树里同步选中该切片行(②反向)。 // 反向 VTK→list在 VTK 里点中/选中一张切片 → 在三维体段树里同步选中该切片行(②反向)。
interactionMgr->onSliceSelectionChanged = [drawer](const std::string& dsId) { // 点空白/清选(dsId 空) → 一并清 VTK 异常高亮(否则取消选中后异常图形仍高亮,用户反馈)。
interactionMgr->onSliceSelectionChanged = [drawer, sceneView, renderWindowPtr](
const std::string& dsId) {
if (auto* sec = drawer->analysisTab()->section("voxel")) if (auto* sec = drawer->analysisTab()->section("voxel"))
sec->selectItem(QString::fromStdString(dsId)); sec->selectItem(QString::fromStdString(dsId));
if (dsId.empty()) {
sceneView->setSelectedAnomaly(std::string{});
renderWindowPtr->Render();
}
};
// 已保存切片经 VTK 右键「关闭」→ 取消数据列表对应切片项的勾选(否则列表仍勾选,用户反馈)。
interactionMgr->onSliceClosed = [drawer](const std::string& dsId) {
if (auto* sec = drawer->analysisTab()->section("voxel"))
sec->setChecked(QString::fromStdString(dsId), false);
}; };
// 异常双击属性(R83)/右键删除已并入 analysisTab 的 detailRequested(dd_anomaly) /
// deleteDatasetRequested(dd_anomaly)列表选中→VTK高亮(R84)随旧栏退役暂缺,待新段补 anomalySelected。
// ── 二维数据集栏:天地图底图开关(③,复用轨迹图 token经同一共享 GeoLocalFrame 配准)── // ── 二维数据集栏:天地图底图开关(③,复用轨迹图 token经同一共享 GeoLocalFrame 配准)──
auto* basemap = new geopro::app::TileBasemap(*scene, renderWindowPtr, frame, &window); auto* basemap = new geopro::app::TileBasemap(*scene, renderWindowPtr, frame, &window);
@ -980,6 +1271,19 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
if (*view2dMode == 4) sceneCtrl->set2DPlacement(4, z); if (*view2dMode == 4) sceneCtrl->set2DPlacement(4, z);
}); });
// ── 二维分析改造 A 期:切「三维分析/二维分析」tab → 一场景两相机 ──────────────────
// 三处协作:①切片隐藏+交互锁(仅平移+缩放) [InteractionManager];②按目标维度重置取景基线
// [VtkSceneController]——使切换后该维度首条数据自动取景;③维度显隐+近俯视/自由相机+取景+坐标轴+
// 渲染 [VtkSceneView]。顺序:先 ①②(都不渲染),最后 ③ 收尾统一渲染。只翻可见标志、不清空/重建 →
// 切换瞬时;地形+底图常驻。
QObject::connect(drawer, &geopro::app::ColumnDrawer::analysisModeChanged, &window,
[interactionMgr, sceneCtrl, sceneView, viewToolbar](bool is2D) {
interactionMgr->setMode2D(is2D);
sceneCtrl->onAnalysisModeChanged(is2D);
sceneView->setAnalysisMode2D(is2D);
viewToolbar->setAnalysisMode2D(is2D); // 二维下禁用 6 向快捷视图
});
// 首个真实剖面到达 → frame 重锚到数据 lat/lon 后,把选中的底图加载到数据所在位置 // 首个真实剖面到达 → frame 重锚到数据 lat/lon 后,把选中的底图加载到数据所在位置
// (默认天地图即在此刻出现,避免启动时在样本原点拉无关瓦片)。 // (默认天地图即在此刻出现,避免启动时在样本原点拉无关瓦片)。
sceneView->onFrameReanchored = [basemap, basemapKind]() { sceneView->onFrameReanchored = [basemap, basemapKind]() {
@ -1022,7 +1326,10 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
// ── 中央“空状态”引导浮层:未接入真实 sections 时,引导首次使用者从左侧入手。── // ── 中央“空状态”引导浮层:未接入真实 sections 时,引导首次使用者从左侧入手。──
// 透明背景 + 鼠标穿透(不挡 QVTK 交互CenterOverlay 随视口尺寸保持居中; // 透明背景 + 鼠标穿透(不挡 QVTK 交互CenterOverlay 随视口尺寸保持居中;
// 接入真实中央数据后改成依 sections 是否为空调 setVisible 即可。 // 接入真实中央数据后改成依 sections 是否为空调 setVisible 即可。
auto* emptyState = new QFrame(centerWidget); // 挂在 vtkWidget 下(而非 centerWidget使其与工具条/提示同属 vtkWidget 子级CenterOverlay 会把它
// 压到子级最底(在 GL 之上、工具条/提示之下)→ 工具条永远在最上层、引导层在最下层(修视图缩小时
// 引导层挡住工具条)。
auto* emptyState = new QFrame(vtkWidget);
emptyState->setObjectName(QStringLiteral("centralEmpty")); emptyState->setObjectName(QStringLiteral("centralEmpty"));
emptyState->setAttribute(Qt::WA_TransparentForMouseEvents); emptyState->setAttribute(Qt::WA_TransparentForMouseEvents);
// 背景取 canvas/bg(#0B1320)——与画布同色:在原生 GL 上覆盖透明无法生效(会回退成不透明浅底), // 背景取 canvas/bg(#0B1320)——与画布同色:在原生 GL 上覆盖透明无法生效(会回退成不透明浅底),
@ -1065,16 +1372,24 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
esLay->addWidget(esHint); esLay->addWidget(esHint);
auto* emptyCentering = new CenterOverlay(emptyState, vtkWidget); auto* emptyCentering = new CenterOverlay(emptyState, vtkWidget);
// 引导层定位后,把工具条与提示浮层 raise 到其上 → 工具条永在最上层(修:缩小渲染区时引导层挡工具条)。
emptyCentering->setRaiseAfter({viewToolbar, anomalyHint, elevHint});
emptyCentering->reposition(); emptyCentering->reposition();
auto* vtkDock = new ads::CDockWidget(QStringLiteral("VTK视图")); auto* vtkDock = new ads::CDockWidget(QStringLiteral("VTK视图"));
vtkDock->setWidget(centerWidget); vtkDock->setWidget(centerWidget);
auto* centerDockArea = dockManager->addDockWidget(ads::CenterDockWidgetArea, vtkDock); auto* centerDockArea = dockManager->addDockWidget(ads::CenterDockWidgetArea, vtkDock);
// 项目管理「直接嵌入」web 页:作为中央 QStackedWidget 的第二页,整窗加载(覆盖整个工作台)。
// 单实例复用——点不同菜单项时重新 loadtoken 已注入页面 localStorage。
auto* projectWebView = new geopro::app::ProjectWebView(sessionToken);
centralStack->addWidget(projectWebView); // index 1项目管理 web 整窗
// ── 下方「数据详情」dock平面图表多 Tab 面板QGraphicsViewVTK 仅算几何)── // ── 下方「数据详情」dock平面图表多 Tab 面板QGraphicsViewVTK 仅算几何)──
// 单击数据集 → 聚焦已开页;双击 → 新建/聚焦页(真实反演剖面/散点/异常/色阶)。 // 单击数据集 → 聚焦已开页;双击 → 新建/聚焦页(真实反演剖面/散点/异常/色阶)。
auto* detailPanel = new geopro::app::DatasetDetailPanel(); auto* detailPanel = new geopro::app::DatasetDetailPanel();
// 注入色阶模板仓储 + 当前 projectId 取值回调(网格剖面「色阶配置」编辑器的另存/打开/新建色阶)。 // 注入色阶模板仓储 + 当前 projectId 取值回调(网格剖面「色阶配置」编辑器的另存/打开/新建色阶)。
detailPanel->setViewState(viewState); // 跨视图色阶真源2D↔3D 同步)
detailPanel->setColorTemplateRepo(&colorTplRepo, [&nav] { return nav.currentProjectId(); }); detailPanel->setColorTemplateRepo(&colorTplRepo, [&nav] { return nav.currentProjectId(); });
// 注入反演命令仓储measurement 反演运算/生成视电阻率。projectId 取值仍由页内 projectIdGetter 提供。 // 注入反演命令仓储measurement 反演运算/生成视电阻率。projectId 取值仍由页内 projectIdGetter 提供。
detailPanel->setCommandRepo(&cmdRepo); detailPanel->setCommandRepo(&cmdRepo);
@ -1320,12 +1635,32 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
QObject::connect(topBar, &geopro::app::TopBar::workspaceSwitchRequested, &nav, QObject::connect(topBar, &geopro::app::TopBar::workspaceSwitchRequested, &nav,
&geopro::controller::WorkbenchNavController::switchWorkspace); &geopro::controller::WorkbenchNavController::switchWorkspace);
// 项目管理「直接嵌入」web 页:拼当前项目的嵌入 URL在中央区整窗加载切到 web 页)。
// space=3 为「项目空间(projectSpace)」固定常量——Excel 所有 projectSpace 页均 space=3
// 与租户/工作空间 id 无关(误用工作空间 snowflake 会被后端拒space 参数无效)。
QObject::connect(
topBar, &geopro::app::TopBar::webPageRequested, &window,
[projectWebView, centralStack, &nav](const QString& /*title*/, const QString& target) {
const QString url =
QStringLiteral("http://tenant.geomative.cn/#/embed?space=3&projectId=%1&target=%2")
.arg(nav.currentProjectId(), target);
projectWebView->load(url);
centralStack->setCurrentWidget(projectWebView); // 整窗切到 web 页
});
// 视图菜单「分析视图」:中央区切回工作台(默认视图)。
QObject::connect(topBar, &geopro::app::TopBar::analysisViewRequested, &window,
[centralStack, dockManager]() {
centralStack->setCurrentWidget(dockManager);
});
// 切换到「不同项目」时先清空中央区,避免新项目残留旧项目的三栏数据与 VTK 渲染。 // 切换到「不同项目」时先清空中央区,避免新项目残留旧项目的三栏数据与 VTK 渲染。
// 仅真正换项目用delete-refresh 等 switchProject(currentProjectId) 不走此处,避免误清)。 // 仅真正换项目用delete-refresh 等 switchProject(currentProjectId) 不走此处,避免误清)。
auto clearCentral = [sceneCtrl, emptyState, checkedProfiles, checkedAnalysis, auto clearCentral = [sceneCtrl, emptyState, checkedProfiles, checkedAnalysis,
pushChecked, lastSourceRows, refreshAnalysis, checkedSliceIds, pushChecked, lastSourceRows, refreshAnalysis, checkedSliceIds,
syncSlices, basemap, sceneView]() { syncSlices, basemap, sceneView, scene3dRepo]() {
// 数据源清空 → 5 段 + col2D 清空refreshAnalysis 内 setBuckets/dim2D客户端三维体仍驻留 // 切项目:先清内存态三维体/切片/异常(否则上个项目的产物残留进新项目列表),再 refreshAnalysis。
scene3dRepo->clearMockData();
// 数据源清空 → 5 段 + col2D 清空refreshAnalysis 内 setBuckets/dim2D
*lastSourceRows = {}; *lastSourceRows = {};
refreshAnalysis(); refreshAnalysis();
// 勾选集清空并下发空到 VTK帘面/体素/切片/2D 足迹全部撤场)。 // 勾选集清空并下发空到 VTK帘面/体素/切片/2D 足迹全部撤场)。
@ -1897,6 +2232,17 @@ public:
return false; return false;
} }
}; };
// VTK 警告/错误输出窗口:转 Qt 日志qWarning不弹独立窗口并把 VTK 报错落进 geopro 日志便于排查。
class QtVtkOutputWindow : public vtkOutputWindow {
public:
static QtVtkOutputWindow* New();
vtkTypeMacro(QtVtkOutputWindow, vtkOutputWindow);
void DisplayText(const char* txt) override {
if (txt && *txt) qWarning().noquote() << "[vtk]" << QString::fromUtf8(txt).trimmed();
}
};
vtkStandardNewMacro(QtVtkOutputWindow);
} // namespace } // namespace
int main(int argc, char* argv[]) int main(int argc, char* argv[])
@ -1916,10 +2262,28 @@ int main(int argc, char* argv[])
QSurfaceFormat::setDefaultFormat(QVTKOpenGLStereoWidget::defaultFormat()); QSurfaceFormat::setDefaultFormat(QVTKOpenGLStereoWidget::defaultFormat());
GuardedApplication app(argc, argv); // 顶层异常护栏slot/事件里的异常不致客户端崩溃 GuardedApplication app(argc, argv); // 顶层异常护栏slot/事件里的异常不致客户端崩溃
// VTK 警告/错误转 Qt 日志qWarning → geopro 日志),彻底不弹独立 vtkOutputWindow 窗口
// Windows 默认 vtkWin32OutputWindow 不认 DisplayMode仍会弹空窗故直接替换实例
// 同时把 VTK 报错落进日志,便于排查(如体绘制偶发不渲染的真因)。
{
auto* vtkOut = QtVtkOutputWindow::New();
vtkOutputWindow::SetInstance(vtkOut);
vtkOut->Delete(); // SetInstance 已持引用
}
// 异步 ApiCall::finished 等信号携带 ApiResponse注册元类型以支持跨 QueuedConnection 传递 // 异步 ApiCall::finished 等信号携带 ApiResponse注册元类型以支持跨 QueuedConnection 传递
// (当前详情链路为同线程 DirectConnection非严格必需但作防御性注册见 spec §5.1)。 // (当前详情链路为同线程 DirectConnection非严格必需但作防御性注册见 spec §5.1)。
qRegisterMetaType<geopro::net::ApiResponse>(); qRegisterMetaType<geopro::net::ApiResponse>();
// Qt 标准控件文案中文化:安装 Qt 自带 zh_CN 翻译 → QMessageBox/QDialogButtonBox/QFileDialog/
// QColorDialog 等的 OK/Cancel/Yes/No/Save… 全局显示中文。translator 须存活至程序结束(放 main 栈)。
QTranslator qtZhTranslator;
const QString appTr = QCoreApplication::applicationDirPath() + QStringLiteral("/translations");
if (qtZhTranslator.load(QStringLiteral("qtbase_zh_CN"), appTr) || // 部署版(exe 旁)
qtZhTranslator.load(QStringLiteral("qtbase_zh_CN"),
QLibraryInfo::path(QLibraryInfo::TranslationsPath))) // dev(Qt 安装)
app.installTranslator(&qtZhTranslator);
// 组织/应用名QSettings 持久化dock 布局、登录记忆等)按此定位存储位置。 // 组织/应用名QSettings 持久化dock 布局、登录记忆等)按此定位存储位置。
QCoreApplication::setOrganizationName(QStringLiteral("Geomative")); QCoreApplication::setOrganizationName(QStringLiteral("Geomative"));
QCoreApplication::setApplicationName(QStringLiteral("Geopro3")); QCoreApplication::setApplicationName(QStringLiteral("Geopro3"));
@ -2030,7 +2394,7 @@ int main(int argc, char* argv[])
// 而是给出可见错误提示。否则登录后窗口未显示、进程消失,用户无从排查。 // 而是给出可见错误提示。否则登录后窗口未显示、进程消失,用户无从排查。
try { try {
buildWorkbench(*window, repo, projectRepo, datasetRepo, colorTplRepo, cmdRepo, nav, buildWorkbench(*window, repo, projectRepo, datasetRepo, colorTplRepo, cmdRepo, nav,
detailCtrl); detailCtrl, token);
} catch (const std::exception& e) { } catch (const std::exception& e) {
QMessageBox::critical( QMessageBox::critical(
nullptr, QStringLiteral("启动失败"), nullptr, QStringLiteral("启动失败"),

View File

@ -51,7 +51,8 @@ void DatasetDetailPage::build(const QString& dsId, const QString& ddCode, const
// dsIdGetter 用本页 dsId_此处已赋值随项目/数据集稳定。 // dsIdGetter 用本页 dsId_此处已赋值随项目/数据集稳定。
auto view = makeDetailView(spec.kind, this, colorTplRepo_, projectIdGetter_, cmdRepo_, auto view = makeDetailView(spec.kind, this, colorTplRepo_, projectIdGetter_, cmdRepo_,
[this] { return dsId_; }, [this] { return dsId_; },
[this] { return tmObjectId_; }); // 抛出由调用栈兜底GuardedApplication [this] { return tmObjectId_; },
viewState_); // 抛出由调用栈兜底GuardedApplication
IDetailView* raw = view.release(); // QWidget 由 this/QwtPlot 父子树接管生命周期 IDetailView* raw = view.release(); // QWidget 由 this/QwtPlot 父子树接管生命周期
views_[i] = raw; views_[i] = raw;
// lazy 页签:建覆盖该视图的加载遮罩(父为视图 widget随其尺寸覆盖图区 // lazy 页签:建覆盖该视图的加载遮罩(父为视图 widget随其尺寸覆盖图区

View File

@ -12,6 +12,10 @@ class IColorTemplateRepository;
class IDatasetCommandRepository; class IDatasetCommandRepository;
} }
namespace geopro::controller {
class DatasetViewState; // 跨视图色阶真源(统一同步)
}
namespace geopro::app { namespace geopro::app {
class IDetailView; class IDetailView;
@ -35,6 +39,9 @@ public:
// 所属 TM 对象 id=白化 structParentId注入须在 build 前设置 → tmObjectIdGetter 透传给视图)。 // 所属 TM 对象 id=白化 structParentId注入须在 build 前设置 → tmObjectIdGetter 透传给视图)。
void setTmObjectId(const QString& tmObjectId) { tmObjectId_ = tmObjectId; } void setTmObjectId(const QString& tmObjectId) { tmObjectId_ = tmObjectId; }
// 跨视图色阶真源注入(须在 build 前设置 → 透传给网格视图,实现 2D↔3D 色阶同步)。
void setViewState(geopro::controller::DatasetViewState* state) { viewState_ = state; }
// 按页签集构建页签首次打开调一次。dsId/ddCode/dsName 用于 tabNeeded。 // 按页签集构建页签首次打开调一次。dsId/ddCode/dsName 用于 tabNeeded。
void build(const QString& dsId, const QString& ddCode, const QString& dsName, void build(const QString& dsId, const QString& ddCode, const QString& dsName,
const std::vector<geopro::controller::TabSpec>& tabs); const std::vector<geopro::controller::TabSpec>& tabs);
@ -75,6 +82,8 @@ private:
// 反演命令仓储注入(透传给 makeDetailView → measurement 散点视图反演按钮)。 // 反演命令仓储注入(透传给 makeDetailView → measurement 散点视图反演按钮)。
geopro::data::IDatasetCommandRepository* cmdRepo_ = nullptr; geopro::data::IDatasetCommandRepository* cmdRepo_ = nullptr;
geopro::controller::DatasetViewState* viewState_ = nullptr; // 跨视图色阶真源(透传给网格视图)
}; };
} // namespace geopro::app } // namespace geopro::app

View File

@ -15,6 +15,10 @@ void DatasetDetailPanel::setCommandRepo(geopro::data::IDatasetCommandRepository*
cmdRepo_ = repo; cmdRepo_ = repo;
} }
void DatasetDetailPanel::setViewState(geopro::controller::DatasetViewState* state) {
viewState_ = state;
}
DatasetDetailPanel::DatasetDetailPanel(QWidget* parent) : QTabWidget(parent) { DatasetDetailPanel::DatasetDetailPanel(QWidget* parent) : QTabWidget(parent) {
setTabsClosable(true); setTabsClosable(true);
connect(this, &QTabWidget::tabCloseRequested, this, [this](int i) { widget(i)->deleteLater(); }); connect(this, &QTabWidget::tabCloseRequested, this, [this](int i) { widget(i)->deleteLater(); });
@ -40,6 +44,7 @@ void DatasetDetailPanel::onDatasetOpened(const QString& dsId, const QString& ddC
// 注入须在 build 前build 内造视图时即透传给工厂)。 // 注入须在 build 前build 内造视图时即透传给工厂)。
p->setColorTemplateRepo(colorTplRepo_, projectIdGetter_); p->setColorTemplateRepo(colorTplRepo_, projectIdGetter_);
p->setCommandRepo(cmdRepo_); p->setCommandRepo(cmdRepo_);
p->setViewState(viewState_); // 跨视图色阶真源build 前设置 → 透传给网格视图)
p->setTmObjectId(tmObjectId); // 白化 structParentIdbuild 前设置 → 透传给视图) p->setTmObjectId(tmObjectId); // 白化 structParentIdbuild 前设置 → 透传给视图)
p->build(dsId, ddCode, dsName, tabs); // ddCode 透传 → 页内 tabNeeded 携带 p->build(dsId, ddCode, dsName, tabs); // ddCode 透传 → 页内 tabNeeded 携带
const QString title = dsName.isEmpty() ? dsId : dsName; // 页签标题用数据名(空则回退 id const QString title = dsName.isEmpty() ? dsId : dsName; // 页签标题用数据名(空则回退 id

View File

@ -9,6 +9,9 @@ namespace geopro::data {
class IColorTemplateRepository; class IColorTemplateRepository;
class IDatasetCommandRepository; class IDatasetCommandRepository;
} }
namespace geopro::controller {
class DatasetViewState; // 跨视图色阶真源(统一同步)
}
namespace geopro::app { namespace geopro::app {
class DatasetDetailPage; class DatasetDetailPage;
@ -25,6 +28,9 @@ public:
// 反演命令仓储透传给每个新建的详情页measurement 反演运算/生成视电阻率用)。 // 反演命令仓储透传给每个新建的详情页measurement 反演运算/生成视电阻率用)。
void setCommandRepo(geopro::data::IDatasetCommandRepository* repo); void setCommandRepo(geopro::data::IDatasetCommandRepository* repo);
// 跨视图色阶真源:透传给每个新建的详情页 → 网格视图2D↔3D 色阶同步)。
void setViewState(geopro::controller::DatasetViewState* state);
// 数据集打开find-or-create 页 → build(tabs) → 加/抬该面板页签。 // 数据集打开find-or-create 页 → build(tabs) → 加/抬该面板页签。
// tmObjectId所属 TM 对象 id白化 structParentIdbuild 前交给页 → 视图。 // tmObjectId所属 TM 对象 id白化 structParentIdbuild 前交给页 → 视图。
void onDatasetOpened(const QString& dsId, const QString& ddCode, const QString& dsName, void onDatasetOpened(const QString& dsId, const QString& ddCode, const QString& dsName,
@ -51,5 +57,7 @@ private:
// 反演命令仓储注入(新页 build 前 setCommandRepo 透传)。 // 反演命令仓储注入(新页 build 前 setCommandRepo 透传)。
geopro::data::IDatasetCommandRepository* cmdRepo_ = nullptr; geopro::data::IDatasetCommandRepository* cmdRepo_ = nullptr;
geopro::controller::DatasetViewState* viewState_ = nullptr; // 跨视图色阶真源(透传给详情页)
}; };
} // namespace geopro::app } // namespace geopro::app

View File

@ -90,6 +90,17 @@ public:
const bool isContainer = idx.data(kDsDdCodeRole).toString() == QStringLiteral("container"); const bool isContainer = idx.data(kDsDdCodeRole).toString() == QStringLiteral("container");
if (checkable) { if (checkable) {
QRect checkRect(r.left() + 12, r.top() + (r.height() - box) / 2, box, box); QRect checkRect(r.left() + 12, r.top() + (r.height() - box) / 2, box, box);
if (idx.data(kDsBusyRole).toBool()) {
// 渲染中:复选框位置画旋转 spinner等待动画角度由 kDsSpinAngleRole 驱动)。
const int angle = idx.data(kDsSpinAngleRole).toInt();
p->save();
p->setRenderHint(QPainter::Antialiasing, true);
QPen pen(geopro::app::tokenColor("accent/primary"), 2.0);
pen.setCapStyle(Qt::RoundCap);
p->setPen(pen);
p->drawArc(QRectF(checkRect).adjusted(2, 2, -2, -2), -angle * 16, 270 * 16);
p->restore();
} else {
const auto cs = static_cast<Qt::CheckState>(idx.data(Qt::CheckStateRole).toInt()); const auto cs = static_cast<Qt::CheckState>(idx.data(Qt::CheckStateRole).toInt());
QStyleOptionViewItem o(opt); QStyleOptionViewItem o(opt);
o.rect = checkRect; o.rect = checkRect;
@ -98,6 +109,7 @@ public:
const QWidget* w = opt.widget; const QWidget* w = opt.widget;
QStyle* st = w ? w->style() : QApplication::style(); QStyle* st = w ? w->style() : QApplication::style();
st->drawPrimitive(QStyle::PE_IndicatorItemViewItemCheck, &o, p, w); st->drawPrimitive(QStyle::PE_IndicatorItemViewItemCheck, &o, p, w);
}
textLeftPad = 12 + box + 8; // 复选框右侧留白后再放文本 textLeftPad = 12 + box + 8; // 复选框右侧留白后再放文本
} else if (isContainer) { } else if (isContainer) {
// 容器文本左缘对齐子级复选框的左缘(r.left()+12)——使「容器→带框子级」的视觉缩进 = 一个树级 // 容器文本左缘对齐子级复选框的左缘(r.left()+12)——使「容器→带框子级」的视觉缩进 = 一个树级
@ -143,6 +155,7 @@ public:
const QModelIndex& idx) override { const QModelIndex& idx) override {
if (!(idx.flags() & Qt::ItemIsUserCheckable)) if (!(idx.flags() & Qt::ItemIsUserCheckable))
return QStyledItemDelegate::editorEvent(ev, model, opt, idx); return QStyledItemDelegate::editorEvent(ev, model, opt, idx);
const bool busy = idx.data(kDsBusyRole).toBool(); // 渲染中 → 吞掉勾选交互
const QRect r = opt.rect.adjusted(4, 2, -4, -2); const QRect r = opt.rect.adjusted(4, 2, -4, -2);
const int box = 16; const int box = 16;
// 命中区放宽到复选框左侧整段(含一点文本起始),便于点击。 // 命中区放宽到复选框左侧整段(含一点文本起始),便于点击。
@ -154,12 +167,14 @@ public:
if (ev->type() == QEvent::MouseButtonRelease) { if (ev->type() == QEvent::MouseButtonRelease) {
auto* me = static_cast<QMouseEvent*>(ev); auto* me = static_cast<QMouseEvent*>(ev);
if (me->button() == Qt::LeftButton && hit.contains(me->pos())) { if (me->button() == Qt::LeftButton && hit.contains(me->pos())) {
if (busy) return true; // 渲染中:不切换,仅消费
toggle(); toggle();
return true; return true;
} }
} else if (ev->type() == QEvent::KeyPress) { } else if (ev->type() == QEvent::KeyPress) {
auto* ke = static_cast<QKeyEvent*>(ev); auto* ke = static_cast<QKeyEvent*>(ev);
if (ke->key() == Qt::Key_Space || ke->key() == Qt::Key_Select) { if (ke->key() == Qt::Key_Space || ke->key() == Qt::Key_Select) {
if (busy) return true;
toggle(); toggle();
return true; return true;
} }

View File

@ -23,6 +23,8 @@ constexpr int kDsNameRole = 0x0105; // Qt::UserRole + 5dsName详情页
constexpr int kDsTypeNameRole = 0x0106; // Qt::UserRole + 6类型名快速筛选用 constexpr int kDsTypeNameRole = 0x0106; // Qt::UserRole + 6类型名快速筛选用
constexpr int kDsCreateTimeRole = 0x0107; // Qt::UserRole + 7创建时间按日期筛选用 constexpr int kDsCreateTimeRole = 0x0107; // Qt::UserRole + 7创建时间按日期筛选用
constexpr int kDsTmObjectIdRole = 0x0108; // Qt::UserRole + 8所属 TM 对象 id=白化 structParentId constexpr int kDsTmObjectIdRole = 0x0108; // Qt::UserRole + 8所属 TM 对象 id=白化 structParentId
constexpr int kDsBusyRole = 0x0109; // Qt::UserRole + 9true=该行渲染中,复选框位置画等待 spinner
constexpr int kDsSpinAngleRole = 0x010A; // Qt::UserRole + 10spinner 角度定时器驱动delegate 据此旋转)
// 数据页签:树形(按 DsRow.parentId 嵌套,源数据为根、派生数据挂其下,对齐原版 el-table 树)。 // 数据页签:树形(按 DsRow.parentId 嵌套,源数据为根、派生数据挂其下,对齐原版 el-table 树)。
// 每项列0文本 = dsName +「创建时间 · 类型名」data(0,角色) 存 dsId/ddCode/dsName。 // 每项列0文本 = dsName +「创建时间 · 类型名」data(0,角色) 存 dsId/ddCode/dsName。

View File

@ -30,7 +30,10 @@ LoadingOverlay::LoadingOverlay(QWidget* parent) : QWidget(parent), label_(new QL
hide(); hide();
} }
void LoadingOverlay::showOver() { void LoadingOverlay::showOver() { showOver(QStringLiteral("加载中…")); }
void LoadingOverlay::showOver(const QString& message) {
label_->setText(message);
if (parentWidget()) setGeometry(parentWidget()->rect()); if (parentWidget()) setGeometry(parentWidget()->rect());
raise(); raise();
show(); show();

View File

@ -3,12 +3,14 @@
class QLabel; class QLabel;
namespace geopro::app { namespace geopro::app {
// 半透明「加载中…」遮罩。贴在目标视图上层showOver()/hide() 切换,几何随父 resize 跟随。 // 半透明「等待」遮罩(公共组件)。贴在任意目标视图上层(含 VTK QVTKOpenGLStereoWidget
// showOver()/hide() 切换,几何随父 resize 跟随。可传自定义文案在不同场景复用。
class LoadingOverlay : public QWidget { class LoadingOverlay : public QWidget {
Q_OBJECT Q_OBJECT
public: public:
explicit LoadingOverlay(QWidget* parent); explicit LoadingOverlay(QWidget* parent);
void showOver(); // 铺满父尺寸、置顶、显示 void showOver(); // 铺满父尺寸、置顶、显示(默认「加载中…」)
void showOver(const QString& message); // 同上,自定义提示文案(如「正在保存三维体…」)
protected: protected:
bool eventFilter(QObject* obj, QEvent* ev) override; // 跟随父 resize bool eventFilter(QObject* obj, QEvent* ev) override; // 跟随父 resize
private: private:

View File

@ -11,6 +11,11 @@ inline double clamp01(double v) {
inline unsigned char lerpByte(unsigned char a, unsigned char b, double t) { inline unsigned char lerpByte(unsigned char a, unsigned char b, double t) {
return static_cast<unsigned char>(a + (b - a) * t + 0.5); return static_cast<unsigned char>(a + (b - a) * t + 0.5);
} }
// 两级透明度:每色 alpha × 整体透明度(渲染时相乘,不烘焙)。
inline core::Rgba applyGlobalAlpha(core::Rgba c, double g) {
c.a = static_cast<unsigned char>(c.a * g + 0.5);
return c;
}
} // namespace } // namespace
ColorMapService::ColorMapService(const core::ColorScale& scale) ColorMapService::ColorMapService(const core::ColorScale& scale)
@ -47,17 +52,18 @@ double ColorMapService::normalized(double v) const {
} }
core::Rgba ColorMapService::colorAtContinuous(double v) const { core::Rgba ColorMapService::colorAtContinuous(double v) const {
if (normStops_.empty()) return core::Rgba{0, 0, 0, 255}; const double g = scale_.globalOpacity(); // 两级第二级:整体透明度
if (normStops_.size() == 1) return normStops_.front().color; if (normStops_.empty()) return applyGlobalAlpha(core::Rgba{0, 0, 0, 255}, g);
if (normStops_.size() == 1) return applyGlobalAlpha(normStops_.front().color, g);
double t = normalized(v); double t = normalized(v);
// 非有限值NaN/Inf可能来自降级后端的脏数据或退化数据范围回退首断点色 // 非有限值NaN/Inf可能来自降级后端的脏数据或退化数据范围回退首断点色
// 避免下方 upper_bound 用 NaN 比较返回 end() 后解引用越界(崩溃)。 // 避免下方 upper_bound 用 NaN 比较返回 end() 后解引用越界(崩溃)。
if (!std::isfinite(t)) return normStops_.front().color; if (!std::isfinite(t)) return applyGlobalAlpha(normStops_.front().color, g);
// 找到 t 落在哪两个 normStop 之间 // 找到 t 落在哪两个 normStop 之间
if (t <= normStops_.front().pos) return normStops_.front().color; if (t <= normStops_.front().pos) return applyGlobalAlpha(normStops_.front().color, g);
if (t >= normStops_.back().pos) return normStops_.back().color; if (t >= normStops_.back().pos) return applyGlobalAlpha(normStops_.back().color, g);
// 二分查找第一个 pos > t // 二分查找第一个 pos > t
auto it = std::upper_bound(normStops_.begin(), normStops_.end(), t, auto it = std::upper_bound(normStops_.begin(), normStops_.end(), t,
@ -68,16 +74,16 @@ core::Rgba ColorMapService::colorAtContinuous(double v) const {
double segLen = hi.pos - lo.pos; double segLen = hi.pos - lo.pos;
double frac = (segLen > 0.0) ? (t - lo.pos) / segLen : 0.0; double frac = (segLen > 0.0) ? (t - lo.pos) / segLen : 0.0;
return core::Rgba{ return applyGlobalAlpha(core::Rgba{
lerpByte(lo.color.r, hi.color.r, frac), lerpByte(lo.color.r, hi.color.r, frac),
lerpByte(lo.color.g, hi.color.g, frac), lerpByte(lo.color.g, hi.color.g, frac),
lerpByte(lo.color.b, hi.color.b, frac), lerpByte(lo.color.b, hi.color.b, frac),
lerpByte(lo.color.a, hi.color.a, frac) lerpByte(lo.color.a, hi.color.a, frac)
}; }, g);
} }
core::Rgba ColorMapService::colorAtDiscrete(double v) const { core::Rgba ColorMapService::colorAtDiscrete(double v) const {
return scale_.colorAt(v); return applyGlobalAlpha(scale_.colorAt(v), scale_.globalOpacity());
} }
} // namespace geopro::app } // namespace geopro::app

View File

@ -0,0 +1,57 @@
#include "panels/chart/ColorScaleProperties.hpp"
#include <QJsonArray>
namespace geopro::app {
QString rgbaToColorBarCss(const geopro::core::Rgba& c) {
if (c.a >= 255)
return QStringLiteral("#%1%2%3")
.arg(c.r, 2, 16, QLatin1Char('0'))
.arg(c.g, 2, 16, QLatin1Char('0'))
.arg(c.b, 2, 16, QLatin1Char('0'))
.toUpper();
return QStringLiteral("rgba(%1, %2, %3, %4)")
.arg(c.r)
.arg(c.g)
.arg(c.b)
.arg(QString::number(c.a / 255.0, 'g', 3));
}
QJsonObject buildColorScaleProperties(const geopro::core::ColorScale& scale,
const ContourLineConfig& lineCfg, const QString& lvlSchemeType,
int logLinesCount, int equalAreaLayerCount,
bool includeLvlScheme) {
QJsonArray colorBar;
for (const auto& [value, color] : scale.stops())
colorBar.append(QJsonArray{QString::number(value, 'f', 2), rgbaToColorBarCss(color)});
QJsonObject lineConfig{
{QStringLiteral("showLines"), lineCfg.lineShow},
{QStringLiteral("color"), rgbaToColorBarCss(lineCfg.lineColor)},
{QStringLiteral("lineType"),
lineCfg.dashed ? QStringLiteral("dashed") : QStringLiteral("solid")}};
QJsonObject labelConfig{{QStringLiteral("showLabels"), lineCfg.labelShow},
{QStringLiteral("color"), rgbaToColorBarCss(lineCfg.labelColor)}};
QJsonObject props{{QStringLiteral("colorBar"), colorBar},
{QStringLiteral("opacity"), scale.globalOpacity()}, // 两级第二级:整体透明度
{QStringLiteral("lineConfig"), lineConfig},
{QStringLiteral("labelConfig"), labelConfig}};
if (includeLvlScheme) { // 等值面(网格/反演)路径:层级方案透传字段(复刻原版,整条覆盖写须带)
props[QStringLiteral("lvlSchemeType")] = lvlSchemeType;
props[QStringLiteral("logLinesCount")] = logLinesCount;
props[QStringLiteral("equalAreaLayerCount")] = equalAreaLayerCount;
}
return props;
}
QJsonObject withColorBarAndOpacity(const QJsonObject& base, const geopro::core::ColorScale& scale) {
QJsonObject props = base; // 保留 lineConfig/labelConfig/层级方案 等加载到的原值
QJsonArray colorBar;
for (const auto& [value, color] : scale.stops())
colorBar.append(QJsonArray{QString::number(value, 'f', 2), rgbaToColorBarCss(color)});
props[QStringLiteral("colorBar")] = colorBar; // 仅覆盖本次编辑的颜色
props[QStringLiteral("opacity")] = scale.globalOpacity(); // 与整体不透明度
return props;
}
} // namespace geopro::app

View File

@ -0,0 +1,29 @@
#pragma once
#include <QJsonObject>
#include <QString>
#include "ContourLineDialog.hpp" // ContourLineConfig
#include "model/ColorScale.hpp"
namespace geopro::app {
// core::Rgba → colorBar 颜色串(不透明 #RRGGBB半透明 rgba(r,g,b,a∈0..1)),与后端 colorBar 互通。
QString rgbaToColorBarCss(const geopro::core::Rgba& c);
// 组装色阶持久化 propertiescolorBar[每色含 alpha] + opacity[整体透明度] + lineConfig + labelConfig
// + lvlSchemeType/logLinesCount/equalAreaLayerCount[层级方案,复刻原版透传字段])。
// 散点/网格共用同一格式(同一条后端记录 businessCode="")。整条 properties 覆盖写,故层级字段必须带,
// 否则会清空 web 设过的值。opacity 为两级透明度的第二级。
// includeLvlScheme=falsemeasurement 散点(type3) 路径,不写等值面专属的层级方案字段(对齐原版
// scatters「仅发 colorBar/lineConfig/labelConfig」+ 桌面两级 opacity避免向 R0 记录注入无关字段。
QJsonObject buildColorScaleProperties(const geopro::core::ColorScale& scale,
const ContourLineConfig& lineCfg,
const QString& lvlSchemeType = QStringLiteral("normal"),
int logLinesCount = 8, int equalAreaLayerCount = 10,
bool includeLvlScheme = true);
// load-then-save 回写(对齐原版 originPage在加载到的原始 properties 上【只覆盖 colorBar+opacity】
// 其余字段lineConfig/labelConfig/层级方案)原样保留,避免散点保存清掉网格(共用同一条记录)的值。
QJsonObject withColorBarAndOpacity(const QJsonObject& base, const geopro::core::ColorScale& scale);
} // namespace geopro::app

View File

@ -195,7 +195,7 @@ void ContourPlotItem::buildFillImage(const core::Grid& g, ColorMapService* svc)
double v = (v00 * (1 - ti) + v10 * ti) * (1 - tj) + double v = (v00 * (1 - ti) + v10 * ti) * (1 - tj) +
(v01 * (1 - ti) + v11 * ti) * tj; (v01 * (1 - ti) + v11 * ti) * tj;
auto c = svc->colorAtDiscrete(v); // 离散色带 → 平滑填充带边界 auto c = svc->colorAtDiscrete(v); // 离散色带 → 平滑填充带边界
scan[px] = qRgba(c.r, c.g, c.b, c.a ? c.a : 255); scan[px] = qRgba(c.r, c.g, c.b, c.a); // 听色阶 alphaalpha=0 真透明(无 alpha 色阶默认 255 不受影响)
} }
} }
fillImage_ = std::move(img); fillImage_ = std::move(img);

View File

@ -17,7 +17,8 @@ std::unique_ptr<IDetailView> makeDetailView(controller::ViewKind kind, QWidget*
std::function<QString()> projectIdGetter, std::function<QString()> projectIdGetter,
geopro::data::IDatasetCommandRepository* cmdRepo, geopro::data::IDatasetCommandRepository* cmdRepo,
std::function<QString()> dsIdGetter, std::function<QString()> dsIdGetter,
std::function<QString()> tmObjectIdGetter) { std::function<QString()> tmObjectIdGetter,
geopro::controller::DatasetViewState* viewState) {
switch (kind) { switch (kind) {
case controller::ViewKind::Scatter: { case controller::ViewKind::Scatter: {
auto* raw = new RawDataChartView(parent); auto* raw = new RawDataChartView(parent);
@ -25,6 +26,8 @@ std::unique_ptr<IDetailView> makeDetailView(controller::ViewKind kind, QWidget*
raw->setCommandRepo(cmdRepo, dsIdGetter, projectIdGetter); raw->setCommandRepo(cmdRepo, dsIdGetter, projectIdGetter);
// 注入色阶模板仓储(散点「色阶配置」编辑器另存为/打开/覆盖用projectId 复用上面的 getter // 注入色阶模板仓储(散点「色阶配置」编辑器另存为/打开/覆盖用projectId 复用上面的 getter
raw->setColorTemplateRepo(colorTplRepo); raw->setColorTemplateRepo(colorTplRepo);
// 注入跨视图色阶真源(反演原数据 type1 与网格/3D 共用色阶 → 实时联动measurement 不路由)。
raw->setViewState(viewState);
return std::unique_ptr<IDetailView>(raw); return std::unique_ptr<IDetailView>(raw);
} }
case controller::ViewKind::FilledContour: { case controller::ViewKind::FilledContour: {
@ -35,6 +38,8 @@ std::unique_ptr<IDetailView> makeDetailView(controller::ViewKind kind, QWidget*
grid->setCommandRepo(cmdRepo, std::move(dsIdGetter), std::move(projectIdGetter)); grid->setCommandRepo(cmdRepo, std::move(dsIdGetter), std::move(projectIdGetter));
// 注入 tmObjectId 取值回调(白化对话框模板列表用,= 数据集 structParentId // 注入 tmObjectId 取值回调(白化对话框模板列表用,= 数据集 structParentId
grid->setTmObjectIdGetter(std::move(tmObjectIdGetter)); grid->setTmObjectIdGetter(std::move(tmObjectIdGetter));
// 注入跨视图色阶真源编辑→真源→3D 帘面/体等跟随;本视图也跟随他视图改色)。
grid->setViewState(viewState);
return std::unique_ptr<IDetailView>(grid); return std::unique_ptr<IDetailView>(grid);
} }
case controller::ViewKind::Table: { case controller::ViewKind::Table: {

View File

@ -13,6 +13,10 @@ class IColorTemplateRepository;
class IDatasetCommandRepository; class IDatasetCommandRepository;
} }
namespace geopro::controller {
class DatasetViewState; // 跨视图色阶真源(统一同步)
}
namespace geopro::app { namespace geopro::app {
class IDetailView; class IDetailView;
@ -29,6 +33,7 @@ std::unique_ptr<IDetailView> makeDetailView(
std::function<QString()> projectIdGetter = {}, std::function<QString()> projectIdGetter = {},
geopro::data::IDatasetCommandRepository* cmdRepo = nullptr, geopro::data::IDatasetCommandRepository* cmdRepo = nullptr,
std::function<QString()> dsIdGetter = {}, std::function<QString()> dsIdGetter = {},
std::function<QString()> tmObjectIdGetter = {}); std::function<QString()> tmObjectIdGetter = {},
geopro::controller::DatasetViewState* viewState = nullptr);
} // namespace geopro::app } // namespace geopro::app

View File

@ -36,6 +36,8 @@
#include "panels/chart/AutoAnnotationDialog.hpp" #include "panels/chart/AutoAnnotationDialog.hpp"
#include "panels/chart/ColorBarWidget.hpp" #include "panels/chart/ColorBarWidget.hpp"
#include "panels/chart/ColorMapService.hpp" #include "panels/chart/ColorMapService.hpp"
#include "panels/chart/ColorScaleProperties.hpp"
#include "DatasetViewState.hpp"
#include "panels/chart/ContourDrawTool.hpp" #include "panels/chart/ContourDrawTool.hpp"
#include "panels/chart/ContourHoverTip.hpp" #include "panels/chart/ContourHoverTip.hpp"
#include "panels/chart/ContourPlotItem.hpp" #include "panels/chart/ContourPlotItem.hpp"
@ -324,7 +326,7 @@ void GridDataChartView::openColorScaleEditor() {
tplRepo_, projectId, lvlTemplateId_, this); tplRepo_, projectId, lvlTemplateId_, this);
if (dlg.exec() != QDialog::Accepted) return; if (dlg.exec() != QDialog::Accepted) return;
gridScale_ = dlg.colorScale(); const auto cs = dlg.colorScale();
lineCfg_ = dlg.lineConfig(); lineCfg_ = dlg.lineConfig();
showLabels_ = lineCfg_.labelShow; // 标注显隐同步 + 回写工具条复选框(避免 UI 与状态脱钩) showLabels_ = lineCfg_.labelShow; // 标注显隐同步 + 回写工具条复选框(避免 UI 与状态脱钩)
if (chkShowLabels_) { if (chkShowLabels_) {
@ -332,10 +334,63 @@ void GridDataChartView::openColorScaleEditor() {
chkShowLabels_->setChecked(showLabels_); chkShowLabels_->setChecked(showLabels_);
} }
const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString();
// 统一同步:写入色阶真源 → 经 colorScaleChanged 触发本视图(及 3D 帘面/体等)重渲染。
// 无状态层(理论不至)才本地兜底重绘。
if (state_ && !dsId.isEmpty()) {
state_->setColorScale(dsId, cs);
} else {
gridScale_ = cs;
applyColorScaleRender();
}
persistColorScale(dsId, cs, dlg.lvlSchemeType(), dlg.logLinesCount(),
dlg.equalAreaLayerCount()); // 持久化到后端businessCode="",与散点同一条记录)
}
void GridDataChartView::applyColorScaleRender() {
delete colorSvc_; delete colorSvc_;
colorSvc_ = new ColorMapService(gridScale_); colorSvc_ = new ColorMapService(gridScale_);
rebuildContour(); rebuildContour();
colorBar_->setColorScale(gridScale_); if (colorBar_) colorBar_->setColorScale(gridScale_);
}
void GridDataChartView::setViewState(geopro::controller::DatasetViewState* state) {
state_ = state;
if (!state_) return;
connect(state_, &geopro::controller::DatasetViewState::colorScaleChanged, this,
&GridDataChartView::onColorScaleChanged);
}
void GridDataChartView::onColorScaleChanged(const QString& dsId) {
if (!state_ || !hasGrid_) return;
if (!dsIdGetter_ || dsIdGetter_() != dsId) return; // 只跟随本视图所示数据集
const auto* cs = state_->colorScale(dsId);
if (!cs) return;
gridScale_ = *cs;
applyColorScaleRender();
}
void GridDataChartView::persistColorScale(const QString& dsId, const geopro::core::ColorScale& cs,
const QString& lvlSchemeType, int logLinesCount,
int equalAreaLayerCount) {
if (!cmdRepo_ || dsId.isEmpty()) return; // 无仓储/无 dsId → 仅本地生效(不阻塞)
const QString projectId = projectIdGetter_ ? projectIdGetter_() : QString();
// 网格(getDetail type2) 与 反演散点(type1) 共用 businessCode=""(后端按 (dsObjectId,businessCode)
// 存唯一记录save 无 type 字段。properties 含每色 alpha + 整体透明度 + 层级方案透传字段。
QJsonObject body{
{QStringLiteral("dsObjectId"), dsId},
{QStringLiteral("templateId"), lvlTemplateId_},
{QStringLiteral("businessCode"), QString()},
{QStringLiteral("projectId"), projectId},
{QStringLiteral("properties"),
buildColorScaleProperties(cs, lineCfg_, lvlSchemeType, logLinesCount, equalAreaLayerCount)},
};
QPointer<GridDataChartView> self(this);
cmdRepo_->saveColorGradation(body, [self](bool ok, QString msg) {
if (!self || ok) return;
QMessageBox::warning(self, QStringLiteral("色阶配置"),
msg.isEmpty() ? QStringLiteral("色阶保存失败") : msg);
});
} }
void GridDataChartView::setCommandRepo(geopro::data::IDatasetCommandRepository* repo, void GridDataChartView::setCommandRepo(geopro::data::IDatasetCommandRepository* repo,

View File

@ -4,6 +4,7 @@
#include <vector> #include <vector>
#include <QJsonObject> // submitDrawnException 默认参数 const QJsonObject& = {} 需完整类型 #include <QJsonObject> // submitDrawnException 默认参数 const QJsonObject& = {} 需完整类型
#include <QPointer>
#include <QString> #include <QString>
#include <QWidget> #include <QWidget>
@ -28,6 +29,10 @@ class IColorTemplateRepository;
class IDatasetCommandRepository; class IDatasetCommandRepository;
} }
namespace geopro::controller {
class DatasetViewState; // 跨视图色阶真源(统一同步机制)
}
namespace geopro::app { namespace geopro::app {
class AnomalyTablePanel; class AnomalyTablePanel;
@ -71,9 +76,17 @@ public:
tmObjectIdGetter_ = std::move(tmObjectIdGetter); tmObjectIdGetter_ = std::move(tmObjectIdGetter);
} }
// 注入跨视图色阶真源(统一同步):编辑写入它、并连 colorScaleChanged 跟随他视图(如 3D改色。
void setViewState(geopro::controller::DatasetViewState* state);
private: private:
void rebuildContour(); // 按当前显隐开关重建并重绘 ContourPlotItem void rebuildContour(); // 按当前显隐开关重建并重绘 ContourPlotItem
void openColorScaleEditor(); // 「色阶配置」→ 共享色阶编辑器(色阶 + 层级⚙ + 线形⚙) void openColorScaleEditor(); // 「色阶配置」→ 共享色阶编辑器(色阶 + 层级⚙ + 线形⚙)
void applyColorScaleRender(); // 用当前 gridScale_ 重建色彩服务/等值面/色阶条
void onColorScaleChanged(const QString& dsId); // 色阶真源变更(本视图或他视图编辑)→ 跟随重渲染
void persistColorScale(const QString& dsId, const geopro::core::ColorScale& cs,
const QString& lvlSchemeType, int logLinesCount,
int equalAreaLayerCount); // 存后端(含层级方案透传字段)
void openGridWizard(); // I1「网格」→ 网格化向导 void openGridWizard(); // I1「网格」→ 网格化向导
void openWhitening(); // I3「白化」→ 白化弹窗 void openWhitening(); // I3「白化」→ 白化弹窗
void openFilter(); // I4「滤波处理」→ 滤波弹窗 void openFilter(); // I4「滤波处理」→ 滤波弹窗
@ -131,6 +144,8 @@ private:
// tmObjectId 取值回调(= 数据集 structParentId。白化对话框模板列表用空 → 模板列表为空。 // tmObjectId 取值回调(= 数据集 structParentId。白化对话框模板列表用空 → 模板列表为空。
std::function<QString()> tmObjectIdGetter_; std::function<QString()> tmObjectIdGetter_;
QPointer<geopro::controller::DatasetViewState> state_; // 跨视图色阶真源注入QPointer 自动判空防悬挂)
}; };
} // namespace geopro::app } // namespace geopro::app

View File

@ -2,6 +2,8 @@
#include "ColorScaleConfigDialog.hpp" #include "ColorScaleConfigDialog.hpp"
#include "panels/chart/ChartTheme.hpp" #include "panels/chart/ChartTheme.hpp"
#include "panels/chart/ColorBarWidget.hpp" #include "panels/chart/ColorBarWidget.hpp"
#include "panels/chart/ColorScaleProperties.hpp"
#include "DatasetViewState.hpp"
#include "panels/chart/GridWizardDialog.hpp" #include "panels/chart/GridWizardDialog.hpp"
#include "panels/chart/InversionFormDialog.hpp" #include "panels/chart/InversionFormDialog.hpp"
#include "panels/chart/SaveAsDialog.hpp" #include "panels/chart/SaveAsDialog.hpp"
@ -302,41 +304,6 @@ void styleToolIconButton(QToolButton* btn, const QIcon& icon) {
btn->setCursor(Qt::PointingHandCursor); btn->setCursor(Qt::PointingHandCursor);
} }
// core::Rgba → colorBar 颜色串(与 ColorScaleConfigDialog::rgbaToCss 同格式:不透明 #RRGGBB
// 半透明 rgba(r,g,b,a∈0..1)),与后端 colorBar 互通。
QString rgbaToColorBarCss(const geopro::core::Rgba& c) {
if (c.a >= 255)
return QStringLiteral("#%1%2%3")
.arg(c.r, 2, 16, QLatin1Char('0'))
.arg(c.g, 2, 16, QLatin1Char('0'))
.arg(c.b, 2, 16, QLatin1Char('0'))
.toUpper();
return QStringLiteral("rgba(%1, %2, %3, %4)")
.arg(c.r)
.arg(c.g)
.arg(c.b)
.arg(QString::number(c.a / 255.0, 'g', 3));
}
// 组装色阶 propertiescolorBar + lineConfig + labelConfig与原版散点路径
// newLvlColorLevel 一致battery/scatters 仅发这三块,不含 lvlSchemeType 等等值面专属字段)。
QJsonObject buildColorScaleProperties(const geopro::core::ColorScale& scale,
const ContourLineConfig& lineCfg) {
QJsonArray colorBar;
for (const auto& [value, color] : scale.stops())
colorBar.append(QJsonArray{QString::number(value, 'f', 2), rgbaToColorBarCss(color)});
QJsonObject lineConfig{
{QStringLiteral("showLines"), lineCfg.lineShow},
{QStringLiteral("color"), rgbaToColorBarCss(lineCfg.lineColor)},
{QStringLiteral("lineType"),
lineCfg.dashed ? QStringLiteral("dashed") : QStringLiteral("solid")}};
QJsonObject labelConfig{{QStringLiteral("showLabels"), lineCfg.labelShow},
{QStringLiteral("color"), rgbaToColorBarCss(lineCfg.labelColor)}};
return QJsonObject{{QStringLiteral("colorBar"), colorBar},
{QStringLiteral("lineConfig"), lineConfig},
{QStringLiteral("labelConfig"), labelConfig}};
}
} // namespace } // namespace
void RawDataChartView::showNotImplemented(QWidget* anchor) { void RawDataChartView::showNotImplemented(QWidget* anchor) {
@ -400,16 +367,21 @@ void RawDataChartView::openInversionColorScale(QWidget* anchor) {
this); this);
if (dlg.exec() != QDialog::Accepted) return; if (dlg.exec() != QDialog::Accepted) return;
// 本地重建上色重绘。 const auto cs = dlg.colorScale();
data_.scale = dlg.colorScale(); const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString();
// 统一同步:反演原数据(type1,"")与网格/3D 共用 → 写真源,经 colorScaleChanged 各视图(含自身)重绘。
if (state_ && !dsId.isEmpty()) {
state_->setColorScale(dsId, cs);
} else {
data_.scale = cs;
delete colorSvc_; delete colorSvc_;
colorSvc_ = new ColorMapService(data_.scale); colorSvc_ = new ColorMapService(data_.scale);
redrawScatter(); redrawScatter();
colorBar_->setColorScale(data_.scale); colorBar_->setColorScale(data_.scale);
}
showToast(this, QStringLiteral("色阶应用成功")); // M8 成功提示(对照原版 Message.success showToast(this, QStringLiteral("色阶应用成功")); // M8 成功提示(对照原版 Message.success
// 持久化businessCode 空,对照原版 originPage newLvlColorLevel businessCode:'')。 // 持久化businessCode 空,对照原版 originPage newLvlColorLevel businessCode:'')。
const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString();
const QString projectId = projectIdGetter_ ? projectIdGetter_() : QString(); const QString projectId = projectIdGetter_ ? projectIdGetter_() : QString();
if (!cmdRepo_ || dsId.isEmpty()) return; if (!cmdRepo_ || dsId.isEmpty()) return;
QJsonObject body{ QJsonObject body{
@ -417,7 +389,13 @@ void RawDataChartView::openInversionColorScale(QWidget* anchor) {
{QStringLiteral("templateId"), data_.templateId}, // 读取到的色阶模板 id对照原版可空 {QStringLiteral("templateId"), data_.templateId}, // 读取到的色阶模板 id对照原版可空
{QStringLiteral("businessCode"), QString()}, {QStringLiteral("businessCode"), QString()},
{QStringLiteral("projectId"), projectId}, {QStringLiteral("projectId"), projectId},
{QStringLiteral("properties"), buildColorScaleProperties(data_.scale, dlg.lineConfig())}, // load-then-save在加载到的原始 properties 上只覆盖 colorBar+opacity保留网格设过的
// lineConfig/层级(共用同一条 businessCode="" 记录);无原始记录(首次)才整条新建。
{QStringLiteral("properties"),
data_.properties.isEmpty()
? buildColorScaleProperties(cs, dlg.lineConfig(), dlg.lvlSchemeType(),
dlg.logLinesCount(), dlg.equalAreaLayerCount())
: withColorBarAndOpacity(data_.properties, cs)},
}; };
QPointer<RawDataChartView> self(this); QPointer<RawDataChartView> self(this);
cmdRepo_->saveColorGradation(body, [self](bool ok, QString msg) { cmdRepo_->saveColorGradation(body, [self](bool ok, QString msg) {
@ -427,6 +405,26 @@ void RawDataChartView::openInversionColorScale(QWidget* anchor) {
}); });
} }
void RawDataChartView::setViewState(geopro::controller::DatasetViewState* state) {
state_ = state;
if (!state_) return;
connect(state_, &geopro::controller::DatasetViewState::colorScaleChanged, this,
&RawDataChartView::onColorScaleChanged);
}
void RawDataChartView::onColorScaleChanged(const QString& dsId) {
if (!state_) return;
if (!dsIdGetter_ || dsIdGetter_() != dsId) return; // 只跟随本视图所示数据集
const auto* cs = state_->colorScale(dsId);
if (!cs) return;
data_.scale = *cs;
delete colorSvc_;
colorSvc_ = new ColorMapService(data_.scale);
redrawScatter();
if (data_.verticalLegend) colorBarV_->setColorScale(data_.scale);
else colorBar_->setColorScale(data_.scale);
}
void RawDataChartView::openInversionSaveAs(QWidget* anchor) { void RawDataChartView::openInversionSaveAs(QWidget* anchor) {
// O3另存为复用 SaveAsDialog::Inversion → saveInversionAsData // O3另存为复用 SaveAsDialog::Inversion → saveInversionAsData
const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString(); const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString();
@ -599,7 +597,10 @@ void RawDataChartView::openScatterColorScale(QWidget* anchor) {
{QStringLiteral("templateId"), data_.templateId}, // 读取到的色阶模板 id对照原版可空 {QStringLiteral("templateId"), data_.templateId}, // 读取到的色阶模板 id对照原版可空
{QStringLiteral("businessCode"), currentVFieldCode()}, {QStringLiteral("businessCode"), currentVFieldCode()},
{QStringLiteral("projectId"), projectId}, {QStringLiteral("projectId"), projectId},
{QStringLiteral("properties"), buildColorScaleProperties(data_.scale, dlg.lineConfig())}, // measurement(type3,"R0") 独立记录不写等值面层级方案字段includeLvlScheme=false
{QStringLiteral("properties"),
buildColorScaleProperties(data_.scale, dlg.lineConfig(), QStringLiteral("normal"), 8, 10,
/*includeLvlScheme=*/false)},
}; };
QPointer<RawDataChartView> self(this); QPointer<RawDataChartView> self(this);
cmdRepo_->saveColorGradation(body, [self](bool ok, QString msg) { cmdRepo_->saveColorGradation(body, [self](bool ok, QString msg) {

View File

@ -1,6 +1,7 @@
#pragma once #pragma once
#include <functional> #include <functional>
#include <QPointer>
#include <QString> #include <QString>
#include <QWidget> #include <QWidget>
#include "model/detail/DetailPayloads.hpp" #include "model/detail/DetailPayloads.hpp"
@ -18,6 +19,10 @@ class IDatasetCommandRepository;
class IColorTemplateRepository; class IColorTemplateRepository;
} }
namespace geopro::controller {
class DatasetViewState; // 跨视图色阶真源(统一同步)
}
namespace geopro::app { namespace geopro::app {
class ColorBarWidget; class ColorBarWidget;
@ -52,6 +57,10 @@ public:
// setCommandRepo 注入的 projectIdGetter_。可传空 → 编辑器后端按钮禁用。 // setCommandRepo 注入的 projectIdGetter_。可传空 → 编辑器后端按钮禁用。
void setColorTemplateRepo(geopro::data::IColorTemplateRepository* repo); void setColorTemplateRepo(geopro::data::IColorTemplateRepository* repo);
// 注入跨视图色阶真源:反演原数据散点(type1, businessCode="")与网格/3D 共用色阶 → 实时联动。
// measurement(type3,"R0") 单视图、不路由真源openScatterColorScale 不经此)。
void setViewState(geopro::controller::DatasetViewState* state);
protected: protected:
// 信息模式M13下捕获画布点击找最近散点显示属性。其余事件不消费。 // 信息模式M13下捕获画布点击找最近散点显示属性。其余事件不消费。
bool eventFilter(QObject* obj, QEvent* ev) override; bool eventFilter(QObject* obj, QEvent* ev) override;
@ -70,6 +79,7 @@ private:
// 反演原数据默认工具条交互O1/O2/O3 // 反演原数据默认工具条交互O1/O2/O3
void openGridWizard(QWidget* anchor); // O1 网格化向导(复用 GridWizardDialog void openGridWizard(QWidget* anchor); // O1 网格化向导(复用 GridWizardDialog
void openInversionColorScale(QWidget* anchor); // O2 原数据散点色阶type1businessCode='' void openInversionColorScale(QWidget* anchor); // O2 原数据散点色阶type1businessCode=''
void onColorScaleChanged(const QString& dsId); // 色阶真源变更(本视图或网格/3D)→ type1 散点跟随重绘
void openInversionSaveAs(QWidget* anchor); // O3 另存为(复用 SaveAsDialog::Inversion void openInversionSaveAs(QWidget* anchor); // O3 另存为(复用 SaveAsDialog::Inversion
// measurement 交互: // measurement 交互:
@ -130,6 +140,8 @@ private:
std::function<QString()> projectIdGetter_; std::function<QString()> projectIdGetter_;
// 色阶模板仓储(注入;空则编辑器「另存为/打开」禁用)。 // 色阶模板仓储(注入;空则编辑器「另存为/打开」禁用)。
geopro::data::IColorTemplateRepository* colorTplRepo_ = nullptr; geopro::data::IColorTemplateRepository* colorTplRepo_ = nullptr;
QPointer<geopro::controller::DatasetViewState> state_; // 跨视图色阶真源(注入;仅 type1 路由QPointer 防悬挂)
}; };
} // namespace geopro::app } // namespace geopro::app

View File

@ -1,6 +1,11 @@
#include "panels/columns/CategoryAnalysisTab.hpp" #include "panels/columns/CategoryAnalysisTab.hpp"
#include <QAbstractItemView>
#include <QPointer>
#include <QScrollArea> #include <QScrollArea>
#include <QScrollBar>
#include <QTimer>
#include <QTreeWidget>
#include <QVBoxLayout> #include <QVBoxLayout>
#include "Theme.hpp" #include "Theme.hpp"
@ -15,19 +20,25 @@ CategoryAnalysisTab::CategoryAnalysisTab(geopro::data::DatasetFieldDictionary* d
outer->setSpacing(0); outer->setSpacing(0);
auto* scroll = new QScrollArea(this); auto* scroll = new QScrollArea(this);
scroll_ = scroll;
scroll->setWidgetResizable(true); scroll->setWidgetResizable(true);
scroll->setFrameShape(QFrame::NoFrame); scroll->setFrameShape(QFrame::NoFrame);
scroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); // 内容随面板宽自适应,不出横向滚动条 scroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); // 内容随面板宽自适应,不出横向滚动条
outer->addWidget(scroll, 1); outer->addWidget(scroll, 1);
auto* content = new QWidget(scroll); auto* content = new QWidget(scroll);
content_ = content;
auto* col = new QVBoxLayout(content); auto* col = new QVBoxLayout(content);
col->setContentsMargins(0, 0, 0, 0); col_ = col;
col->setContentsMargins(0, space::kSm, 0, space::kSm); // 顶部留白:首段段头不贴顶
col->setSpacing(space::kSm); col->setSpacing(space::kSm);
for (const CategorySpec& spec : categoryConfigs()) { for (const CategorySpec& spec : categoryConfigs()) {
auto* sec = new CategorySection(spec, dict, content); auto* sec = new CategorySection(spec, dict, content);
sections_[spec.id] = sec; sections_[spec.id] = sec;
ordered_.push_back(sec);
connect(sec, &CategorySection::collapsedChanged, this,
&CategoryAnalysisTab::relayoutSections); // 折叠/展开 → 重排 stretch向上收
const std::string segId = spec.id; const std::string segId = spec.id;
connect(sec, &CategorySection::checkedDatasetsChanged, this, connect(sec, &CategorySection::checkedDatasetsChanged, this,
[this, segId](const QStringList& ids) { [this, segId](const QStringList& ids) {
@ -54,9 +65,22 @@ CategoryAnalysisTab::CategoryAnalysisTab(geopro::data::DatasetFieldDictionary* d
// 某段内容增多时其最小高度(=内容总高)撑大,超出视口则由外层 QScrollArea 统一出纵向滚动条。 // 某段内容增多时其最小高度(=内容总高)撑大,超出视口则由外层 QScrollArea 统一出纵向滚动条。
col->addWidget(sec, 1); col->addWidget(sec, 1);
} }
// 尾部弹簧(末项):默认 0全部段折叠时由 relayoutSections 置 1吸收余量把段头顶到顶部。
col->addStretch(0);
scroll->setWidget(content); scroll->setWidget(content);
} }
void CategoryAnalysisTab::relayoutSections() {
if (!col_) return;
int expanded = 0;
for (auto* sec : ordered_)
if (sec->isExpanded()) ++expanded;
// 展开段 stretch=1吸收余量、铺满折叠段 stretch=0只占段头高下方不再留空
for (auto* sec : ordered_) col_->setStretchFactor(sec, sec->isExpanded() ? 1 : 0);
// 尾部弹簧:仅当全部折叠时=1把所有段头顶到顶部有任一展开段时=0由展开段吸收余量
col_->setStretch(col_->count() - 1, expanded == 0 ? 1 : 0);
}
void CategoryAnalysisTab::setBuckets(const CategoryBuckets& b) { void CategoryAnalysisTab::setBuckets(const CategoryBuckets& b) {
const auto& cfg = categoryConfigs(); const auto& cfg = categoryConfigs();
for (std::size_t i = 0; i < cfg.size() && i < b.segments.size(); ++i) { for (std::size_t i = 0; i < cfg.size() && i < b.segments.size(); ++i) {
@ -81,6 +105,50 @@ CategorySection* CategoryAnalysisTab::section(const std::string& id) const {
return it != sections_.end() ? it->second : nullptr; return it != sections_.end() ? it->second : nullptr;
} }
// ds 归属唯一段未知 → 广播到各段,仅含该 ds 的段会命中生效(其余 no-op
void CategoryAnalysisTab::setItemChecked(const QString& dsId, bool on) {
for (auto* sec : ordered_) sec->setChecked(dsId, on);
}
void CategoryAnalysisTab::setItemBusy(const QString& dsId, bool busy) {
for (auto* sec : ordered_) sec->setBusy(dsId, busy);
}
void CategoryAnalysisTab::clearAllBusy() {
for (auto* sec : ordered_) sec->clearAllBusy();
}
void CategoryAnalysisTab::scrollItemToTop(const QString& dsId) {
// 先就地展开所在段(同步),再进入多拍重试定位(等布局/滚动条范围结算)。
for (auto* sec : ordered_)
if (sec->itemFor(dsId)) { sec->ensureExpanded(); break; }
scrollItemToTopRetry(dsId, /*attemptsLeft=*/5);
}
void CategoryAnalysisTab::scrollItemToTopRetry(const QString& dsId, int attemptsLeft) {
if (!scroll_ || !content_) return;
CategorySection* sec = nullptr;
QTreeWidgetItem* item = nullptr;
for (auto* s : ordered_)
if ((item = s->itemFor(dsId)) != nullptr) { sec = s; break; }
if (sec && item) {
sec->ensureExpanded();
for (QTreeWidgetItem* p = item->parent(); p; p = p->parent())
p->setExpanded(true); // 展开树内父节点,使目标行有有效几何
QTreeWidget* tree = sec->listWidget();
tree->scrollToItem(item, QAbstractItemView::PositionAtTop); // 内层树(若有内滚动)
// 行顶映射到滚动内容坐标 → 设外层滚动条把该行顶到面板最上方。
const int y = tree->viewport()->mapTo(content_, tree->visualItemRect(item).topLeft()).y();
scroll_->verticalScrollBar()->setValue(y);
}
// 多拍重试:每拍布局更趋稳定(滚动条 range 长够、行几何更新),末拍稳定到位 → 根治"有时滚不到位"。
if (attemptsLeft > 0) {
QPointer<CategoryAnalysisTab> self(this);
const QString id = dsId;
QTimer::singleShot(16, this, [self, id, attemptsLeft]() {
if (self) self->scrollItemToTopRetry(id, attemptsLeft - 1);
});
}
}
void CategoryAnalysisTab::recomputeCheckedUnion() { void CategoryAnalysisTab::recomputeCheckedUnion() {
QStringList all; // ds 归属唯一段,跨段不重复,直接拼接 QStringList all; // ds 归属唯一段,跨段不重复,直接拼接
for (const auto& [id, ids] : checkedBySeg_) all += ids; for (const auto& [id, ids] : checkedBySeg_) all += ids;

View File

@ -4,6 +4,9 @@
#include <map> #include <map>
#include <string> #include <string>
#include <vector> #include <vector>
class QVBoxLayout;
class QScrollArea;
#include "DatasetCategory.hpp" // CategoryBuckets #include "DatasetCategory.hpp" // CategoryBuckets
#include "interact/SlicePlaneMath.hpp" // geopro::render::interact::SliceAxis #include "interact/SlicePlaneMath.hpp" // geopro::render::interact::SliceAxis
#include "repo/RepoTypes.hpp" #include "repo/RepoTypes.hpp"
@ -27,6 +30,11 @@ public:
void setStructure(const std::vector<geopro::data::StructNode>& nodes); // 转发各段 void setStructure(const std::vector<geopro::data::StructNode>& nodes); // 转发各段
void refreshArrayFilters(); // 装置枚举异步加载后,重填各段装置筛选下拉 void refreshArrayFilters(); // 装置枚举异步加载后,重填各段装置筛选下拉
CategorySection* section(const std::string& id) const; // 按 CategorySpec.id 取段 CategorySection* section(const std::string& id) const; // 按 CategorySpec.id 取段
// ── 三维体生成后:自动勾选触发渲染 + 标题前等待动画(spinner)反馈(转发到含该 ds 的段)──
void setItemChecked(const QString& dsId, bool on); // 自动勾选(触发渲染)
void setItemBusy(const QString& dsId, bool busy); // 复选框↔等待 spinner 切换
void clearAllBusy(); // 撤回所有 spinner失败兜底
void scrollItemToTop(const QString& dsId); // 把某行尽量滚到面板顶部(新建三维体后定位)
signals: signals:
void checkedDatasetsChanged(const QStringList& dsIds); // 5 段勾选并集 void checkedDatasetsChanged(const QStringList& dsIds); // 5 段勾选并集
@ -45,8 +53,18 @@ signals:
private: private:
void recomputeCheckedUnion(); void recomputeCheckedUnion();
// scrollItemToTop 的多拍重试实现:展开段/新增行后布局与滚动条范围需多次结算,单拍常滚不到位。
// 每拍重算行位置并设滚动条,剩余拍数耗尽前持续校正 → 末拍几何稳定后行稳定到顶。
void scrollItemToTopRetry(const QString& dsId, int attemptsLeft);
// 据各段折叠态重排 stretch折叠段=0(仅段头高)、展开段=1(吸收余量);全折叠时尾部弹簧吸收→段头顶到顶部。
// 仅在面板不产生滚动条(内容short于视口)时有可见效果——正是用户反馈的"折叠后停在原位中间"场景。
void relayoutSections();
std::map<std::string, CategorySection*> sections_; std::map<std::string, CategorySection*> sections_;
std::vector<CategorySection*> ordered_; // 按 categoryConfigs 顺序relayout 遍历用)
QScrollArea* scroll_ = nullptr; // 外层滚动区scrollItemToTop 定位用)
QWidget* content_ = nullptr; // 滚动内容容器(坐标映射用)
QVBoxLayout* col_ = nullptr; // 段堆叠布局(末项=尾部弹簧)
std::map<std::string, QStringList> checkedBySeg_; // 各段当前勾选(合并成并集) std::map<std::string, QStringList> checkedBySeg_; // 各段当前勾选(合并成并集)
}; };

View File

@ -12,6 +12,7 @@
#include <QPushButton> #include <QPushButton>
#include <QSet> #include <QSet>
#include <QSignalBlocker> #include <QSignalBlocker>
#include <QTimer>
#include <QToolButton> #include <QToolButton>
#include <QTreeWidget> #include <QTreeWidget>
#include <QTreeWidgetItemIterator> #include <QTreeWidgetItemIterator>
@ -33,24 +34,50 @@ CategorySection::CategorySection(const CategorySpec& spec, geopro::data::Dataset
root->setContentsMargins(0, 0, 0, 0); root->setContentsMargins(0, 0, 0, 0);
root->setSpacing(0); root->setSpacing(0);
// 数据类型标题行spec §7折叠箭头+标题(左) | 「+新增三维体」(右,仅反演类,在标题行而非筛选行)。 // 数据类型段头可折叠规范§4.3/§6chevron + 标题(title 字号·半粗) |「+ 新增三维体」(右,仅反演类)。
// 段头加浅底 + 底分隔线作视觉分段;去原生小三角(难看)→chevron 文本前缀,随主题/hover 变色。
auto* headerRow = new QWidget(this); auto* headerRow = new QWidget(this);
headerRow->setObjectName(QStringLiteral("secHeader"));
applyTokenizedStyleSheet(headerRow,
QStringLiteral("QWidget#secHeader{background:{{bg/panel-subtle}};"
"border-bottom:1px solid {{divider}};}"));
auto* hl = new QHBoxLayout(headerRow); auto* hl = new QHBoxLayout(headerRow);
hl->setContentsMargins(space::kSm, 0, space::kSm, 0); hl->setContentsMargins(space::kMd, space::kSm, space::kSm, space::kSm);
hl->setSpacing(space::kSm); hl->setSpacing(space::kSm);
header_ = new QToolButton(headerRow); header_ = new QToolButton(headerRow);
header_->setText(QString::fromStdString(spec_.title));
header_->setCheckable(true); header_->setCheckable(true);
header_->setChecked(true); header_->setChecked(true);
header_->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); header_->setArrowType(Qt::NoArrow);
header_->setArrowType(Qt::DownArrow); header_->setToolButtonStyle(Qt::ToolButtonTextOnly);
header_->setAutoRaise(true); header_->setCursor(Qt::PointingHandCursor);
applyTokenizedStyleSheet(
header_, QStringLiteral("QToolButton{border:none;background:transparent;padding:0;"
"font-size:%1px;font-weight:%2;color:{{text/primary}};}"
"QToolButton:hover{color:{{accent/primary}};}")
.arg(scaledPx(type::kTitle))
.arg(type::kWeightSemibold));
auto syncHeader = [this] {
header_->setText((header_->isChecked() ? QStringLiteral("") : QStringLiteral(""))
+ QString::fromStdString(spec_.title));
};
syncHeader();
hl->addWidget(header_); hl->addWidget(header_);
hl->addStretch(1); hl->addStretch(1);
if (spec_.canGenerateVolume) { if (spec_.canGenerateVolume) {
auto* gen = new QToolButton(headerRow); auto* gen = new QToolButton(headerRow);
gen->setText(QStringLiteral("+ 新增三维体")); gen->setText(QStringLiteral("+ 新增三维体"));
gen->setAutoRaise(true); gen->setCursor(Qt::PointingHandCursor);
// 次级强调按钮(规范§6.7):描边 accent + accent 文字hover 浅强调底;非裸文字。
applyTokenizedStyleSheet(
gen, QStringLiteral(
"QToolButton{border:1px solid {{accent/primary}};border-radius:%1px;"
"color:{{accent/primary}};background:transparent;padding:%2px %3px;font-size:%4px;}"
"QToolButton:hover{background:{{bg/selected}};}"
"QToolButton:pressed{background:{{bg/hover}};}")
.arg(radius::kSm)
.arg(scaledPx(space::kXxs))
.arg(scaledPx(space::kMd))
.arg(scaledPx(type::kCaption)));
connect(gen, &QToolButton::clicked, this, [this] { connect(gen, &QToolButton::clicked, this, [this] {
emit generateVolumeRequested(QString::fromStdString(spec_.dsTypeCode), checkedDsIds()); emit generateVolumeRequested(QString::fromStdString(spec_.dsTypeCode), checkedDsIds());
}); });
@ -65,6 +92,7 @@ CategorySection::CategorySection(const CategorySpec& spec, geopro::data::Dataset
// 筛选行:采集时间范围(在前)+ 装置类型(在后,仅 ERT 类)。 // 筛选行:采集时间范围(在前)+ 装置类型(在后,仅 ERT 类)。
auto* filterRow = new QHBoxLayout(); auto* filterRow = new QHBoxLayout();
filterRow->setSpacing(space::kSm);
dateRange_ = new DateRangeEdit(body_); dateRange_ = new DateRangeEdit(body_);
connect(dateRange_, &DateRangeEdit::rangeChanged, this, [this] { rebuildList(); }); connect(dateRange_, &DateRangeEdit::rangeChanged, this, [this] { rebuildList(); });
filterRow->addWidget(dateRange_, 1); filterRow->addWidget(dateRange_, 1);
@ -115,12 +143,26 @@ CategorySection::CategorySection(const CategorySpec& spec, geopro::data::Dataset
root->addWidget(body_, 1); root->addWidget(body_, 1);
connect(header_, &QToolButton::toggled, this, [this](bool on) { connect(header_, &QToolButton::toggled, this, [this, syncHeader](bool on) {
body_->setVisible(on); body_->setVisible(on);
header_->setArrowType(on ? Qt::DownArrow : Qt::RightArrow); syncHeader(); // ▾(展开)/▸(折叠) 切换
emit collapsedChanged(); // 外层据此把折叠段 stretch 归 0、展开段吸收余量 → 折叠向上收
}); });
} }
bool CategorySection::isExpanded() const { return header_ && header_->isChecked(); }
void CategorySection::ensureExpanded() {
if (header_ && !header_->isChecked()) header_->setChecked(true); // toggled→展开段体
}
QTreeWidgetItem* CategorySection::itemFor(const QString& dsId) const {
if (!list_ || dsId.isEmpty()) return nullptr;
for (QTreeWidgetItemIterator it(list_); *it; ++it)
if ((*it)->data(0, kDsIdRole).toString() == dsId) return *it;
return nullptr;
}
void CategorySection::setStructure(const std::vector<geopro::data::StructNode>& nodes) { void CategorySection::setStructure(const std::vector<geopro::data::StructNode>& nodes) {
structure_ = nodes; // 容器分层(项目根/GS/TM→ds在 Task 12 接入真实结构后据此构建。 structure_ = nodes; // 容器分层(项目根/GS/TM→ds在 Task 12 接入真实结构后据此构建。
} }
@ -150,6 +192,51 @@ void CategorySection::setChecked(const QString& dsId, bool on) {
} }
} }
void CategorySection::setBusy(const QString& dsId, bool on) {
QTreeWidgetItem* target = nullptr;
for (QTreeWidgetItemIterator it(list_); *it; ++it)
if ((*it)->data(0, kDsIdRole).toString() == dsId) { target = *it; break; }
if (!target) return;
{
// 改 busy/角度角色用 SignalBlocker不触发 itemChanged→emitChecked→重渲染viewport 仍重绘。
const QSignalBlocker block(list_);
target->setData(0, kDsBusyRole, on);
if (on) target->setData(0, Qt::CheckStateRole, Qt::Checked); // 渲染中保持勾选(仍属渲染集)
}
bool anyBusy = false;
for (QTreeWidgetItemIterator it(list_); *it; ++it)
if ((*it)->data(0, kDsBusyRole).toBool()) { anyBusy = true; break; }
if (anyBusy) {
if (!spinTimer_) {
spinTimer_ = new QTimer(this);
spinTimer_->setInterval(80);
connect(spinTimer_, &QTimer::timeout, this, [this]() {
spinAngle_ = (spinAngle_ + 30) % 360;
const QSignalBlocker block(list_);
for (QTreeWidgetItemIterator it(list_); *it; ++it)
if ((*it)->data(0, kDsBusyRole).toBool())
(*it)->setData(0, kDsSpinAngleRole, spinAngle_);
});
}
if (!spinTimer_->isActive()) spinTimer_->start();
} else if (spinTimer_) {
spinTimer_->stop();
}
if (list_->viewport()) list_->viewport()->update();
}
void CategorySection::clearAllBusy() {
const QSignalBlocker block(list_);
bool any = false;
for (QTreeWidgetItemIterator it(list_); *it; ++it)
if ((*it)->data(0, kDsBusyRole).toBool()) {
(*it)->setData(0, kDsBusyRole, false);
any = true;
}
if (spinTimer_) spinTimer_->stop();
if (any && list_->viewport()) list_->viewport()->update();
}
void CategorySection::refreshArrayCombo() { void CategorySection::refreshArrayCombo() {
if (!spec_.hasArrayTypeFilter || !arrayCombo_) return; if (!spec_.hasArrayTypeFilter || !arrayCombo_) return;
const QString prev = arrayCombo_->currentData().toString(); const QString prev = arrayCombo_->currentData().toString();
@ -313,14 +400,12 @@ void CategorySection::showContextMenu(const QPoint& pos) {
sl->addAction(QStringLiteral("前后"), this, [this, id] { emit sliceRequested(SliceAxis::FrontBack, id); }); sl->addAction(QStringLiteral("前后"), this, [this, id] { emit sliceRequested(SliceAxis::FrontBack, id); });
sl->addAction(QStringLiteral("左右"), this, [this, id] { emit sliceRequested(SliceAxis::LeftRight, id); }); sl->addAction(QStringLiteral("左右"), this, [this, id] { emit sliceRequested(SliceAxis::LeftRight, id); });
sl->addAction(QStringLiteral("任意"), this, [this, id] { emit sliceRequested(SliceAxis::Oblique, id); }); sl->addAction(QStringLiteral("任意"), this, [this, id] { emit sliceRequested(SliceAxis::Oblique, id); });
menu.addAction(QStringLiteral("色阶…"), this, [this, id] { emit colorScaleRequested(id); }); menu.addAction(QStringLiteral("色阶"), this, [this, id] { emit colorScaleRequested(id); });
} else if (ddCode == QStringLiteral("dd_slice")) { // 切片 } else if (ddCode == QStringLiteral("dd_slice")) { // 切片(列表中均为已保存=定稿锁定,无保存/另存)
menu.addAction(QStringLiteral("保存位姿"), this, [this, id] { emit sliceSaveRequested(id); });
menu.addAction(QStringLiteral("另存为…"), this, [this, id] { emit sliceSaveAsRequested(id); });
QMenu* ex = menu.addMenu(QStringLiteral("导出")); QMenu* ex = menu.addMenu(QStringLiteral("导出"));
ex->addAction(QStringLiteral("图片"), this, [this, id] { emit sliceExportImageRequested(id); }); ex->addAction(QStringLiteral("图片"), this, [this, id] { emit sliceExportImageRequested(id); });
ex->addAction(QStringLiteral("dat"), this, [this, id] { emit sliceExportDatRequested(id); }); ex->addAction(QStringLiteral("dat"), this, [this, id] { emit sliceExportDatRequested(id); });
menu.addAction(QStringLiteral("色阶"), this, [this, id] { emit colorScaleRequested(id); }); menu.addAction(QStringLiteral("色阶"), this, [this, id] { emit colorScaleRequested(id); });
menu.addSeparator(); menu.addSeparator();
menu.addAction(QStringLiteral("删除"), this, [this, id, ddCode] { emit deleteDatasetRequested(id, ddCode); }); menu.addAction(QStringLiteral("删除"), this, [this, id, ddCode] { emit deleteDatasetRequested(id, ddCode); });
} else if (ddCode == QStringLiteral("dd_anomaly")) { // 异常 } else if (ddCode == QStringLiteral("dd_anomaly")) { // 异常

View File

@ -7,10 +7,12 @@
#include "repo/RepoTypes.hpp" #include "repo/RepoTypes.hpp"
class QTreeWidget; class QTreeWidget;
class QTreeWidgetItem;
class QComboBox; class QComboBox;
class QDateEdit; class QDateEdit;
class QLabel; class QLabel;
class QToolButton; class QToolButton;
class QTimer;
class QWidget; class QWidget;
namespace geopro::data { namespace geopro::data {
@ -33,13 +35,21 @@ public:
void setStructure(const std::vector<geopro::data::StructNode>& nodes); void setStructure(const std::vector<geopro::data::StructNode>& nodes);
void setDatasets(const std::vector<geopro::data::DsRow>& rows); void setDatasets(const std::vector<geopro::data::DsRow>& rows);
void setChecked(const QString& dsId, bool on); // 按 dsId 勾选/取消(新建切片自动勾选等场景) void setChecked(const QString& dsId, bool on); // 按 dsId 勾选/取消(新建切片自动勾选等场景)
// 渲染中:该行复选框替换为等待 spinnerbusy=true/复原false。busy 期间保持勾选、动画由定时器驱动。
void setBusy(const QString& dsId, bool busy);
void clearAllBusy(); // 撤回本段所有 spinner失败兜底
void selectItem(const QString& dsId); // 程序化选中某行(VTK→list 反向联动);空/未找到=清选中 void selectItem(const QString& dsId); // 程序化选中某行(VTK→list 反向联动);空/未找到=清选中
QStringList checkedIds() const { return checkedDsIds(); } // 当前勾选 ds异常显隐同步用 QStringList checkedIds() const { return checkedDsIds(); } // 当前勾选 ds异常显隐同步用
void refreshArrayFilter() { refreshArrayCombo(); } // 装置枚举异步加载后重填下拉 void refreshArrayFilter() { refreshArrayCombo(); } // 装置枚举异步加载后重填下拉
const CategorySpec& spec() const { return spec_; } const CategorySpec& spec() const { return spec_; }
bool isExpanded() const; // 段头展开态(供外层按折叠状态重分配 stretch实现"折叠向上收"
void ensureExpanded(); // 展开本段(折叠则展开):滚动定位前确保目标行可见
QTreeWidget* listWidget() const { return list_; } // 段体树(外层滚动定位用)
QTreeWidgetItem* itemFor(const QString& dsId) const; // 按 dsId 取行(无则 nullptr
signals: signals:
void checkedDatasetsChanged(const QStringList& dsIds); // 数据行勾选=渲染 void checkedDatasetsChanged(const QStringList& dsIds); // 数据行勾选=渲染
void collapsedChanged(); // 折叠/展开切换 → 外层 CategoryAnalysisTab 重排各段 stretch
void generateVolumeRequested(const QString& dsTypeCode, const QStringList& sourceDsIds); // 段头「+新增三维体」 void generateVolumeRequested(const QString& dsTypeCode, const QStringList& sourceDsIds); // 段头「+新增三维体」
void detailRequested(const QString& dsId, const QString& ddCode, const QString& name); // 双击/右键=详情 void detailRequested(const QString& dsId, const QString& ddCode, const QString& name); // 双击/右键=详情
void deleteDatasetRequested(const QString& dsId, const QString& ddCode); // 右键删除(切片/异常) void deleteDatasetRequested(const QString& dsId, const QString& ddCode); // 右键删除(切片/异常)
@ -71,6 +81,8 @@ private:
QComboBox* arrayCombo_ = nullptr; // 装置类型筛选(仅 hasArrayTypeFilter QComboBox* arrayCombo_ = nullptr; // 装置类型筛选(仅 hasArrayTypeFilter
DateRangeEdit* dateRange_ = nullptr; // 采集时间范围筛选(起止双日历,可清空) DateRangeEdit* dateRange_ = nullptr; // 采集时间范围筛选(起止双日历,可清空)
QTreeWidget* list_ = nullptr; QTreeWidget* list_ = nullptr;
QTimer* spinTimer_ = nullptr; // 驱动 busy 行 spinner 旋转(有 busy 行时运行)
int spinAngle_ = 0; // 当前 spinner 角度(度)
}; };
} // namespace geopro::app } // namespace geopro::app

View File

@ -1,5 +1,8 @@
#include "panels/columns/Column2DDataset.hpp" #include "panels/columns/Column2DDataset.hpp"
#include <set>
#include <string>
#include <QComboBox> #include <QComboBox>
#include "EmptyAwareComboBox.hpp" #include "EmptyAwareComboBox.hpp"
@ -69,6 +72,7 @@ Column2DDataset::Column2DDataset(QWidget* parent) : QWidget(parent) {
list_ = new QTreeWidget(); list_ = new QTreeWidget();
list_->setHeaderHidden(true); list_->setHeaderHidden(true);
list_->setRootIsDecorated(true); list_->setRootIsDecorated(true);
list_->setSelectionMode(QAbstractItemView::ExtendedSelection); // 多选行(与 VTK 多选拖动联动)
applyDatasetCardDelegate(list_); applyDatasetCardDelegate(list_);
connect(list_, &QTreeWidget::itemChanged, this, [this](QTreeWidgetItem*, int) { connect(list_, &QTreeWidget::itemChanged, this, [this](QTreeWidgetItem*, int) {
QStringList ids; QStringList ids;
@ -78,19 +82,36 @@ Column2DDataset::Column2DDataset(QWidget* parent) : QWidget(parent) {
} }
emit checkedDatasetsChanged(ids); emit checkedDatasetsChanged(ids);
}); });
// 行选中变化 → 上抛选中 dsId(高亮联动 VTK与勾选/渲染独立)。
connect(list_, &QTreeWidget::itemSelectionChanged, this, [this]() {
QStringList ids;
for (QTreeWidgetItem* it : list_->selectedItems())
ids << it->data(0, kDsIdRole).toString();
emit selectedDatasetsChanged(ids);
});
root->addWidget(list_, 1); root->addWidget(list_, 1);
} }
void Column2DDataset::setDatasets(const std::vector<geopro::data::DsRow>& rows) { void Column2DDataset::setDatasets(const std::vector<geopro::data::DsRow>& rows) {
// 增量保留:记住当前已勾选的足迹 ds重建后复原仍存在的项保持勾选。否则对象树每次增删勾选都触发
// 本刷新 → 清空全部勾选 + 上抛空集 → 已渲染足迹被移除、列表选中丢失(用户反馈:必须增量更新,
// 与三维分析段 CategorySection::rebuildList 同一处理)。
std::set<std::string> wasChecked;
for (QTreeWidgetItemIterator it(list_); *it; ++it)
if ((*it)->checkState(0) == Qt::Checked)
wasChecked.insert((*it)->data(0, kDsIdRole).toString().toStdString());
{ {
QSignalBlocker blocker(list_); QSignalBlocker blocker(list_);
populateDatasetList(list_, rows, /*append=*/false); populateDatasetList(list_, rows, /*append=*/false);
for (QTreeWidgetItemIterator it(list_); *it; ++it) { for (QTreeWidgetItemIterator it(list_); *it; ++it) {
(*it)->setFlags((*it)->flags() | Qt::ItemIsUserCheckable); (*it)->setFlags((*it)->flags() | Qt::ItemIsUserCheckable);
(*it)->setCheckState(0, Qt::Unchecked); const std::string id = (*it)->data(0, kDsIdRole).toString().toStdString();
// 复原勾选:仍存在的曾勾选项保持勾选;新项默认不勾。
(*it)->setCheckState(0, wasChecked.count(id) ? Qt::Checked : Qt::Unchecked);
} }
} // blocker released here } // blocker released here
// 填充后统一发一次(新载入必为空选):清掉上一次的渲染勾选 // 上抛复原后的勾选集(保持渲染,不再清空 → 控制器据 diff 增量保留已渲染足迹,集合不变则不增删)。
QStringList ids; QStringList ids;
for (QTreeWidgetItemIterator it(list_); *it; ++it) for (QTreeWidgetItemIterator it(list_); *it; ++it)
if ((*it)->checkState(0) == Qt::Checked) if ((*it)->checkState(0) == Qt::Checked)
@ -98,4 +119,11 @@ void Column2DDataset::setDatasets(const std::vector<geopro::data::DsRow>& rows)
emit checkedDatasetsChanged(ids); emit checkedDatasetsChanged(ids);
} }
void Column2DDataset::setSelectedDsIds(const QStringList& dsIds) {
QSignalBlocker blocker(list_); // 防回环VTK→列表 设置选中不再上抛 selectedDatasetsChanged
list_->clearSelection();
for (QTreeWidgetItemIterator it(list_); *it; ++it)
if (dsIds.contains((*it)->data(0, kDsIdRole).toString())) (*it)->setSelected(true);
}
} // namespace geopro::app } // namespace geopro::app

View File

@ -14,12 +14,15 @@ class Column2DDataset : public QWidget {
public: public:
explicit Column2DDataset(QWidget* parent = nullptr); explicit Column2DDataset(QWidget* parent = nullptr);
void setDatasets(const std::vector<geopro::data::DsRow>& rows); void setDatasets(const std::vector<geopro::data::DsRow>& rows);
// VTK→列表 选择联动:按 dsId 选中对应行(高亮),内部屏蔽信号避免回环。
void setSelectedDsIds(const QStringList& dsIds);
signals: signals:
void basemapChanged(int index); // 0 天地图 / 1 Google / 2 隐藏 void basemapChanged(int index); // 0 天地图 / 1 Google / 2 隐藏
void view2DModeChanged(int index); // 0 关闭 /1 Z=0 /2 顶部 /3 底部 /4 自定义 void view2DModeChanged(int index); // 0 关闭 /1 Z=0 /2 顶部 /3 底部 /4 自定义
void customZChanged(double z); // 世界绝对高程(米),向上为正 void customZChanged(double z); // 世界绝对高程(米),向上为正
void checkedDatasetsChanged(const QStringList& dsIds); void checkedDatasetsChanged(const QStringList& dsIds); // 勾选(渲染开关)变化
void selectedDatasetsChanged(const QStringList& dsIds); // 行选中(高亮联动)变化,非勾选
private: private:
QTreeWidget* list_ = nullptr; QTreeWidget* list_ = nullptr;

View File

@ -25,6 +25,10 @@ ColumnDrawer::ColumnDrawer(QWidget* parent, geopro::data::DatasetFieldDictionary
tabs->addTab(analysisTab_, QStringLiteral("三维分析")); tabs->addTab(analysisTab_, QStringLiteral("三维分析"));
tabs->addTab(col2D_, QStringLiteral("二维分析")); tabs->addTab(col2D_, QStringLiteral("二维分析"));
tabs->tabBar()->setUsesScrollButtons(false); // 永不出左右滚动箭头(两 tab 必能平铺) tabs->tabBar()->setUsesScrollButtons(false); // 永不出左右滚动箭头(两 tab 必能平铺)
// 切 tab → 发 analysisModeChanged(is2D):以"当前 widget 是否 col2D"判定,不写死索引。
connect(tabs, &QTabWidget::currentChanged, this, [this, tabs](int idx) {
emit analysisModeChanged(tabs->widget(idx) == col2D_);
});
// 折叠按钮:固定宽 18px垂直拉伸。 // 折叠按钮:固定宽 18px垂直拉伸。
// 用 SVG 图标(makeGlyph)而非 ◀/▶ 文字——三角符(U+25C0/25B6)不在 YaHei作按钮文字会触发 // 用 SVG 图标(makeGlyph)而非 ◀/▶ 文字——三角符(U+25C0/25B6)不在 YaHei作按钮文字会触发
@ -79,6 +83,7 @@ void ColumnDrawer::toggleCollapsed()
// 折叠后只保留按钮宽度;展开恢复可调范围 // 折叠后只保留按钮宽度;展开恢复可调范围
setMinimumWidth(collapsed_ ? 0 : 180); setMinimumWidth(collapsed_ ? 0 : 180);
setMaximumWidth(collapsed_ ? 18 : 560); setMaximumWidth(collapsed_ ? 18 : 560);
emit collapsedChanged(collapsed_); // 通知上层调 QSplitter 尺寸,回收/恢复栏宽(防残留空白)
} }
void ColumnDrawer::expand() void ColumnDrawer::expand()

View File

@ -23,6 +23,12 @@ public:
Column2DDataset* col2D() const { return col2D_; } Column2DDataset* col2D() const { return col2D_; }
CategoryAnalysisTab* analysisTab() const { return analysisTab_; } CategoryAnalysisTab* analysisTab() const { return analysisTab_; }
signals:
// 切换「三维分析 / 二维分析」tabis2D=true 进入二维分析。上层据此切相机+翻维度可见标志。
void analysisModeChanged(bool is2D);
// 折叠/展开切换:上层据此调整 QSplitter 尺寸,回收/恢复抽屉栏宽(否则收起残留空白区)。
void collapsedChanged(bool collapsed);
public slots: public slots:
void toggleCollapsed(); void toggleCollapsed();
void expand(); // 强制展开(进入全屏时确保三栏可见) void expand(); // 强制展开(进入全屏时确保三栏可见)

View File

@ -0,0 +1,63 @@
#include "panels/web/ProjectWebView.hpp"
#include <QUrl>
#include <QVBoxLayout>
#include <QWebEnginePage>
#include <QWebEngineScript>
#include <QWebEngineScriptCollection>
#include <QWebEngineView>
namespace geopro::app {
namespace {
// 把字符串转成安全的 JS 字面量(带引号、转义),用于拼进注入脚本。
QString jsStringLiteral(const QString& s) {
// QJsonValue::toJson 不直接给单值字符串手工转义足够token 仅含 base64/空格)。
QString out;
out.reserve(s.size() + 2);
out += QLatin1Char('"');
for (const QChar c : s) {
switch (c.unicode()) {
case '\\': out += QStringLiteral("\\\\"); break;
case '"': out += QStringLiteral("\\\""); break;
case '\n': out += QStringLiteral("\\n"); break;
case '\r': out += QStringLiteral("\\r"); break;
case '\t': out += QStringLiteral("\\t"); break;
default: out += c; break;
}
}
out += QLatin1Char('"');
return out;
}
} // namespace
ProjectWebView::ProjectWebView(const QString& token, QWidget* parent) : QWidget(parent) {
auto* lay = new QVBoxLayout(this);
lay->setContentsMargins(0, 0, 0, 0);
lay->setSpacing(0);
view_ = new QWebEngineView(this);
lay->addWidget(view_, 1);
// token 注入DocumentCreation 阶段把登录 token 写入 localStorage["token"]
// 早于嵌入页 SPA 启动脚本,保证其读取鉴权时已就绪。每次 load 都会重新执行。
if (!token.isEmpty()) {
QWebEngineScript script;
script.setName(QStringLiteral("inject-geopro-token"));
script.setInjectionPoint(QWebEngineScript::DocumentCreation);
script.setWorldId(QWebEngineScript::MainWorld);
script.setRunsOnSubFrames(true);
script.setSourceCode(
QStringLiteral("try{localStorage.setItem('token', %1);}catch(e){}")
.arg(jsStringLiteral(token)));
view_->page()->scripts().insert(script);
}
}
void ProjectWebView::load(const QString& url) {
view_->load(QUrl(url));
}
} // namespace geopro::app

View File

@ -0,0 +1,25 @@
#pragma once
#include <QString>
#include <QWidget>
class QWebEngineView;
namespace geopro::app {
// 项目管理 webview 宿主:内嵌 QWebEngineView承载需「直接嵌入」的 web 管理页
// (在线监测 / 工具组件 / 批量导出 / 告警管理)。
// 构造期注入 DocumentCreation 脚本,把登录 token 写入页面 localStorage["token"]
// 早于页面自身脚本执行,确保 web 端读取鉴权时已就绪。
class ProjectWebView : public QWidget {
Q_OBJECT
public:
explicit ProjectWebView(const QString& token, QWidget* parent = nullptr);
// 加载嵌入页(完整 URL含 #/embed?space=..&projectId=..&target=..)。
void load(const QString& url);
private:
QWebEngineView* view_ = nullptr;
};
} // namespace geopro::app

View File

@ -2,6 +2,7 @@ find_package(Qt6 COMPONENTS Core REQUIRED)
add_library(geopro_controller STATIC add_library(geopro_controller STATIC
WorkbenchNavController.cpp WorkbenchNavController.cpp
DatasetDetailController.cpp DatasetDetailController.cpp
DatasetViewState.cpp
VtkSceneController.cpp) VtkSceneController.cpp)
target_include_directories(geopro_controller PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) target_include_directories(geopro_controller PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
target_link_libraries(geopro_controller PUBLIC geopro_data Qt6::Core) target_link_libraries(geopro_controller PUBLIC geopro_data Qt6::Core)

View File

@ -0,0 +1,2 @@
#include "DatasetViewState.hpp"
// 实现全部内联于头;此 .cpp 仅为让 AUTOMOC 为带 Q_OBJECT 的头生成并链接 moc。

View File

@ -0,0 +1,49 @@
#pragma once
#include <QHash>
#include <QObject>
#include <QString>
#include "model/ColorScale.hpp"
namespace geopro::controller {
// 跨视图共享的「单一真源」会话状态,按 dsId 维护。统一所有视图间同步,取代两两接线:
// - 改色阶:任何编辑入口只调 setColorScale(dsId, cs),不再各改各的拷贝;
// - 观察:各视图连一次 colorScaleChanged(dsId),槽里【只重渲染】、【绝不回写】→ 无信号回环;
// - 加载:视图取色阶时先问 hubcolorScale 非空则用之),否则把后端值 seed 进来当真源。
// 新增同步项(可见性/选中/值域…)= 加一个字段 + 一个 xxxChanged(dsId) 信号,沿用同一套机制。
//
// 作用域:数据集的「默认/共享色阶」(后端 businessCode=""),被 反演散点/网格/帘面/体 共用(同一条后端
// 记录。measurement(businessCode="R0") 为单视图、无跨视图伙伴,暂不入此层(清晰边界,非欠债)。
class DatasetViewState : public QObject {
Q_OBJECT
public:
explicit DatasetViewState(QObject* parent = nullptr) : QObject(parent) {}
bool hasColorScale(const QString& dsId) const { return scales_.contains(dsId); }
// 无记录返回 nullptr调用方据此兜底为自带值
const geopro::core::ColorScale* colorScale(const QString& dsId) const {
auto it = scales_.constFind(dsId);
return it == scales_.constEnd() ? nullptr : &it.value();
}
// 用户编辑应用:写入真源并广播。观察者据此重渲染。
void setColorScale(const QString& dsId, const geopro::core::ColorScale& cs) {
scales_.insert(dsId, cs);
emit colorScaleChanged(dsId);
}
// 首次从后端加载得到色阶时播种:已有则不覆盖、不广播(避免加载即触发重建/存盘)。
void seedColorScale(const QString& dsId, const geopro::core::ColorScale& cs) {
if (!scales_.contains(dsId)) scales_.insert(dsId, cs);
}
signals:
void colorScaleChanged(const QString& dsId);
private:
QHash<QString, geopro::core::ColorScale> scales_;
};
} // namespace geopro::controller

View File

@ -31,6 +31,8 @@ public:
virtual void clear() = 0; virtual void clear() = 0;
virtual void setVerticalExaggeration(double ve) = 0; virtual void setVerticalExaggeration(double ve) = 0;
// 三维体体绘制最大不透明度0~1运行时调节已渲染体 + 后续新体(默认 0.30)。默认空实现,测试 mock 无需覆盖。
virtual void setVolumeOpacity(double maxOpacity) { (void)maxOpacity; }
// 地表高程基准测线地表高程2D 足迹「顶部/底部」摆放锚定真实地表。 // 地表高程基准测线地表高程2D 足迹「顶部/底部」摆放锚定真实地表。
virtual double zRefElev() const = 0; virtual double zRefElev() const = 0;
@ -42,6 +44,10 @@ public:
// 3D体绘制IDW 体素 + colorScale按 dsId 跟踪。 // 3D体绘制IDW 体素 + colorScale按 dsId 跟踪。
virtual void addVolume(const std::string& dsId, const geopro::data::VolumeGrid& vol, virtual void addVolume(const std::string& dsId, const geopro::data::VolumeGrid& vol,
const geopro::core::ColorScale& cs) = 0; const geopro::core::ColorScale& cs) = 0;
// 原地更新已渲染体颜色/不透明度(仅换传函、不重建 image色阶改动用避免换 image 连带关闭未保存切片。
// 返回 true=已原地更新false=该体未渲染/不支持 → 调用方回退 remove+add。默认 false。
virtual bool updateVolumeColorInPlace(const std::string& /*dsId*/,
const geopro::core::ColorScale& /*cs*/) { return false; }
// 2D 足迹:把测线/轨迹经纬折线平铺进 3D 地图worldZ=摆放高程);按 dsId 跟踪以支持增量移除。 // 2D 足迹:把测线/轨迹经纬折线平铺进 3D 地图worldZ=摆放高程);按 dsId 跟踪以支持增量移除。
virtual void addMapLine(const std::string& dsId, const geopro::data::MapLine& line, virtual void addMapLine(const std::string& dsId, const geopro::data::MapLine& line,
double worldZ) = 0; double worldZ) = 0;

View File

@ -4,8 +4,10 @@
#include <set> #include <set>
#include <utility> #include <utility>
#include <QDebug>
#include <QPointer> #include <QPointer>
#include "DatasetViewState.hpp"
#include "I3dSceneView.hpp" #include "I3dSceneView.hpp"
#include "repo/IDatasetRepository.hpp" #include "repo/IDatasetRepository.hpp"
@ -23,6 +25,39 @@ VtkSceneController::VtkSceneController(data::IDatasetRepository& dsRepo,
QObject* parent) QObject* parent)
: QObject(parent), dsRepo_(dsRepo), sceneRepo_(sceneRepo), view_(view) {} : QObject(parent), dsRepo_(dsRepo), sceneRepo_(sceneRepo), view_(view) {}
void VtkSceneController::setViewState(DatasetViewState* state) {
state_ = state;
if (state_)
connect(state_, &DatasetViewState::colorScaleChanged, this,
&VtkSceneController::recolorDataset);
}
void VtkSceneController::recolorDataset(const QString& qid) {
if (!state_) return;
const geopro::core::ColorScale* cs = state_->colorScale(qid);
if (!cs) return;
const std::string dsId = qid.toStdString();
volumeScaleCache_[dsId] = *cs; // 体色阶随真源更新(未渲染时下次勾选命中)
if (!isChecked(dsId)) return; // 未渲染 → 仅更缓存
// 就地重建:体 → 用新色阶重 addVolumeaddVolume 内部触发体下切片随新色阶重建);
// 帘面 → 用缓存源网格重 addCurtain。一个 dsId 只会是其一。
bool changed = false;
if (auto vit = volumeCache_.find(dsId); vit != volumeCache_.end()) {
// 优先原地改色(仅换传函、不重建 image→ 该体下未保存切片不被关闭、跟随改色。
// 原地失败(理论不至)才回退 remove+add会关未保存切片
if (!view_.updateVolumeColorInPlace(dsId, *cs)) {
view_.removeDataset(dsId);
view_.addVolume(dsId, vit->second, *cs);
}
changed = true;
} else if (auto sit = sectionGridCache_.find(dsId); sit != sectionGridCache_.end()) {
view_.removeDataset(dsId);
view_.addCurtain(dsId, sit->second, *cs);
changed = true;
}
if (changed) view_.renderIncremental();
}
void VtkSceneController::setCheckedDatasets(const QStringList& dsIds) { void VtkSceneController::setCheckedDatasets(const QStringList& dsIds) {
std::vector<std::string> newDs; std::vector<std::string> newDs;
newDs.reserve(static_cast<std::size_t>(dsIds.size())); newDs.reserve(static_cast<std::size_t>(dsIds.size()));
@ -46,7 +81,8 @@ void VtkSceneController::setCheckedDatasets(const QStringList& dsIds) {
// 取景意图按「场景是否已有数据到场过」判定,而非 checkedDs_ 是否空——否则连续快速勾选第二个 // 取景意图按「场景是否已有数据到场过」判定,而非 checkedDs_ 是否空——否则连续快速勾选第二个
// ds 时 checkedDs_ 已非空但首批尚未到场,会被误清取景意图,相机不对准数据 → 看似不渲染。 // ds 时 checkedDs_ 已非空但首批尚未到场,会被误清取景意图,相机不对准数据 → 看似不渲染。
fitOnArrival_ = !hadArrivedData_; fitOnArrival_ = !hadArrivedData_;
if (checkedDs_.empty()) hadArrivedData_ = false; // 全取消 → 下批到场重新取景 // 仅当 3D 与 2D 都空才复位取景基线:否则取消全部 3D 但仍有 2D 足迹时,下个 3D 勾选会误跳取景。
if (checkedDs_.empty() && checked2dDs_.empty()) hadArrivedData_ = false;
const unsigned long long gen = rebuildGeneration_; // 不自增:并发增量互不作废 const unsigned long long gen = rebuildGeneration_; // 不自增:并发增量互不作废
for (const auto& id : checkedDs_) for (const auto& id : checkedDs_)
@ -63,14 +99,16 @@ void VtkSceneController::setChecked2DDatasets(const QStringList& dsIds) {
// 二维足迹始终画进 View3D 场景,且按 dsId 跟踪 → 一律增量 diff不全量重建不打断 3D 帘面/体)。 // 二维足迹始终画进 View3D 场景,且按 dsId 跟踪 → 一律增量 diff不全量重建不打断 3D 帘面/体)。
const std::set<std::string> oldSet(checked2dDs_.begin(), checked2dDs_.end()); const std::set<std::string> oldSet(checked2dDs_.begin(), checked2dDs_.end());
const std::set<std::string> newSet(newDs.begin(), newDs.end()); const std::set<std::string> newSet(newDs.begin(), newDs.end());
// 此前空场景(无 3D 数据且无 2D 足迹) → 首批足迹到场自动取景;否则增量追加保持相机不跳。
const bool wasEmpty = checkedDs_.empty() && checked2dDs_.empty();
for (const auto& id : checked2dDs_) for (const auto& id : checked2dDs_)
if (!newSet.count(id)) view_.removeDataset(id); // 取消勾选 → 移除该足迹图元 if (!newSet.count(id)) view_.removeDataset(id); // 取消勾选 → 移除该足迹图元
checked2dDs_ = std::move(newDs); checked2dDs_ = std::move(newDs);
fitOnArrival_ = wasEmpty; // 首批足迹(空场景)取景;否则保持当前相机不跳 // 取景基线与 3D 路径统一用 hadArrivedData_而非"两栏皆空"):否则二维分析下若已有隐藏的 3D 数据,
// 勾选首条足迹会因 wasEmpty=false 而不取景 → 足迹落在视野外。切 tab 时 onAnalysisModeChanged 已按
// 目标维度是否有数据重置该基线,故此处首条可见维度数据能正确取景。
fitOnArrival_ = !hadArrivedData_;
if (checkedDs_.empty() && checked2dDs_.empty()) hadArrivedData_ = false;
// 足迹画进 View3D 场景mode=0 关闭 → 仅记录勾选不渲染(见 set2DPlacement 切回时补画)。 // 足迹画进 View3D 场景mode=0 关闭 → 仅记录勾选不渲染(见 set2DPlacement 切回时补画)。
if (placement2dMode_ != 0 && mode_ == ViewMode::View3D) { if (placement2dMode_ != 0 && mode_ == ViewMode::View3D) {
@ -145,11 +183,17 @@ void VtkSceneController::addDatasetAsync(const std::string& dsId, unsigned long
auto cachedGrid = volumeCache_.find(dsId); auto cachedGrid = volumeCache_.find(dsId);
auto cachedScale = volumeScaleCache_.find(dsId); auto cachedScale = volumeScaleCache_.find(dsId);
if (cachedGrid != volumeCache_.end() && cachedScale != volumeScaleCache_.end()) { if (cachedGrid != volumeCache_.end() && cachedScale != volumeScaleCache_.end()) {
view_.addVolume(dsId, cachedGrid->second, cachedScale->second); // 缓存命中(色阶随体缓存) const QString qid = QString::fromStdString(dsId); // 优先用色阶真源(含已编辑值)
const geopro::core::ColorScale& useCs =
(state_ && state_->colorScale(qid)) ? *state_->colorScale(qid) : cachedScale->second;
view_.addVolume(dsId, cachedGrid->second, useCs); // 缓存命中(色阶随体缓存)
onDatasetArrived(); onDatasetArrived();
emit volumeRendered(QString::fromStdString(dsId)); // 缓存命中即时完成 → 撤 spinner
emit datasetRendered(QString::fromStdString(dsId));
return; return;
} }
loadingDs_.insert(dsId); loadingDs_.insert(dsId);
emit datasetLoading(QString::fromStdString(dsId)); // 异步建体开始 → 列表项转 spinner
sceneRepo_.loadVolume( sceneRepo_.loadVolume(
dsId, dsId,
[self, gen, dsId](data::VolumeGrid g, core::ColorScale cs) { [self, gen, dsId](data::VolumeGrid g, core::ColorScale cs) {
@ -157,9 +201,19 @@ void VtkSceneController::addDatasetAsync(const std::string& dsId, unsigned long
self->loadingDs_.erase(dsId); self->loadingDs_.erase(dsId);
if (gen != self->rebuildGeneration_ || !self->isChecked(dsId)) return; if (gen != self->rebuildGeneration_ || !self->isChecked(dsId)) return;
self->volumeScaleCache_[dsId] = cs; // 色阶随体一起缓存mock 体在 dsRepo_ 无条目) self->volumeScaleCache_[dsId] = cs; // 色阶随体一起缓存mock 体在 dsRepo_ 无条目)
const QString qid = QString::fromStdString(dsId);
if (self->state_) self->state_->seedColorScale(qid, cs); // 播种真源
auto it = self->volumeCache_.emplace(dsId, std::move(g)).first; auto it = self->volumeCache_.emplace(dsId, std::move(g)).first;
self->view_.addVolume(dsId, it->second, self->volumeScaleCache_[dsId]); qInfo().noquote() << "[volrender] addVolume dsId=" << qid
<< "nx=" << it->second.vol.nx() << "ny=" << it->second.vol.ny()
<< "nz=" << it->second.vol.nz();
const geopro::core::ColorScale& useCs =
(self->state_ && self->state_->colorScale(qid)) ? *self->state_->colorScale(qid)
: self->volumeScaleCache_[dsId];
self->view_.addVolume(dsId, it->second, useCs);
self->onDatasetArrived(); self->onDatasetArrived();
emit self->volumeRendered(QString::fromStdString(dsId)); // 落地完成 → 撤 spinner
emit self->datasetRendered(QString::fromStdString(dsId));
}, },
[self, gen, dsId](const std::string& m) { [self, gen, dsId](const std::string& m) {
if (!self) return; if (!self) return;
@ -172,14 +226,22 @@ void VtkSceneController::addDatasetAsync(const std::string& dsId, unsigned long
// 剖面 → 帘面(着色用 loadSection 返回的 s.scale与体的源色阶同源 // 剖面 → 帘面(着色用 loadSection 返回的 s.scale与体的源色阶同源
loadingDs_.insert(dsId); loadingDs_.insert(dsId);
emit datasetLoading(QString::fromStdString(dsId)); // 剖面首次加载较慢 → 列表项转 spinner
sceneRepo_.loadSection( sceneRepo_.loadSection(
dsId, dsId,
[self, gen, dsId](data::SectionData s) { [self, gen, dsId](data::SectionData s) {
if (!self) return; if (!self) return;
self->loadingDs_.erase(dsId); self->loadingDs_.erase(dsId);
if (gen != self->rebuildGeneration_ || !self->isChecked(dsId)) return; // 作废/已取消 if (gen != self->rebuildGeneration_ || !self->isChecked(dsId)) return; // 作废/已取消
self->view_.addCurtain(dsId, s.grid, s.scale); self->sectionGridCache_.insert_or_assign(dsId, s.grid); // 留存源网格供帘面重着色Grid 无默认构造)
const QString qid = QString::fromStdString(dsId);
if (self->state_) self->state_->seedColorScale(qid, s.scale); // 播种真源
const geopro::core::ColorScale& useCs =
(self->state_ && self->state_->colorScale(qid)) ? *self->state_->colorScale(qid)
: s.scale;
self->view_.addCurtain(dsId, s.grid, useCs);
self->onDatasetArrived(); self->onDatasetArrived();
emit self->datasetRendered(QString::fromStdString(dsId)); // 帘面落地 → 复原复选框
}, },
[self, gen, dsId](const std::string& m) { [self, gen, dsId](const std::string& m) {
if (!self) return; if (!self) return;
@ -210,6 +272,14 @@ void VtkSceneController::setViewMode(ViewMode mode) {
rebuildInternal(); rebuildInternal();
} }
void VtkSceneController::onAnalysisModeChanged(bool is2D) {
// 切「三维分析/二维分析」tab按目标维度是否已有数据重置取景基线。
// 目标维度空 → hadArrivedData_=false切换后该维度第一条数据自动取景(治"3D 数据不知生成到哪")。
// 目标维度非空 → hadArrivedData_=true视图切换时已 fit 到该维度,后续勾选不再跳(与三维一致)。
// 显隐/相机/坐标轴由 VtkSceneView::setAnalysisMode2D 处理(上层在同一处调用);此处只管取景基线。
hadArrivedData_ = is2D ? !checked2dDs_.empty() : !checkedDs_.empty();
}
void VtkSceneController::setLayer(SceneLayer layer, bool on) { void VtkSceneController::setLayer(SceneLayer layer, bool on) {
switch (layer) { switch (layer) {
case SceneLayer::Curtain: showCurtain_ = on; break; case SceneLayer::Curtain: showCurtain_ = on; break;
@ -229,16 +299,25 @@ void VtkSceneController::setVerticalExaggeration(double ve) {
preserveCameraOnRebuild_ = false; preserveCameraOnRebuild_ = false;
} }
void VtkSceneController::setVolumeOpacity(double maxOpacity) {
// 运行时更新已渲染体的不透明度传递函数(不重建体,实时跟手)+ 记为后续新体默认(见 VtkSceneView
view_.setVolumeOpacity(maxOpacity);
}
void VtkSceneController::rebuild() { rebuildInternal(); } void VtkSceneController::rebuild() { rebuildInternal(); }
void VtkSceneController::setVolumeColorScale(const std::string& dsId, void VtkSceneController::setVolumeColorScale(const std::string& dsId,
const geopro::core::ColorScale& cs) { const geopro::core::ColorScale& cs) {
volumeScaleCache_[dsId] = cs; // 会话级 mock 持久(再勾选命中缓存,见 addDatasetAsync // 统一走色阶真源:写入即广播 colorScaleChanged → recolorDataset 就地重着色(体/帘面+切片),
if (!isChecked(dsId)) return; // 未渲染 → 仅更缓存,下次勾选生效 // 同时 2D 详情等其它视图一并跟随。无 state_理论不至才退化为直连重建。
if (state_) {
state_->setColorScale(QString::fromStdString(dsId), cs);
return;
}
volumeScaleCache_[dsId] = cs;
if (!isChecked(dsId)) return;
auto git = volumeCache_.find(dsId); auto git = volumeCache_.find(dsId);
if (git == volumeCache_.end()) return; // 体网格尚未到场 → 同上 if (git == volumeCache_.end()) return;
// 移除旧体素 → 以新色阶重建addVolume 内部置 currentColorScale_ 并触发 onVolumeChanged
// InteractionManager 据此以新色阶重建该体下已勾选切片。
view_.removeDataset(dsId); view_.removeDataset(dsId);
view_.addVolume(dsId, git->second, cs); view_.addVolume(dsId, git->second, cs);
view_.renderIncremental(); view_.renderIncremental();

View File

@ -1,5 +1,6 @@
#pragma once #pragma once
#include <QObject> #include <QObject>
#include <QPointer>
#include <QString> #include <QString>
#include <QStringList> #include <QStringList>
#include <map> #include <map>
@ -18,6 +19,8 @@ class IDatasetRepository;
namespace geopro::controller { namespace geopro::controller {
class DatasetViewState; // 跨视图共享色阶真源(统一同步机制)
// 中央视图模式:二维地图(俯视测线)/ 三维视图(帘面/体素/地形)。 // 中央视图模式:二维地图(俯视测线)/ 三维视图(帘面/体素/地形)。
enum class ViewMode { Map2D, View3D }; enum class ViewMode { Map2D, View3D };
@ -35,6 +38,10 @@ public:
VtkSceneController(data::IDatasetRepository& dsRepo, data::I3dSceneRepository& sceneRepo, VtkSceneController(data::IDatasetRepository& dsRepo, data::I3dSceneRepository& sceneRepo,
I3dSceneView& view, QObject* parent = nullptr); I3dSceneView& view, QObject* parent = nullptr);
// 注入跨视图色阶真源(统一同步):连 colorScaleChanged → 就地按 dsId 重着色帘面/体。
// 构造后由 main.cpp 注入一次。
void setViewState(DatasetViewState* state);
public slots: public slots:
void setCheckedDatasets(const QStringList& dsIds); void setCheckedDatasets(const QStringList& dsIds);
// 二维数据集栏勾选(足迹型测线/轨迹)→ 平铺进 View3D 地图。与 3D 勾选集独立,按 dsId 增量。 // 二维数据集栏勾选(足迹型测线/轨迹)→ 平铺进 View3D 地图。与 3D 勾选集独立,按 dsId 增量。
@ -42,12 +49,17 @@ public slots:
// 二维足迹摆放高度mode0关闭 /1 Z=0 /2 顶部 /3 底部 /4 自定义customZ 仅 mode=4 用)。 // 二维足迹摆放高度mode0关闭 /1 Z=0 /2 顶部 /3 底部 /4 自定义customZ 仅 mode=4 用)。
void set2DPlacement(int mode, double customZ); void set2DPlacement(int mode, double customZ);
void setViewMode(ViewMode mode); void setViewMode(ViewMode mode);
// 切「三维分析/二维分析」tabA 期):按目标维度是否已有数据重置取景基线,使切换后该维度第一条
// 数据自动取景。显隐/相机/坐标轴由 VtkSceneView::setAnalysisMode2D 负责(上层在同一处一并调用)。
void onAnalysisModeChanged(bool is2D);
void setLayer(SceneLayer layer, bool on); void setLayer(SceneLayer layer, bool on);
void setVerticalExaggeration(double ve); void setVerticalExaggeration(double ve);
// 三维体透明度调节工具条滑块运行时更新已渲染体的不透明度并作为后续新体默认0~1
void setVolumeOpacity(double maxOpacity);
void rebuild(); // 主题切换等外部触发的重渲染 void rebuild(); // 主题切换等外部触发的重渲染
// 色阶编辑器「确定」:更新某三维体色阶并就地重渲染(体素 + 其切片随新色阶重建)。 // 色阶编辑器「确定」:写入色阶真源(state_),经 colorScaleChanged 统一就地重着色(体/帘面 + 切片)。
// 后端 3D 色阶保存未就绪 → 缓存即会话级 mock 持久(再勾选命中 volumeScaleCache_ // 兼容旧调用点;真正的重着色在 recolorDataset()。无 state_ 时退化为直连重建
void setVolumeColorScale(const std::string& dsId, const geopro::core::ColorScale& cs); void setVolumeColorScale(const std::string& dsId, const geopro::core::ColorScale& cs);
// ── P2 三维数据集栏 ── // ── P2 三维数据集栏 ──
@ -63,9 +75,17 @@ public slots:
signals: signals:
void loadFailed(const QString& message); void loadFailed(const QString& message);
// 三维体异步建体+落地渲染完成dsId。供 UI 撤回该体列表项的等待 spinner、复原复选框。
void volumeRendered(const QString& dsId);
// 任一数据集(剖面/体)异步加载开始 / 渲染完成:上层据此把该列表项复选框↔等待 spinner 切换。
// 仅异步路径发(缓存命中即时完成只发 rendered覆盖非三维体剖面首次渲染也较慢用户反馈
void datasetLoading(const QString& dsId);
void datasetRendered(const QString& dsId);
private: private:
void rebuildInternal(); void rebuildInternal();
// colorScaleChanged(dsId) 槽:从 state_ 取新色阶,就地重建该 dsId 的帘面/体(及体下切片)。只渲染,不回写。
void recolorDataset(const QString& dsId);
// 增量加入单个 ds帘面/体素,按图层开关);回调按 gen + 仍勾选 守护,落地后增量渲染。 // 增量加入单个 ds帘面/体素,按图层开关);回调按 gen + 仍勾选 守护,落地后增量渲染。
void addDatasetAsync(const std::string& dsId, unsigned long long gen); void addDatasetAsync(const std::string& dsId, unsigned long long gen);
// 增量加入单个 2D 足迹(异步 loadMapLine → addMapLine at 当前摆放 Z回调按 gen + 仍勾选 守护。 // 增量加入单个 2D 足迹(异步 loadMapLine → addMapLine at 当前摆放 Z回调按 gen + 仍勾选 守护。
@ -102,9 +122,13 @@ private:
AxisRangeCfg axisX_, axisY_, axisZ_; // 坐标轴设置面板的 per-axis 可见性 + 自定义范围 AxisRangeCfg axisX_, axisY_, axisZ_; // 坐标轴设置面板的 per-axis 可见性 + 自定义范围
static constexpr int kAxesFontSize = 12; static constexpr int kAxesFontSize = 12;
QPointer<DatasetViewState> state_; // 跨视图色阶真源(注入;编辑/加载/重着色都经它QPointer 防悬挂)
// 缓存(按 dsId避免重复读盘/插值。 // 缓存(按 dsId避免重复读盘/插值。
std::map<std::string, geopro::core::Grid> gridCache_; std::map<std::string, geopro::core::Grid> gridCache_;
std::map<std::string, geopro::core::ColorScale> colorScaleCache_; std::map<std::string, geopro::core::ColorScale> colorScaleCache_;
// 帘面源网格缓存:帘面重着色需 grid 重建 addCurtainloadSection 的 s.grid 不在 gridCache_
std::map<std::string, geopro::core::Grid> sectionGridCache_;
std::map<std::string, data::VolumeGrid> volumeCache_; std::map<std::string, data::VolumeGrid> volumeCache_;
// 三维体色阶缓存mock 体在 dsRepo_ 无条目,色阶随 loadVolume 一起交付并缓存于此。 // 三维体色阶缓存mock 体在 dsRepo_ 无条目,色阶随 loadVolume 一起交付并缓存于此。
std::map<std::string, geopro::core::ColorScale> volumeScaleCache_; std::map<std::string, geopro::core::ColorScale> volumeScaleCache_;

View File

@ -3,10 +3,12 @@
#include <algorithm> #include <algorithm>
#include <cmath> #include <cmath>
#include <cstddef> #include <cstddef>
#include <cstdint>
#include <limits> #include <limits>
#include <numeric>
#include <stdexcept> #include <stdexcept>
#include <unordered_map>
#include "algo/IdwInterpolator.hpp" #include <vector>
namespace geopro::core { namespace geopro::core {
@ -24,10 +26,64 @@ void fitAxis(double ext, double cell, double& outCell, int& outN) {
outN = kMaxVolumeDim; outN = kMaxVolumeDim;
outCell = ext / static_cast<double>(kMaxVolumeDim - 1); // maxDim 格覆盖全 ext outCell = ext / static_cast<double>(kMaxVolumeDim - 1); // maxDim 格覆盖全 ext
} }
// 平面凸包Andrew monotone chain返回 CCW 顶点;末点=首点已去重)。点 <3 / 共线退化 → 空。
struct Hull2D { std::vector<double> x, y; };
// (A-O) × (B-O)>0 表示 B 在有向边 O→A 左侧。
double cross2(double ox, double oy, double ax, double ay, double bx, double by) {
return (ax - ox) * (by - oy) - (ay - oy) * (bx - ox);
}
Hull2D convexHull2D(const std::vector<double>& xs, const std::vector<double>& ys) {
Hull2D hull;
const std::size_t n = xs.size();
if (n < 3) return hull;
std::vector<std::size_t> idx(n);
for (std::size_t i = 0; i < n; ++i) idx[i] = i;
std::sort(idx.begin(), idx.end(), [&](std::size_t a, std::size_t b) {
return xs[a] < xs[b] || (xs[a] == xs[b] && ys[a] < ys[b]);
});
std::vector<std::size_t> h(2 * n);
int k = 0;
for (std::size_t ii = 0; ii < n; ++ii) { // 下凸包
const std::size_t i = idx[ii];
while (k >= 2 &&
cross2(xs[h[k - 2]], ys[h[k - 2]], xs[h[k - 1]], ys[h[k - 1]], xs[i], ys[i]) <= 0)
--k;
h[k++] = i;
}
const int lower = k + 1;
for (std::size_t ii = n; ii-- > 0;) { // 上凸包
const std::size_t i = idx[ii];
while (k >= lower &&
cross2(xs[h[k - 2]], ys[h[k - 2]], xs[h[k - 1]], ys[h[k - 1]], xs[i], ys[i]) <= 0)
--k;
h[k++] = i;
}
if (k - 1 < 3) return hull; // 去末点后仍 <3 → 退化
hull.x.reserve(k - 1); hull.y.reserve(k - 1);
for (int t = 0; t < k - 1; ++t) { hull.x.push_back(xs[h[t]]); hull.y.push_back(ys[h[t]]); }
return hull;
}
// 点是否在 CCW 凸多边形内含边界buf=向外缓冲(米,保边界整列不被误裁)。
bool inHull(const Hull2D& hull, double px, double py, double buf) {
const std::size_t m = hull.x.size();
for (std::size_t i = 0; i < m; ++i) {
const std::size_t j = (i + 1) % m;
const double ex = hull.x[j] - hull.x[i], ey = hull.y[j] - hull.y[i];
const double len = std::sqrt(ex * ex + ey * ey);
const double c = cross2(hull.x[i], hull.y[i], hull.x[j], hull.y[j], px, py);
// c/len = 点到该边的有符号垂距(内侧为正)< -buf 即在多边形外超过缓冲 → 排除。
if (len > 0.0 && c < -buf * len) return false;
}
return true;
}
} // namespace } // namespace
BuiltVolume buildVolume(const PointSet& pts, double cellXY, double cellZ, BuiltVolume buildVolume(const PointSet& pts, double cellXY, double cellZ,
double power, double maxDist) { double power, double maxDist, bool clipToFootprint) {
if (pts.v.empty()) { if (pts.v.empty()) {
throw std::invalid_argument("buildVolume: empty point set"); throw std::invalid_argument("buildVolume: empty point set");
} }
@ -50,11 +106,111 @@ BuiltVolume buildVolume(const PointSet& pts, double cellXY, double cellZ,
fitAxis(maxy - miny, cellXY, spec.dy, spec.ny); fitAxis(maxy - miny, cellXY, spec.dy, spec.ny);
fitAxis(maxz - minz, cellZ, spec.dz, spec.nz); fitAxis(maxz - minz, cellZ, spec.dz, spec.nz);
spec.power = power; spec.power = power;
spec.maxDist = maxDist;
// 3) IDWmaxDist 外 NaN 留空)。 // 节点封顶:真实数据(大测区/小 cell)易逼近 kMaxVolumeDim³ → IDW 卡死。超 kMaxNodes 等比放大三轴 cell。
const IdwInterpolator idw; {
ScalarVolume vol = idw.interpolate(pts, spec); constexpr long long kMaxNodes = 4'000'000;
const long long tot = 1LL * spec.nx * spec.ny * spec.nz;
if (tot > kMaxNodes) {
const double s = std::cbrt(static_cast<double>(tot) / static_cast<double>(kMaxNodes));
spec.dx *= s; spec.dy *= s; spec.dz *= s;
auto rc = [](double ext, double cell) { int n = static_cast<int>(ext / cell) + 1; return n < 1 ? 1 : n; };
spec.nx = rc(maxx - minx, spec.dx);
spec.ny = rc(maxy - miny, spec.dy);
spec.nz = rc(maxz - minz, spec.dz);
}
}
// 退化维补一层:若某横/竖向范围 < 一个 cell如共面/共线剖面 → 该向仅 1 层vtkGPUVolumeRayCastMapper
// 无法对「1 层厚的体」(本质 2D)体绘制 → 报 vtkExecutive 错误、什么都渲染不出来。补到 2 层 → 成薄板可渲。
spec.nx = std::max(spec.nx, 2);
spec.ny = std::max(spec.ny, 2);
spec.nz = std::max(spec.nz, 2);
// 各向异性搜索半径(实测:对角线全域 IDW 对真实井字数据=每节点求和全部点→卡死;井字线间最大空隙
// 仅 ~20m故水平半径 auto=0.2×XY 对角线[限 12~60m] 足以跨格填满而非全域;垂直限带→剖面深向密
// 采、带内必有点,既不混深度又把候选点经「按 z 排序+二分定带」剪到深度邻域,避免卡死)。
const double exX = maxx - minx, exY = maxy - miny;
const double xydiag = std::sqrt(exX * exX + exY * exY);
// 水平半径 auto = XY 对角线 → 填满整个凸包足迹(对齐 Surfer Blanking 后的实心体)。实测因抽稀
// + z-带垂直剪枝,大半径与小半径耗时几乎一致(~2.8s/真实赣州 4 线),故取满填。
const double maxDistH = (maxDist > 0.0) ? maxDist : (xydiag > 0.0 ? xydiag : 1.0);
const double maxDistV = std::max(6.0 * spec.dz, 2.0);
spec.maxDist = maxDistH; // 记录(属性页/诊断)
// 点抽稀到网格分辨率:剖面 ~0.4m 采样远密于网格 → 按 (dx,dy,dz) 体素聚合(质心+均值)
// 大幅减候选点、不损可视化分辨率。
struct ThinAcc { double sx = 0, sy = 0, sz = 0, sv = 0; int c = 0; };
std::unordered_map<long long, ThinAcc> tmap;
tmap.reserve(pts.v.size());
auto keyOf = [&](double x, double y, double z) -> long long {
const long long ix = static_cast<long long>(std::floor((x - minx) / spec.dx));
const long long iy = static_cast<long long>(std::floor((y - miny) / spec.dy));
const long long iz = static_cast<long long>(std::floor((z - minz) / spec.dz));
return (ix * 73856093LL) ^ (iy * 19349663LL) ^ (iz * 83492791LL);
};
for (std::size_t i = 0; i < pts.v.size(); ++i) {
ThinAcc& a = tmap[keyOf(pts.x[i], pts.y[i], pts.z[i])];
a.sx += pts.x[i]; a.sy += pts.y[i]; a.sz += pts.z[i]; a.sv += pts.v[i]; ++a.c;
}
std::vector<double> tx, ty, tz, tv;
tx.reserve(tmap.size()); ty.reserve(tmap.size()); tz.reserve(tmap.size()); tv.reserve(tmap.size());
for (const auto& kv : tmap) {
const ThinAcc& a = kv.second;
tx.push_back(a.sx / a.c); ty.push_back(a.sy / a.c);
tz.push_back(a.sz / a.c); tv.push_back(a.sv / a.c);
}
const std::size_t nt = tv.size();
// 抽稀点按 z 升序 → 每深度切片二分定 [gz-V, gz+V] 带,仅遍历带内点。
std::vector<std::size_t> order(nt);
std::iota(order.begin(), order.end(), std::size_t{0});
std::sort(order.begin(), order.end(), [&](std::size_t a, std::size_t b) { return tz[a] < tz[b]; });
std::vector<double> zs(nt);
for (std::size_t t = 0; t < nt; ++t) zs[t] = tz[order[t]];
// 足迹凸包(用原始点;退化 <3/共线 → 空 → 跳过裁剪)。
const Hull2D hull = clipToFootprint ? convexHull2D(pts.x, pts.y) : Hull2D{};
const bool useClip = hull.x.size() >= 3;
const double buf = 0.5 * std::max(spec.dx, spec.dy);
// 3) 各向异性 z-带 IDW凸包外/带内无点 → NaN 留空)。
ScalarVolume vol(spec.nx, spec.ny, spec.nz);
const double nan = std::numeric_limits<double>::quiet_NaN();
const double maxH2 = maxDistH * maxDistH;
const bool fastPow2 = (power == 2.0);
const double halfPow = power * 0.5;
for (int k = 0; k < spec.nz; ++k) {
const double gz = spec.oz + k * spec.dz;
const std::size_t lo = static_cast<std::size_t>(
std::lower_bound(zs.begin(), zs.end(), gz - maxDistV) - zs.begin());
const std::size_t hi = static_cast<std::size_t>(
std::upper_bound(zs.begin(), zs.end(), gz + maxDistV) - zs.begin());
for (int j = 0; j < spec.ny; ++j) {
const double gy = spec.oy + j * spec.dy;
for (int i = 0; i < spec.nx; ++i) {
const double gx = spec.ox + i * spec.dx;
if (useClip && !inHull(hull, gx, gy, buf)) { vol.at(i, j, k) = nan; continue; }
double wsum = 0.0, vsum = 0.0;
bool any = false, hit = false; double hitVal = 0.0;
for (std::size_t t = lo; t < hi; ++t) {
const std::size_t p = order[t];
const double ddx = gx - tx[p], ddy = gy - ty[p];
const double h2 = ddx * ddx + ddy * ddy;
if (h2 > maxH2) continue; // 超水平半径
const double ddz = gz - tz[p];
const double d2 = h2 + ddz * ddz;
any = true;
if (d2 < 1e-12) { hit = true; hitVal = tv[p]; break; }
const double w = fastPow2 ? (1.0 / d2) : std::pow(d2, -halfPow);
wsum += w; vsum += w * tv[p];
}
if (hit) vol.at(i, j, k) = hitVal;
else if (!any || wsum == 0.0) vol.at(i, j, k) = nan;
else vol.at(i, j, k) = vsum / wsum;
}
}
}
// 4) 数据实测值域(仅有限值)。无有限值 → 退化 {0,1}。 // 4) 数据实测值域(仅有限值)。无有限值 → 退化 {0,1}。
double vmin = std::numeric_limits<double>::infinity(); double vmin = std::numeric_limits<double>::infinity();

View File

@ -20,7 +20,15 @@ struct BuiltVolume {
// 前置pts 须含 ≥1 点(空集抛 std::invalid_argument // 前置pts 须含 ≥1 点(空集抛 std::invalid_argument
// 确定性:同点集同参数 → 同 GridSpec 同体(不冻结 spec见计划 §1 决策)。 // 确定性:同点集同参数 → 同 GridSpec 同体(不冻结 spec见计划 §1 决策)。
// 提取自 LocalSample3dRepository::loadVolume供本地样本 / 真实 Api 共享,消除调参漂移。 // 提取自 LocalSample3dRepository::loadVolume供本地样本 / 真实 Api 共享,消除调参漂移。
//
// maxDist 语义(对齐客户 SurferXYZC + IDW + 边界 Blanking
// docs/superpowers/specs/2026-06-27-inversion-3d-volume-surfer-method-and-gaps.md
// - maxDist > 0局部 IDW 半径(超距 blank偏快、剖面附近清晰但跨大空隙可能填不满。
// - maxDist <= 0自动「覆盖测区」——半径取包络对角线域内每点取到全部散点≈Surfer 用全数据)。
// clipToFootprint=true默认用散点平面**凸包**做足迹裁剪凸包外网格列整列置空≈Surfer 用
// 边界多边形 Blanking。避免单纯放大半径把体鼓满外接盒"变粗"的根因)。
// 退化(散点 <3 / 平面近共线,如单条剖面)→ 自动跳过裁剪。
BuiltVolume buildVolume(const PointSet& pts, double cellXY, double cellZ, BuiltVolume buildVolume(const PointSet& pts, double cellXY, double cellZ,
double power, double maxDist); double power, double maxDist, bool clipToFootprint = true);
} // namespace geopro::core } // namespace geopro::core

View File

@ -23,10 +23,16 @@ public:
void setOver(Rgba c) { over_ = c; } void setOver(Rgba c) { over_ = c; }
void setNan(Rgba c) { nan_ = c; } void setNan(Rgba c) { nan_ = c; }
bool empty() const { return stops_.empty(); } bool empty() const { return stops_.empty(); }
// 整体透明度(两级透明度的第二级):与每色 alpha 相乘,渲染时才叠加,绝不烘焙进 stop。
// [0,1],默认 1不透明。独立存储 → 色阶编辑可回显真实值、单色 alpha 保持独立。
void setGlobalOpacity(double o) { globalOpacity_ = o; }
double globalOpacity() const { return globalOpacity_; }
private: private:
struct Stop { double value; Rgba color; }; struct Stop { double value; Rgba color; };
std::vector<Stop> stops_; std::vector<Stop> stops_;
std::optional<Rgba> under_, over_, nan_; std::optional<Rgba> under_, over_, nan_;
double globalOpacity_ = 1.0;
}; };
} // namespace geopro::core } // namespace geopro::core

View File

@ -1,5 +1,6 @@
#pragma once #pragma once
#include <vector> #include <vector>
#include <QJsonObject>
#include <QMetaType> #include <QMetaType>
#include <QString> #include <QString>
#include "model/Field.hpp" #include "model/Field.hpp"
@ -41,6 +42,9 @@ struct ScatterPayload {
// 色阶模板 id来自 lvl/colorGradation/getDetail 的 templateId保存色阶时回带 // 色阶模板 id来自 lvl/colorGradation/getDetail 的 templateId保存色阶时回带
// (对照原版 newLvlColorLevel 带读取到的 templateId可空 // (对照原版 newLvlColorLevel 带读取到的 templateId可空
QString templateId; QString templateId;
// type1 原始 propertieslineConfig/labelConfig/层级方案):保存色阶时只覆盖 colorBar+opacity、
// 其余原样回写,避免清掉网格(共用同一条 businessCode="" 记录)设过的等值线/层级值(对齐原版 load-then-save
QJsonObject properties;
}; };
// 等值面载荷grid(rows) + 色阶 + 异常(≈ data::GridParts // 等值面载荷grid(rows) + 色阶 + 异常(≈ data::GridParts

View File

@ -36,12 +36,14 @@ VolumeGrid builtI16ToVolumeGrid(const geopro::core::BuiltI16& built) {
} }
VolumeGrid createGprVolumeGrid(const std::string& lineDir, VolumeGrid createGprVolumeGrid(const std::string& lineDir,
const std::string& linePrefix, int coarse) { const std::string& linePrefix, int coarse,
double targetDy) {
// 走 P1/P2 链(io::gpr)得处理后 int16 量化体 → 反量化为 app 的 float 体。 // 走 P1/P2 链(io::gpr)得处理后 int16 量化体 → 反量化为 app 的 float 体。
// metricsOut 传 nullptrrepository 只产数据,度量留给 gpr_poc CLI。 // metricsOut 传 nullptrrepository 只产数据,度量留给 gpr_poc CLI。
// targetDy 透传 → 默认走线内通道插值(2.5cm 网格)app 渲染链即得密 Y 体。
const geopro::core::BuiltI16 built = const geopro::core::BuiltI16 built =
geopro::io::gpr::buildLineVolumeFromGpr3dv(lineDir, linePrefix, geopro::io::gpr::buildLineVolumeFromGpr3dv(
/*metricsOut=*/nullptr, coarse); lineDir, linePrefix, /*metricsOut=*/nullptr, coarse, targetDy);
return builtI16ToVolumeGrid(built); return builtI16ToVolumeGrid(built);
} }

View File

@ -26,9 +26,12 @@ VolumeGrid builtI16ToVolumeGrid(const geopro::core::BuiltI16& built);
// lineDir/linePrefix 同 gpr3dv-smoke / build-line(如 "D:/Downloads/明星路", "明星路_001")。 // lineDir/linePrefix 同 gpr3dv-smoke / build-line(如 "D:/Downloads/明星路", "明星路_001")。
// coarse(下采样因子≥1):沿测线(道/X 轴)每 coarse 道取 1省内存横向/深度保全分辨率。 // coarse(下采样因子≥1):沿测线(道/X 轴)每 coarse 道取 1省内存横向/深度保全分辨率。
// 稠密 VolumeGrid 全内存,长线需较大 coarse 控内存(默认 4 = build-line POC 档)。 // 稠密 VolumeGrid 全内存,长线需较大 coarse 控内存(默认 4 = build-line POC 档)。
// targetDy(米,>0 启用):线内【通道间插值】目标横向间距(读真实道偏移规则化,不跨线)。
// 默认 0.025(2.5cm)0=不插值(Y=原通道数)。详见 io::gpr::buildLineVolumeFromGpr3dv。
// 失败(加载失败/立方体为空)→ 抛 std::runtime_error(由 io::gpr 链抛出,原样透传)。 // 失败(加载失败/立方体为空)→ 抛 std::runtime_error(由 io::gpr 链抛出,原样透传)。
VolumeGrid createGprVolumeGrid(const std::string& lineDir, VolumeGrid createGprVolumeGrid(const std::string& lineDir,
const std::string& linePrefix, int coarse = 4); const std::string& linePrefix, int coarse = 4,
double targetDy = 0.025);
} // namespace geopro::data } // namespace geopro::data

View File

@ -1,5 +1,6 @@
#include "api/Api3dRepository.hpp" #include "api/Api3dRepository.hpp"
#include <QCoreApplication>
#include <QDateTime> #include <QDateTime>
#include <QDebug> #include <QDebug>
#include <QJsonDocument> #include <QJsonDocument>
@ -7,14 +8,19 @@
#include <QString> #include <QString>
#include <QVariant> #include <QVariant>
#include <algorithm>
#include <chrono>
#include <cmath> #include <cmath>
#include <cstddef> #include <cstddef>
#include <exception> #include <exception>
#include <memory> #include <memory>
#include <thread>
#include <tuple>
#include <utility> #include <utility>
#include "algo/VolumeBuilder.hpp" // core::PointSet / BuiltVolume / buildVolume含 Field.hpp #include "algo/VolumeBuilder.hpp" // core::PointSet / BuiltVolume / buildVolume含 Field.hpp
#include "api/DatasetLoadHandles.hpp" #include "api/DatasetLoadHandles.hpp"
#include "GprVolumeRepository.hpp" // createGprVolumeGrid§6 接入GPR 体直产)
#include "model/ColorScale.hpp" #include "model/ColorScale.hpp"
#include "model/detail/DetailPayloads.hpp" #include "model/detail/DetailPayloads.hpp"
#include "repo/IAsyncDatasetRepository.hpp" #include "repo/IAsyncDatasetRepository.hpp"
@ -38,11 +44,9 @@ DsDimension Api3dRepository::dimensionOf(const DsRow& ds) const {
return DsDimension::Dim3D; return DsDimension::Dim3D;
} }
if (c == "dd_slice") return DsDimension::Analysis3D; if (c == "dd_slice") return DsDimension::Analysis3D;
// 足迹型(测线/各类轨迹) → 二维数据集:地面 lat/lon 序列平铺进地图spec §4.1/§4.2)。 // 足迹型 → 二维数据集:地面 lat/lon 序列平铺进地图。dd_trajectory_data = 统一通用轨迹
if (c == "dd_trajectory_data" || c == "dd_transient_electromagnetic_trajectory_data" || // (数据字典 DD0623「保留」已并入 dd_radar_rtk_trajectory);瞬变电磁/雷达通道/RTK 轨迹字典均「删除」。
c == "dd_radar_channel_trajectory" || c == "dd_radar_rtk_trajectory") { if (c == "dd_trajectory_data") return DsDimension::Dim2D;
return DsDimension::Dim2D;
}
return DsDimension::Other; return DsDimension::Other;
} }
@ -121,11 +125,41 @@ std::string Api3dRepository::createVolume(const VoxelGenerateRequest& req) {
return id; return id;
} }
std::string Api3dRepository::createGprVolume(const std::string& lineDir,
const std::string& linePrefix,
const std::string& name, int coarse) {
// 走 io::gpr 逐线管线(含线内通道插值)直接产体(抛异常透传给调用方)。
VolumeGrid grid = geopro::data::createGprVolumeGrid(lineDir, linePrefix, coarse);
// 简易灰度色阶(负→暗、零→灰、正→亮)覆盖体值域,使体素渲染可见。
core::ColorScale scale;
const double mid = 0.5 * (grid.vmin + grid.vmax);
scale.addStop(grid.vmin, core::Rgba{20, 24, 40, 255});
scale.addStop(mid, core::Rgba{140, 140, 150, 255});
scale.addStop(grid.vmax, core::Rgba{235, 232, 220, 255});
const std::string id = "vol-" + std::to_string(++volumeCounter_);
StoredVolume sv;
sv.name = name;
sv.createTime =
QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd HH:mm")).toStdString();
sv.cachedGrid = std::move(grid); // 预填 → loadVolume 直接命中渲染(不走 mock IDW
sv.cachedScale = scale;
volumes_[id] = std::move(sv);
return id;
}
const VoxelGenerateRequest* Api3dRepository::lastVoxelRequest(const std::string& dsId) const { const VoxelGenerateRequest* Api3dRepository::lastVoxelRequest(const std::string& dsId) const {
const auto it = volumes_.find(dsId); const auto it = volumes_.find(dsId);
return (it != volumes_.end() && it->second.request) ? &*it->second.request : nullptr; return (it != volumes_.end() && it->second.request) ? &*it->second.request : nullptr;
} }
void Api3dRepository::clearMockData() {
// 切换项目:清空内存态三维体/切片/异常,避免上个项目的产物残留进新项目列表。
volumes_.clear();
slices_.clear();
anomalies_.clear();
}
std::vector<DsRow> Api3dRepository::volumeRows() const { std::vector<DsRow> Api3dRepository::volumeRows() const {
std::vector<DsRow> rows; std::vector<DsRow> rows;
rows.reserve(volumes_.size()); rows.reserve(volumes_.size());
@ -208,6 +242,19 @@ void Api3dRepository::appendGridPoints(const core::Grid& g, core::PointSet& pts)
} }
} }
void Api3dRepository::loadSectionWithRetry(const std::string& dsId, int attemptsLeft,
std::function<void(SectionData)> onOk, OnError onErr) {
loadSection(dsId, onOk, [this, dsId, attemptsLeft, onOk, onErr](const std::string& m) {
if (attemptsLeft > 0) { // 瞬时失败502 等)→ 重试,不立刻判整体失败
qInfo().noquote() << "[volbuild] source" << QString::fromStdString(dsId)
<< "加载失败,重试(剩" << attemptsLeft << "次):" << QString::fromStdString(m);
loadSectionWithRetry(dsId, attemptsLeft - 1, onOk, onErr);
return;
}
onErr(m);
});
}
void Api3dRepository::finalizeVolume(const std::string& dsId, const core::PointSet& pts, void Api3dRepository::finalizeVolume(const std::string& dsId, const core::PointSet& pts,
const core::ColorScale& scale, const core::ColorScale& scale,
const VolumeBuildParams& params, const VolumeBuildParams& params,
@ -217,34 +264,75 @@ void Api3dRepository::finalizeVolume(const std::string& dsId, const core::PointS
onErr("Api3dRepository::loadVolume: 配准后无有效散点(源数据为空或全无数据)"); onErr("Api3dRepository::loadVolume: 配准后无有效散点(源数据为空或全无数据)");
return; return;
} }
try {
geopro::core::BuiltVolume bv = // 重 IDW 建体放后台线程,避免阻塞 UI用户要求渲染必须异步。纯计算无 Qt/VTK算完
geopro::core::buildVolume(pts, params.cellXY, params.cellZ, params.power, params.maxDist); // 经事件循环回主线程做缓存 + 交付(缓存写 volumes_ / onOk 触碰 VTK 必须在主线程)。
// 值域:优先色阶分段值,否则 buildVolume 的数据实测范围。 // 兜底:无 QCoreApplicationheadless/单测)时退化为同步,保证可测/可离屏。
double vmin = bv.vmin, vmax = bv.vmax; auto deliver = [this, dsId, scale, onOk, onErr](std::shared_ptr<geopro::core::BuiltVolume> bv,
std::string err, std::size_t nPts) {
if (!bv) {
onErr(std::string("Api3dRepository::loadVolume: ") + err);
return;
}
double vmin = bv->vmin, vmax = bv->vmax;
const std::vector<double> stops = scale.stopValues(); const std::vector<double> stops = scale.stopValues();
if (stops.size() >= 2) { if (stops.size() >= 2) {
vmin = stops.front(); vmin = stops.front();
vmax = stops.back(); vmax = stops.back();
} }
qInfo().noquote() << "[volbuild] finalize pts=" << pts.v.size() << "grid" qInfo().noquote() << "[volbuild] finalize pts=" << nPts << "grid" << bv->spec.nx << "x"
<< bv.spec.nx << "x" << bv.spec.ny << "x" << bv.spec.nz << bv->spec.ny << "x" << bv->spec.nz << "origin" << bv->spec.ox
<< "origin" << bv.spec.ox << bv.spec.oy << bv.spec.oz << "spacing" << bv->spec.oy << bv->spec.oz << "spacing" << bv->spec.dx << bv->spec.dy
<< bv.spec.dx << bv.spec.dy << bv.spec.dz; << bv->spec.dz;
VolumeGrid out{std::move(bv.vol), VolumeGrid out{std::move(bv->vol),
{{bv.spec.ox, bv.spec.oy, bv.spec.oz}}, {{bv->spec.ox, bv->spec.oy, bv->spec.oz}},
{{bv.spec.dx, bv.spec.dy, bv.spec.dz}}, {{bv->spec.dx, bv->spec.dy, bv->spec.dz}},
vmin, vmax}; vmin, vmax};
auto it = volumes_.find(dsId); auto it = volumes_.find(dsId);
if (it != volumes_.end()) { // 缓存明细 + 色阶(下次命中即跳重算) if (it != volumes_.end()) { // 缓存明细 + 色阶(下次命中即跳重算)
it->second.cachedGrid = out; it->second.cachedGrid = out;
it->second.cachedScale = scale; it->second.cachedScale = scale;
it->second.pointCount = pts.v.size(); // 持久化聚合散点数(详情统计用) it->second.pointCount = nPts; // 持久化聚合散点数(详情统计用)
} }
onOk(std::move(out), scale); onOk(std::move(out), scale);
};
// 纯计算闭包:返回 (built|nullptr, err, nPts)。
auto compute = [pts, params]() {
std::shared_ptr<geopro::core::BuiltVolume> bv;
std::string err;
try {
bv = std::make_shared<geopro::core::BuiltVolume>(geopro::core::buildVolume(
pts, params.cellXY, params.cellZ, params.power, params.maxDist));
} catch (const std::exception& e) { } catch (const std::exception& e) {
onErr(std::string("Api3dRepository::loadVolume: ") + e.what()); err = e.what();
} }
return std::make_tuple(bv, err, pts.v.size());
};
qInfo().noquote() << "[volbuild] start dsId=" << QString::fromStdString(dsId)
<< "pts=" << pts.v.size() << "async=" << (QCoreApplication::instance() != nullptr);
if (!QCoreApplication::instance()) { // 无事件循环headless/单测)→ 同步
auto res = compute();
deliver(std::get<0>(res), std::get<1>(res), std::get<2>(res));
return;
}
std::thread([compute, deliver, dsId]() mutable {
const auto t0 = std::chrono::steady_clock::now();
auto res = compute();
auto bv = std::get<0>(res); // 具名变量(非结构化绑定)→ C++17 可被 lambda 捕获
auto err = std::get<1>(res);
auto nPts = std::get<2>(res);
const auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - t0).count();
qInfo().noquote() << "[volbuild] computed dsId=" << QString::fromStdString(dsId)
<< "ms=" << ms << "ok=" << (bv != nullptr);
// 回主线程交付QueuedConnectionqApp 为主线程对象,存活于整个会话)。
QMetaObject::invokeMethod(
qApp,
[deliver, bv, err, nPts]() mutable { deliver(std::move(bv), std::move(err), nPts); },
Qt::QueuedConnection);
}).detach();
} }
void Api3dRepository::loadVolume(const std::string& dsId, void Api3dRepository::loadVolume(const std::string& dsId,
@ -272,15 +360,14 @@ void Api3dRepository::loadVolume(const std::string& dsId,
int pending; int pending;
bool failed = false; bool failed = false;
core::PointSet pts; core::PointSet pts;
core::ColorScale scale; // 取首个到达源的色阶定值域 std::vector<core::ColorScale> scales; // 收集所有源色阶 → 取 vmax 中位者定值域(不依赖到达顺序)
bool haveScale = false;
}; };
auto agg = std::make_shared<Agg>(); auto agg = std::make_shared<Agg>();
agg->pending = static_cast<int>(params.sourceDatasetIds.size()); agg->pending = static_cast<int>(params.sourceDatasetIds.size());
for (const std::string& srcId : params.sourceDatasetIds) { for (const std::string& srcId : params.sourceDatasetIds) {
loadSection( loadSectionWithRetry(
srcId, srcId, /*attemptsLeft=*/2,
[this, dsId, srcId, params, agg, onOk, onErr](SectionData s) { [this, dsId, srcId, params, agg, onOk, onErr](SectionData s) {
if (agg->failed) return; if (agg->failed) return;
const std::size_t before = agg->pts.v.size(); const std::size_t before = agg->pts.v.size();
@ -289,12 +376,20 @@ void Api3dRepository::loadVolume(const std::string& dsId,
<< "grid" << s.grid.nx() << "x" << s.grid.ny() << "-> +" << "grid" << s.grid.nx() << "x" << s.grid.ny() << "-> +"
<< (agg->pts.v.size() - before) << "pts (total" << (agg->pts.v.size() - before) << "pts (total"
<< agg->pts.v.size() << ")"; << agg->pts.v.size() << ")";
if (!agg->haveScale) { agg->scales.push_back(s.scale);
agg->scale = s.scale;
agg->haveScale = true;
}
if (--agg->pending > 0) return; // 还有源未到齐 if (--agg->pending > 0) return; // 还有源未到齐
finalizeVolume(dsId, agg->pts, agg->scale, params, onOk, onErr); // 值域定法(修偶发"淡蓝/几乎不可见"根因):旧逻辑取「首个到达源」的色阶 → 多条线值域
// 不一(如多条 2168、一条 24550)时随异步到达顺序抖动;取到大值域那条会把数据全压到
// 色阶低端→全蓝近透明。改为取所有源色阶按 vmax 排序的中位者:确定性(去到达顺序依赖)
// + 抗单条线值域离群 → 多数线的正常值域稳定胜出。
auto& ss = agg->scales;
std::sort(ss.begin(), ss.end(),
[](const core::ColorScale& a, const core::ColorScale& b) {
const auto av = a.stopValues(), bv = b.stopValues();
return (av.empty() ? 0.0 : av.back()) < (bv.empty() ? 0.0 : bv.back());
});
const core::ColorScale chosen = ss.empty() ? core::ColorScale{} : ss[ss.size() / 2];
finalizeVolume(dsId, agg->pts, chosen, params, onOk, onErr);
}, },
[agg, onErr](const std::string& m) { [agg, onErr](const std::string& m) {
if (agg->failed) return; if (agg->failed) return;
@ -382,6 +477,21 @@ void Api3dRepository::deleteSlice(const std::string& dsId, std::function<void()>
onOk(); onOk();
} }
void Api3dRepository::setSliceColorScale(const std::string& dsId,
const geopro::core::ColorScale& cs) {
auto it = slices_.find(dsId);
if (it == slices_.end()) return;
it->second.colorScale = cs; // 切片独立色阶mock真实后端走该切片 dsId 的 colorGradation
it->second.hasColorScale = true;
}
bool Api3dRepository::sliceColorScale(const std::string& dsId, geopro::core::ColorScale& out) const {
auto it = slices_.find(dsId);
if (it == slices_.end() || !it->second.hasColorScale) return false;
out = it->second.colorScale;
return true;
}
// ── 异常 / 异常体(后端真实端点存在,但异常挂三维体、三维体仍 mock → 异常暂内存 mock // ── 异常 / 异常体(后端真实端点存在,但异常挂三维体、三维体仍 mock → 异常暂内存 mock
// 挂载结构按"异常→三维体",整链端点就绪后切真实,见记忆 vtk-3d-persistence-structure── // 挂载结构按"异常→三维体",整链端点就绪后切真实,见记忆 vtk-3d-persistence-structure──

View File

@ -42,8 +42,16 @@ public:
std::string createVolume(VolumeBuildParams params, const std::string& name); std::string createVolume(VolumeBuildParams params, const std::string& name);
// 请求体形态创建:组装真实 VoxelGenerateRequest → 派生 params 存储 + 打印请求体 JSON供后端联调 // 请求体形态创建:组装真实 VoxelGenerateRequest → 派生 params 存储 + 打印请求体 JSON供后端联调
std::string createVolume(const VoxelGenerateRequest& req); std::string createVolume(const VoxelGenerateRequest& req);
// GPR 三维体:走 io::gpr 逐线管线含线内通道插值§1直接产体并【预填 cachedGrid】
// 注册为 dd_voxel 体 → 自动进 volumeRows/三级树loadVolume 直接命中渲染(不走 mock IDW
// lineDir/linePrefix 同 build-line如 "D:/Downloads/明星路","明星路_010"coarse 控内存。
// 返回新 dsId失败抛 std::runtime_error加载/立方体空,由 io::gpr 链透传)。
std::string createGprVolume(const std::string& lineDir, const std::string& linePrefix,
const std::string& name, int coarse = 8);
// 取回某三维体组装的请求体(测试/联调);非本 repo 创建或无 request 时返回 nullptr。 // 取回某三维体组装的请求体(测试/联调);非本 repo 创建或无 request 时返回 nullptr。
const VoxelGenerateRequest* lastVoxelRequest(const std::string& dsId) const; const VoxelGenerateRequest* lastVoxelRequest(const std::string& dsId) const;
// 清空内存态三维体/切片/异常(切换项目时调;否则上个项目的体/切片/异常残留在新项目列表)。
void clearMockData();
// 已创建三维体的列表行ddCode="dd_voxel"),供三维分析栏合并注入(每次 setDatasets 追加)。 // 已创建三维体的列表行ddCode="dd_voxel"),供三维分析栏合并注入(每次 setDatasets 追加)。
std::vector<DsRow> volumeRows() const; std::vector<DsRow> volumeRows() const;
@ -90,6 +98,8 @@ public:
std::function<void()> onOk, OnError onErr) override; std::function<void()> onOk, OnError onErr) override;
void deleteSlice(const std::string& dsId, void deleteSlice(const std::string& dsId,
std::function<void()> onOk, OnError onErr) override; std::function<void()> onOk, OnError onErr) override;
void setSliceColorScale(const std::string& dsId, const geopro::core::ColorScale& cs) override;
bool sliceColorScale(const std::string& dsId, geopro::core::ColorScale& out) const override;
// 异常 / 异常体(后端未就绪 → load 回空树,变更走 onErr // 异常 / 异常体(后端未就绪 → load 回空树,变更走 onErr
void loadAnomalyTree(const std::string& objectId, void loadAnomalyTree(const std::string& objectId,
@ -113,6 +123,10 @@ private:
// 把一条剖面 section grid 的有限值节点按 CurtainActor 同口径定位lat/lon→frame.toLocal // 把一条剖面 section grid 的有限值节点按 CurtainActor 同口径定位lat/lon→frame.toLocal
// 世界 Z=grid.y 高程)追加为 IDW 散点 → 保证体与帘面同系对齐。 // 世界 Z=grid.y 高程)追加为 IDW 散点 → 保证体与帘面同系对齐。
void appendGridPoints(const core::Grid& g, core::PointSet& pts) const; void appendGridPoints(const core::Grid& g, core::PointSet& pts) const;
// 源剖面带重试加载:瞬时失败(如后端 502 Bad Gateway重试 attemptsLeft 次,避免一条源抖动
// 就让整个三维体建不出来(表现为"连坐标轴都没有"的无声不渲染)。重试用尽才 onErr。
void loadSectionWithRetry(const std::string& dsId, int attemptsLeft,
std::function<void(SectionData)> onOk, OnError onErr);
// 多源散点聚合完成 → buildVolume + 值域 + 缓存明细/色阶 → onOk(体, 色阶)。 // 多源散点聚合完成 → buildVolume + 值域 + 缓存明细/色阶 → onOk(体, 色阶)。
void finalizeVolume(const std::string& dsId, const core::PointSet& pts, void finalizeVolume(const std::string& dsId, const core::PointSet& pts,
const core::ColorScale& scale, const VolumeBuildParams& params, const core::ColorScale& scale, const VolumeBuildParams& params,
@ -139,6 +153,8 @@ private:
SliceSpec spec; SliceSpec spec;
std::string name; std::string name;
std::string createTime; // 创建时刻 std::string createTime; // 创建时刻
geopro::core::ColorScale colorScale; // 切片自己的色阶(颜色快照 + 不透明度并入 globalOpacity
bool hasColorScale = false; // 是否已设过独立色阶(否则还原时跟随三维体)
}; };
std::map<std::string, StoredSlice> slices_; // dsId → 切片 std::map<std::string, StoredSlice> slices_; // dsId → 切片
int sliceCounter_ = 0; int sliceCounter_ = 0;

View File

@ -387,6 +387,13 @@ void ApiDatasetCommandRepository::listExceptionTypes(
std::move(cb)); std::move(cb));
} }
void ApiDatasetCommandRepository::getExceptionTypeDetail(
const QString& exceptionTypeId, std::function<void(bool, QJsonObject, QString)> cb) {
wireObject(api_.getAsync(QStringLiteral("/business/exceptionType/getDetail/%1")
.arg(enc(exceptionTypeId))),
std::move(cb));
}
void ApiDatasetCommandRepository::listArrayTypes( void ApiDatasetCommandRepository::listArrayTypes(
std::function<void(bool, QJsonArray, QString)> cb) { std::function<void(bool, QJsonArray, QString)> cb) {
wireArray(api_.getAsync(QStringLiteral("/business/script/arrayTypeList")), std::move(cb)); wireArray(api_.getAsync(QStringLiteral("/business/script/arrayTypeList")), std::move(cb));

View File

@ -81,6 +81,9 @@ public:
void listExceptionTypes( void listExceptionTypes(
const QString& projectId, const QString& remarkSourceType, const QString& projectId, const QString& remarkSourceType,
std::function<void(bool ok, QJsonArray list, QString msg)> cb) override; std::function<void(bool ok, QJsonArray list, QString msg)> cb) override;
void getExceptionTypeDetail(
const QString& exceptionTypeId,
std::function<void(bool ok, QJsonObject data, QString msg)> cb) override;
void listArrayTypes(std::function<void(bool ok, QJsonArray list, QString msg)> cb) override; void listArrayTypes(std::function<void(bool ok, QJsonArray list, QString msg)> cb) override;
void getExceptionName( void getExceptionName(
const QString& exceptionTypeId, const QString& remarkSourceId, const QString& exceptionTypeId, const QString& remarkSourceId,

View File

@ -28,6 +28,7 @@ struct ChartParts {
geopro::core::ScatterField scatter; geopro::core::ScatterField scatter;
geopro::core::ColorScale scatterScale; geopro::core::ColorScale scatterScale;
QString templateId; // 散点色阶模板 id保存色阶回带对照原版 lvlTemplateId QString templateId; // 散点色阶模板 id保存色阶回带对照原版 lvlTemplateId
QJsonObject scatterProperties; // type1 记录原始 properties保存时回写 lineConfig/层级,不覆盖网格的值)
}; };
// 网格数据加载结果grid(rows) + 网格色阶(type2) + 异常。 // 网格数据加载结果grid(rows) + 网格色阶(type2) + 异常。
struct GridParts { struct GridParts {
@ -61,6 +62,8 @@ ChartParts parseScatterParts(const QList<net::ApiResponse>& r) {
p.scatter = dto::parseScatterGraph(r[0].data); p.scatter = dto::parseScatterGraph(r[0].data);
p.scatterScale = dto::parseColorBar(r[1].data); p.scatterScale = dto::parseColorBar(r[1].data);
p.templateId = r[1].data.value(QStringLiteral("templateId")).toVariant().toString(); p.templateId = r[1].data.value(QStringLiteral("templateId")).toVariant().toString();
// 原始 properties含 lineConfig/labelConfig/层级方案):保存色阶时原样回写,避免清掉网格(共用同条记录)的值。
p.scatterProperties = r[1].data.value(QStringLiteral("properties")).toObject();
return p; return p;
} }
@ -175,6 +178,7 @@ DetailLoad* ApiDatasetRepository::makeInversionScatter(const std::string& dsId)
ChartParts p = parseScatterParts(r); ChartParts p = parseScatterParts(r);
core::ScatterPayload payload{p.scatter, p.scatterScale}; core::ScatterPayload payload{p.scatter, p.scatterScale};
payload.templateId = p.templateId; // 色阶保存回带(对照原版 lvlTemplateId payload.templateId = p.templateId; // 色阶保存回带(对照原版 lvlTemplateId
payload.properties = p.scatterProperties; // 原始 properties → 保存时回写非 colorBar 字段
return QVariant::fromValue(payload); return QVariant::fromValue(payload);
}); });
} }

View File

@ -47,7 +47,10 @@ ScatterField parseScatterGraph(const QJsonObject& data) {
ColorScale parseColorBar(const QJsonObject& data) { ColorScale parseColorBar(const QJsonObject& data) {
ColorScale cs; ColorScale cs;
const QJsonArray bar = data.value("properties").toObject().value("colorBar").toArray(); const QJsonObject props = data.value("properties").toObject();
// 整体透明度两级第二级properties.opacity缺省 1不透明
if (props.contains("opacity")) cs.setGlobalOpacity(props.value("opacity").toDouble(1.0));
const QJsonArray bar = props.value("colorBar").toArray();
for (auto e : bar) { for (auto e : bar) {
const QJsonArray pair = e.toArray(); const QJsonArray pair = e.toArray();
if (pair.size() < 2) continue; if (pair.size() < 2) continue;

View File

@ -113,6 +113,13 @@ public:
virtual void deleteSlice(const std::string& dsId, virtual void deleteSlice(const std::string& dsId,
std::function<void()> onOk, OnError onErr) = 0; std::function<void()> onOk, OnError onErr) = 0;
// 已保存切片的独立色阶(颜色快照 + 不透明度并入 globalOpacity。mock 仓储内存存;
// 默认空实现无色阶存储的仓储。set 在保存切片后调用get 在还原/编辑切片色阶时用。
virtual void setSliceColorScale(const std::string& /*dsId*/,
const geopro::core::ColorScale& /*cs*/) {}
virtual bool sliceColorScale(const std::string& /*dsId*/,
geopro::core::ColorScale& /*out*/) const { return false; }
// ── 异常 / 异常体spec §6.4)──────────────────────────────────────────── // ── 异常 / 异常体spec §6.4)────────────────────────────────────────────
// 异常体(树中间层):含该体下的多个 Anomaly。 // 异常体(树中间层):含该体下的多个 Anomaly。
struct AnomalyBody { struct AnomalyBody {

View File

@ -167,6 +167,13 @@ public:
const QString& projectId, const QString& remarkSourceType, const QString& projectId, const QString& remarkSourceType,
std::function<void(bool ok, QJsonArray list, QString msg)> cb) = 0; std::function<void(bool ok, QJsonArray list, QString msg)> cb) = 0;
// 异常类型详情GET /business/exceptionType/getDetail/{id} → data{...legend...}。
// data.legend = {polylineColor/Width/Shape, pointColor/Size/Shape, polygonFillColor,...}
// 供创建异常时按平台类型样式渲染(与平台一致)。回调 data = 整个 data 对象。
virtual void getExceptionTypeDetail(
const QString& exceptionTypeId,
std::function<void(bool ok, QJsonObject data, QString msg)> cb) = 0;
// 装置类型枚举GET /business/script/arrayTypeList → [{itemValue,name}](电阻率/视电阻率段装置筛选用)。 // 装置类型枚举GET /business/script/arrayTypeList → [{itemValue,name}](电阻率/视电阻率段装置筛选用)。
virtual void listArrayTypes(std::function<void(bool ok, QJsonArray list, QString msg)> cb) = 0; virtual void listArrayTypes(std::function<void(bool ok, QJsonArray list, QString msg)> cb) = 0;

View File

@ -49,11 +49,9 @@ DsDimension LocalSample3dRepository::dimensionOf(const DsRow& ds) const {
} }
// 切片:三维分析栏。 // 切片:三维分析栏。
if (c == "dd_slice") return DsDimension::Analysis3D; if (c == "dd_slice") return DsDimension::Analysis3D;
// 足迹型(测线/各类轨迹):二维数据集(与 Api3dRepository 同口径)。 // 足迹型 → 二维数据集。dd_trajectory_data = 统一通用轨迹(数据字典 DD0623「保留」已并入
if (c == "dd_trajectory_data" || c == "dd_transient_electromagnetic_trajectory_data" || // dd_radar_rtk_trajectory);瞬变电磁/雷达通道/RTK 等轨迹型字典均标「删除」,不再单列。
c == "dd_radar_channel_trajectory" || c == "dd_radar_rtk_trajectory") { if (c == "dd_trajectory_data") return DsDimension::Dim2D;
return DsDimension::Dim2D;
}
return DsDimension::Other; return DsDimension::Other;
} }

View File

@ -4,6 +4,7 @@
#include <cmath> #include <cmath>
#include <limits> #include <limits>
#include <stdexcept> #include <stdexcept>
#include <vector>
#include <QString> #include <QString>
@ -12,6 +13,7 @@
#include "RadarProcessor.h" #include "RadarProcessor.h"
#include "core/model/ScalarVolumeI16.hpp" #include "core/model/ScalarVolumeI16.hpp"
#include "io/gpr/GprGeometry.hpp" // planChannelInterpolation
namespace geopro::io::gpr { namespace geopro::io::gpr {
@ -68,7 +70,7 @@ double nowMs(std::chrono::steady_clock::time_point t0) {
geopro::core::BuiltI16 buildLineVolumeFromGpr3dv(const std::string& lineDir, geopro::core::BuiltI16 buildLineVolumeFromGpr3dv(const std::string& lineDir,
const std::string& linePrefix, const std::string& linePrefix,
BridgeMetrics* metricsOut, BridgeMetrics* metricsOut,
int coarse) { int coarse, double targetDy) {
const int stride = coarse > 1 ? coarse : 1; // 沿测线下采样步长(≥1) const int stride = coarse > 1 ? coarse : 1; // 沿测线下采样步长(≥1)
const QString dir = QString::fromLocal8Bit(lineDir.c_str()); const QString dir = QString::fromLocal8Bit(lineDir.c_str());
const QString base = QString::fromLocal8Bit(linePrefix.c_str()); const QString base = QString::fromLocal8Bit(linePrefix.c_str());
@ -108,9 +110,25 @@ geopro::core::BuiltI16 buildLineVolumeFromGpr3dv(const std::string& lineDir,
// 下采样后输出道数(向上取整保留末道附近)nxOut = ceil(traces/stride)。 // 下采样后输出道数(向上取整保留末道附近)nxOut = ceil(traces/stride)。
const int nxOut = (traces + stride - 1) / stride; const int nxOut = (traces + stride - 1) / stride;
const int nx = nxOut; // X=道(沿测线,已按 stride 下采样) const int nx = nxOut; // X=道(沿测线,已按 stride 下采样)
const int ny = channels; // Y=通道(横向)
const int nz = samples; // Z=样本(深度) const int nz = samples; // Z=样本(深度)
// §1 线内通道插值:读各通道真实横向偏移(header.chXOffsets) → 规则网格化 Y 到 targetDy。
// 绝不跨线;间距/通道数从数据来,不假设。退路(无偏移/未启用)= 逐通道 identity。
std::vector<double> latOff;
const auto& chx = processed.header.chXOffsets;
if (chx.size() == channels)
for (int c = 0; c < channels; ++c)
latOff.push_back(static_cast<double>(chx[c]));
std::vector<geopro::io::gpr::ChannelInterpRow> rows;
bool interpolated = false;
if (static_cast<int>(latOff.size()) == channels && targetDy > 0.0) {
rows = planChannelInterpolation(latOff, targetDy);
interpolated = (static_cast<int>(rows.size()) != channels);
}
if (rows.empty())
for (int c = 0; c < channels; ++c) rows.push_back({c, c, 0.0});
const int ny = static_cast<int>(rows.size()); // Y=通道(横向,可能已插值加密)
// 3) 扫处理后值域 → Quant(offset=中点,防溢出)。 // 3) 扫处理后值域 → Quant(offset=中点,防溢出)。
const auto tFill = std::chrono::steady_clock::now(); const auto tFill = std::chrono::steady_clock::now();
short rawMin = std::numeric_limits<short>::max(); short rawMin = std::numeric_limits<short>::max();
@ -138,20 +156,26 @@ geopro::core::BuiltI16 buildLineVolumeFromGpr3dv(const std::string& lineDir,
quant.scale = (vmax > vmin) ? (vmax - vmin) / 64000.0 : 1.0; quant.scale = (vmax > vmin) ? (vmax - vmin) / 64000.0 : 1.0;
quant.offset = 0.5 * (vmin + vmax); // 中点 → 防溢出 quant.offset = 0.5 * (vmin + vmax); // 中点 → 防溢出
// 4) 逐 (ch,trace,sample) 填体。GPR 立方体为稠密体(每体素有值),无空洞 → 不置 kBlank。 // 4) 逐 (输出行 j, trace, sample) 填体。每个输出行 = 两侧最近真实通道线性插值
// (a==b 时即原通道)。GPR 立方体稠密(每体素有值),无空洞 → 不置 kBlank。
// 沿测线按 stride 下采样:输出道 to → 源道 t = to*stride。 // 沿测线按 stride 下采样:输出道 to → 源道 t = to*stride。
geopro::core::BuiltI16 built; geopro::core::BuiltI16 built;
built.vol = geopro::core::ScalarVolumeI16(nx, ny, nz); built.vol = geopro::core::ScalarVolumeI16(nx, ny, nz);
for (int c = 0; c < channels; ++c) { for (int j = 0; j < ny; ++j) {
const auto& chData = processed.volumeData[c]; const auto& chA = processed.volumeData[rows[j].a];
const auto& chB = processed.volumeData[rows[j].b];
const double wb = rows[j].wb, wa = 1.0 - wb;
for (int to = 0; to < nxOut; ++to) { for (int to = 0; to < nxOut; ++to) {
const int t = to * stride; const int t = to * stride;
const bool hasTrace = t < static_cast<int>(chData.size()); const bool hasA = t < static_cast<int>(chA.size());
const bool hasB = t < static_cast<int>(chB.size());
for (int s = 0; s < samples; ++s) { for (int s = 0; s < samples; ++s) {
short v = 0; const double va =
if (hasTrace && s < static_cast<int>(chData[t].size())) v = chData[t][s]; (hasA && s < static_cast<int>(chA[t].size())) ? chA[t][s] : 0.0;
// X=输出道 to、Y=通道 c、Z=样本 s。 const double vb =
built.vol.at(to, c, s) = quant.toQ(static_cast<double>(v)); (hasB && s < static_cast<int>(chB[t].size())) ? chB[t][s] : 0.0;
// X=输出道 to、Y=输出行 j、Z=样本 s。
built.vol.at(to, j, s) = quant.toQ(wa * va + wb * vb);
} }
} }
} }
@ -162,7 +186,8 @@ geopro::core::BuiltI16 buildLineVolumeFromGpr3dv(const std::string& lineDir,
// 下采样后相邻输出道在世界中跨 stride 个原始道距 → dx ×stride 保持真实尺度。 // 下采样后相邻输出道在世界中跨 stride 个原始道距 → dx ×stride 保持真实尺度。
const double dxBase = h.distanceInc > 1e-9 ? h.distanceInc : 1.0; const double dxBase = h.distanceInc > 1e-9 ? h.distanceInc : 1.0;
const double dx = dxBase * stride; const double dx = dxBase * stride;
const double dy = channelSpacingY(h, channels); // 插值后 Y 已规则化到 targetDy 网格;否则用原通道横距。
const double dy = interpolated ? targetDy : channelSpacingY(h, channels);
const double dz = depthSpacingZ(h); const double dz = depthSpacingZ(h);
built.quant = quant; built.quant = quant;

View File

@ -40,11 +40,15 @@ struct BridgeMetrics {
// metricsOut 非空时回填维度/量化/spacing/耗时(供 CLI 报告,不编造)。 // metricsOut 非空时回填维度/量化/spacing/耗时(供 CLI 报告,不编造)。
// coarse(下采样因子≥1):沿测线(道/X 轴)每 coarse 道取 1spacing.x ×coarse 保形; // coarse(下采样因子≥1):沿测线(道/X 轴)每 coarse 道取 1spacing.x ×coarse 保形;
// 通道/样本(横向/深度)保留全分辨率。coarse≤1 即全分辨率。磁盘紧张时省空间用。 // 通道/样本(横向/深度)保留全分辨率。coarse≤1 即全分辨率。磁盘紧张时省空间用。
// targetDy(米,>0 启用):线内【通道间插值】目标横向间距。读各通道真实横向偏移
// (header.chXOffsets) 规则网格化 Y 到 targetDyny=round(跨度/targetDy)+1逐行线性
// 插值(不跨线、不假设道间距)。<=0 或无偏移 → 不插值Y=原通道数。默认 0.025(2.5cm)。
// 失败(加载失败/立方体为空) → 抛 std::runtime_error。 // 失败(加载失败/立方体为空) → 抛 std::runtime_error。
geopro::core::BuiltI16 buildLineVolumeFromGpr3dv(const std::string& lineDir, geopro::core::BuiltI16 buildLineVolumeFromGpr3dv(const std::string& lineDir,
const std::string& linePrefix, const std::string& linePrefix,
BridgeMetrics* metricsOut, BridgeMetrics* metricsOut,
int coarse = 1); int coarse = 1,
double targetDy = 0.025);
} // namespace geopro::io::gpr } // namespace geopro::io::gpr

View File

@ -1,5 +1,8 @@
#include "io/gpr/GprGeometry.hpp" #include "io/gpr/GprGeometry.hpp"
#include <algorithm>
#include <cmath>
#include <numeric>
#include <sstream> #include <sstream>
#include <string> #include <string>
#include <vector> #include <vector>
@ -22,6 +25,46 @@ std::vector<double> parseChannelXOffsets(const std::string& ordText) {
return offsets; return offsets;
} }
std::vector<ChannelInterpRow> planChannelInterpolation(
const std::vector<double>& offsets, double targetDy) {
const int n = static_cast<int>(offsets.size());
std::vector<ChannelInterpRow> rows;
// 退化:通道<2 或 targetDy 非法 → 逐通道 identity。
if (n < 2 || targetDy <= 0.0) {
for (int i = 0; i < n; ++i) rows.push_back({i, i, 0.0});
return rows;
}
// 按偏移排序的通道索引(端点 / 区间定位用;通道本身可能非有序)。
std::vector<int> ord(n);
std::iota(ord.begin(), ord.end(), 0);
std::sort(ord.begin(), ord.end(),
[&](int x, int y) { return offsets[x] < offsets[y]; });
const double mn = offsets[ord.front()];
const double mx = offsets[ord.back()];
const double span = mx - mn;
// 跨度已比 targetDy 还密 → 不加密,逐通道 identity保原通道序
if (span <= targetDy * 0.5) {
for (int i = 0; i < n; ++i) rows.push_back({i, i, 0.0});
return rows;
}
const int ny = static_cast<int>(std::lround(span / targetDy)) + 1;
int k = 0; // ord 内区间左指针offsets[ord[k]] <= p
for (int j = 0; j < ny; ++j) {
const double p = mn + static_cast<double>(j) * targetDy;
while (k + 1 < n && offsets[ord[k + 1]] < p) ++k;
if (k + 1 >= n) { // p 在最右通道之外 → 取最右通道
rows.push_back({ord[n - 1], ord[n - 1], 0.0});
continue;
}
const int a = ord[k], b = ord[k + 1];
const double oa = offsets[a], ob = offsets[b];
double wb = (ob > oa) ? (p - oa) / (ob - oa) : 0.0;
wb = std::clamp(wb, 0.0, 1.0);
rows.push_back({a, b, wb});
}
return rows;
}
double depthOfSample(int s, const IprHeader& h) { double depthOfSample(int s, const IprHeader& h) {
if (h.samples <= 1) return 0.0; // 防除零 if (h.samples <= 1) return 0.0; // 防除零
const double timeNs = static_cast<double>(s) * h.timeWindowNs / const double timeNs = static_cast<double>(s) * h.timeWindowNs /

View File

@ -11,6 +11,22 @@ namespace geopro::io::gpr {
// 解析 .ord 文本,返回末列==1 的有效通道的横向偏移(第 2 列),按文件顺序。 // 解析 .ord 文本,返回末列==1 的有效通道的横向偏移(第 2 列),按文件顺序。
std::vector<double> parseChannelXOffsets(const std::string& ordText); std::vector<double> parseChannelXOffsets(const std::string& ordText);
// 通道间插值方案:一个输出网格行 = (1-wb)*通道[a] + wb*通道[b](线性)。
// a==b 时即原样取该通道(无插值)。
struct ChannelInterpRow {
int a = 0;
int b = 0;
double wb = 0.0;
};
// 按真实横向偏移 offsets(米,逐通道) + 目标横向间距 targetDy(米) 规则网格化通道维(Y)
// 返回每个输出网格行的线性插值方案。网格在 [min(off), max(off)] 上以 targetDy 等距取
// ny = round(span/targetDy)+1 行;每行找两侧最近真实通道线性插值(端点外用端点)。
// 退化(通道<2 / targetDy<=0 / 跨度已比 targetDy 还密) → 逐通道 identity每通道一行
// 纯函数,便于单测:不依赖任何文件/模型。
std::vector<ChannelInterpRow> planChannelInterpolation(
const std::vector<double>& offsets, double targetDy);
// 采样序号 s → 深度(米)。depth = soilVelocity[m/s] * (s * timeWindowNs/(samples-1) * 1e-9) / 2。 // 采样序号 s → 深度(米)。depth = soilVelocity[m/s] * (s * timeWindowNs/(samples-1) * 1e-9) / 2。
// samples<=1 时返回 0 防除零。 // samples<=1 时返回 0 防除零。
double depthOfSample(int s, const IprHeader& h); double depthOfSample(int s, const IprHeader& h);

View File

@ -8,6 +8,8 @@ namespace {
// 三维斜视方位角 / 仰角。 // 三维斜视方位角 / 仰角。
constexpr double kAzimuth = 30.0; constexpr double kAzimuth = 30.0;
constexpr double kElevation = 25.0; constexpr double kElevation = 25.0;
// 二维分析近俯视:自正俯视下压的角度(12°→俯角约78°)。留一点倾斜使高程差可辨。
constexpr double kNearTopTilt = 12.0;
} // namespace } // namespace
void applyTop2D(vtkRenderer* r) void applyTop2D(vtkRenderer* r)
@ -37,6 +39,20 @@ void applyFree3D(vtkRenderer* r)
r->ResetCamera(); r->ResetCamera();
} }
void applyNearTop2D(vtkRenderer* r)
{
if (!r) return;
auto* c = r->GetActiveCamera();
c->ParallelProjectionOff(); // 透视:配合一点倾斜,使高程差可见(正交/正俯视下不可辨)
// 自正俯视(+Z 向下看、北朝上)起,下压 kNearTopTilt → 俯角约 78°方位不偏(正北俯视)。
c->SetFocalPoint(0, 0, 0);
c->SetPosition(0, 0, 1);
c->SetViewUp(0, 1, 0);
c->Elevation(kNearTopTilt);
c->OrthogonalizeViewUp();
r->ResetCamera();
}
void applyView(vtkRenderer* r, ViewDir dir) void applyView(vtkRenderer* r, ViewDir dir)
{ {
if (!r) return; if (!r) return;

View File

@ -8,6 +8,10 @@ void applyTop2D(vtkRenderer* r);
// 自由三维:透视投影,斜视方位看到剖面立体。 // 自由三维:透视投影,斜视方位看到剖面立体。
void applyFree3D(vtkRenderer* r); void applyFree3D(vtkRenderer* r);
// 二维分析近俯视:透视投影,自正俯视下压一点(约12°→约78°俯角)。留一点倾斜使高程差可见
// (绝对正俯视下高程不可辨),仅平移+缩放(旋转由 interactor style 锁定)。
void applyNearTop2D(vtkRenderer* r);
// 快捷视图方向(世界系 x=East,y=North,z=-depth // 快捷视图方向(世界系 x=East,y=North,z=-depth
// Top 俯视 (相机在 +Z 向下看) // Top 俯视 (相机在 +Z 向下看)
// Bottom 仰视 (相机在 -Z 向上看) // Bottom 仰视 (相机在 -Z 向上看)

View File

@ -2,19 +2,34 @@
namespace geopro::render { namespace geopro::render {
vtkSmartPointer<vtkLookupTable> buildLut(const geopro::core::ColorScale& cs, double vmin, double vmax, int n) vtkSmartPointer<vtkLookupTable> buildLut(const geopro::core::ColorScale& cs, double vmin, double vmax,
int n, bool transparentBelowRange)
{ {
if (n < 2) n = 2; // 至少两级,避免 (n-1) 退化 if (n < 2) n = 2; // 至少两级,避免 (n-1) 退化
auto lut = vtkSmartPointer<vtkLookupTable>::New(); auto lut = vtkSmartPointer<vtkLookupTable>::New();
lut->SetNumberOfTableValues(n); lut->SetNumberOfTableValues(n);
lut->SetTableRange(vmin, vmax);
// 白化(无数据)真透明:体把留空格设为哨兵 vmin-1.0< vmin切片 reslice 后据此识别。
// ⚠ 实测(tests/spike/slice_alpha_probe)vtkImagePlaneWidget 纹理【认】区间内 texel alpha
// alpha=0 的格→透明背后透出但【不认】UseBelowRangeColor下溢被钳到 0 号最低色格、
// 填蓝,根本不走 below-range 色)。故不能用 UseBelowRangeColor改为把下限下移一格、
// 预留 0 号格为全透明"白化槽"——下溢哨兵被钳到 0 号格即透明;真实 [vmin,vmax] 数据落
// 1..n-1 格(不透明),不受影响。
const double lo = transparentBelowRange ? vmin - (vmax - vmin) / (n - 1) : vmin;
lut->SetTableRange(lo, vmax);
for (int t = 0; t < n; ++t) { for (int t = 0; t < n; ++t) {
const double val = vmin + (vmax - vmin) * t / (n - 1); if (transparentBelowRange && t == 0) {
const auto c = cs.colorAt(val); lut->SetTableValue(0, 0.0, 0.0, 0.0, 0.0); // 白化槽:全透明(下溢钳到此)
// 复刻原版 three 渲染parseColor 只取 rgb、MeshBasicMaterial opacity=1 continue;
// 忽略 colorBar 的 alpha画满不透明 RGB。
lut->SetTableValue(t, c.r / 255.0, c.g / 255.0, c.b / 255.0, 1.0);
} }
const double val = lo + (vmax - lo) * t / (n - 1);
const auto c = cs.colorAt(val);
// 两级透明度(渲染时相乘,不烘焙):有效 alpha = 每色 alpha × 整体透明度。
// #RRGGBB 无 alpha 默认 a=255、整体默认 1 → 旧纯色阶不受影响alpha=0 真透明。
lut->SetTableValue(t, c.r / 255.0, c.g / 255.0, c.b / 255.0,
c.a / 255.0 * cs.globalOpacity());
}
if (transparentBelowRange) lut->SetNanColor(0.0, 0.0, 0.0, 0.0); // NaN 也透明(双保险)
lut->Build(); lut->Build();
return lut; return lut;
} }

View File

@ -5,6 +5,10 @@
namespace geopro::render { namespace geopro::render {
// 由 core 阶梯色阶构建 N 级 vtkLookupTable区间 [vmin, vmax]。 // 由 core 阶梯色阶构建 N 级 vtkLookupTable区间 [vmin, vmax]。
vtkSmartPointer<vtkLookupTable> buildLut(const geopro::core::ColorScale& cs, double vmin, double vmax, int n = 256); // transparentBelowRange=true把 < vmin 的标量(=三维体无数据格的留空哨兵 vmin-1.0)映射为
// 全透明(而非默认钳到最低档色不透明)。切片复用体的标量 image据此让白化区真透明、不填蓝。
// 仅切片需要;散点/等值线传 false保留"低于值域钳最低色"的原行为,避免误隐真实欠量数据)。
vtkSmartPointer<vtkLookupTable> buildLut(const geopro::core::ColorScale& cs, double vmin, double vmax,
int n = 256, bool transparentBelowRange = false);
} // namespace geopro::render } // namespace geopro::render

View File

@ -19,8 +19,8 @@ namespace {
// 虚线点画图案(16 位)与重复因子dashed 异常用。 // 虚线点画图案(16 位)与重复因子dashed 异常用。
constexpr int kDashPattern = 0xF0F0; constexpr int kDashPattern = 0xF0F0;
constexpr int kDashRepeat = 1; constexpr int kDashRepeat = 1;
// Point 型异常的方块点像素边长 // Point 型异常的小球像素直径RenderPointsAsSpheres 下为球径)
constexpr float kPointSize = 8.0F; constexpr float kPointSize = 13.0F;
// 把一个异常的 localPts 灌入 pointsx, -y, 0深度取负与 #18 同坐标系)。 // 把一个异常的 localPts 灌入 pointsx, -y, 0深度取负与 #18 同坐标系)。
void fillPoints2D(vtkPoints* points, const geopro::core::Anomaly& a) void fillPoints2D(vtkPoints* points, const geopro::core::Anomaly& a)
@ -82,6 +82,7 @@ vtkSmartPointer<vtkActor> buildActor(vtkPoints* points, std::size_t n,
actor->GetProperty()->SetColor(c.r / 255.0, c.g / 255.0, c.b / 255.0); actor->GetProperty()->SetColor(c.r / 255.0, c.g / 255.0, c.b / 255.0);
if (asPoints) { if (asPoints) {
actor->GetProperty()->SetPointSize(kPointSize); actor->GetProperty()->SetPointSize(kPointSize);
actor->GetProperty()->SetRenderPointsAsSpheres(true); // 点异常渲染为小球(非扁平方点)
} else { } else {
actor->GetProperty()->SetLineWidth(a.lineWidth > 0.0 ? a.lineWidth : 1.0); actor->GetProperty()->SetLineWidth(a.lineWidth > 0.0 ? a.lineWidth : 1.0);
if (a.dashed) { if (a.dashed) {

View File

@ -1,15 +1,25 @@
#include "actors/VoxelActor.hpp" #include "actors/VoxelActor.hpp"
#include <algorithm>
#include <cmath> #include <cmath>
#include <limits> #include <limits>
#include <vtkActor.h>
#include <vtkColorTransferFunction.h> #include <vtkColorTransferFunction.h>
#include <vtkDoubleArray.h> #include <vtkDoubleArray.h>
#include <vtkFloatArray.h>
#include <vtkFlyingEdges3D.h>
#include <vtkGPUVolumeRayCastMapper.h>
#include <vtkNew.h> #include <vtkNew.h>
#include <vtkPolyData.h>
#include <vtkPolyDataMapper.h>
#include <vtkProperty.h>
#include <vtkShortArray.h> #include <vtkShortArray.h>
#include <vtkSmartVolumeMapper.h> #include <vtkSmartVolumeMapper.h>
#include <vtkPiecewiseFunction.h> #include <vtkPiecewiseFunction.h>
#include <vtkPointData.h> #include <vtkPointData.h>
#include <vtkUnsignedCharArray.h>
#include <vtkVolumeMapper.h>
#include <vtkVolumeProperty.h> #include <vtkVolumeProperty.h>
namespace geopro::render { namespace geopro::render {
@ -18,29 +28,88 @@ namespace {
// 颜色/不透明度传递函数采样级数。 // 颜色/不透明度传递函数采样级数。
constexpr int kTransferSamples = 64; constexpr int kTransferSamples = 64;
// 体绘制最大不透明度([vmin,vmax] 线性 0→kMaxOpacity
constexpr double kMaxOpacity = 0.15; // 是否支持 GPU 体绘制(光线投射)。默认 true有独显的常态无 GPU 机器由 setVolumeGpuSupported(false)
// 设回退。影响mask 真白化只有 GPU mapper 支持 → 无 GPU 时不建 mask、改用 SmartVolumeMapper(自动 CPU 回退)
// 空值仍靠不透明度传函(哨兵→0)透明,仅交界处少了 mask 的干净边(重现一圈细渗色)。
bool g_gpuVolumeSupported = true;
// NaN/留空格的哨兵:落在 [vmin,vmax] 之外,传递函数把它映射为完全透明。 // NaN/留空格的哨兵:落在 [vmin,vmax] 之外,传递函数把它映射为完全透明。
double sentinel(double vmin) { return vmin - 1.0; } double sentinel(double vmin) { return vmin - 1.0; }
// double/int16 两版公用的 mapper+property+volume 组装(行为与原 double 版一致)。 // 二值 mask 体UCHAR255=有效、0=空值)。与标量同维同 origin/spacing、同点序
// id=(k*ny+j)*nx+i。空值格 mask=0 → 喂给 GPU ray cast 后被完全跳过:不着色、不参与
// 三线性插值,对齐 Surfer Blanking 的真白化语义(消除"空白处沿数据边界渗蓝")。
// 调用方在填标量的同一循环里写 m->SetValue(id, valid?255:0)。
vtkSmartPointer<vtkImageData> makeMaskLike(int nx, int ny, int nz,
double ox, double oy, double oz,
double dx, double dy, double dz,
vtkUnsignedCharArray*& outArr)
{
auto mask = vtkSmartPointer<vtkImageData>::New();
mask->SetDimensions(nx, ny, nz);
mask->SetOrigin(ox, oy, oz);
mask->SetSpacing(dx, dy, dz);
vtkNew<vtkUnsignedCharArray> m;
m->SetName("mask");
m->SetNumberOfTuples(static_cast<vtkIdType>(nx) * ny * nz);
mask->GetPointData()->SetScalars(m);
outArr = m; // image 持有引用,循环结束前有效
return mask;
}
// double/int16 两版公用的 mapper+property+volume 组装。mask 非空 → 用 GPU ray cast + 二值 mask
// 做真白化SmartVolumeMapper 不转发 mask故走 GPU mapper桌面端恒有 GL 上下文);
// mask 为空 → 保留 SmartVolumeMapperGPU/CPU 自适应)。
vtkSmartPointer<vtkVolume> assembleVolume(vtkImageData* img, vtkSmartPointer<vtkVolume> assembleVolume(vtkImageData* img,
vtkColorTransferFunction* color, vtkColorTransferFunction* color,
vtkPiecewiseFunction* opacity) vtkPiecewiseFunction* opacity,
vtkImageData* mask)
{ {
// 采样距离 + 不透明度单位距离用到几何尺度。
double sp[3];
img->GetSpacing(sp);
const double minSp =
std::min({std::abs(sp[0]), std::abs(sp[1]), std::abs(sp[2])}); // 最细体素维度
double bnd[6];
img->GetBounds(bnd);
const double diag = std::sqrt((bnd[1] - bnd[0]) * (bnd[1] - bnd[0]) +
(bnd[3] - bnd[2]) * (bnd[3] - bnd[2]) +
(bnd[5] - bnd[4]) * (bnd[5] - bnd[4])); // 包围盒对角(最长穿越路径)
vtkSmartPointer<vtkVolumeMapper> mapper;
if (mask && g_gpuVolumeSupported) {
// 真白化mask=0 体素被光线投射完全跳过,杜绝空值格沿边界渗蓝。需 GPU 光线投射支持。
vtkNew<vtkGPUVolumeRayCastMapper> gpu;
gpu->SetInputData(img);
gpu->SetMaskInput(mask);
gpu->SetMaskTypeToBinary();
gpu->SetAutoAdjustSampleDistances(0); // 全程全质量GPU 直接 mapper 无交互降采样开关)
// 关了自适应必须显式给【细】采样距离,否则用粗默认值 → 看到一层层体素(分层伪影)。
if (minSp > 0) gpu->SetSampleDistance(static_cast<float>(0.3 * minSp));
// 抖动:用噪声纹理微扰每条光线的采样起点,消除规则采样面造成的「木纹/分层」伪影VTK 官方此用途)。
gpu->SetUseJittering(1);
mapper = gpu;
} else {
// SmartVolumeMapper有 GPU 走 GPU ray cast否则自动回退 CPU避免无 GPU 时卡死/失败。 // SmartVolumeMapper有 GPU 走 GPU ray cast否则自动回退 CPU避免无 GPU 时卡死/失败。
vtkNew<vtkSmartVolumeMapper> mapper; vtkNew<vtkSmartVolumeMapper> sm;
mapper->SetInputData(img); sm->SetInputData(img);
// 全程统一全质量(GPU 足够快, 实测 ~7ms/帧):关掉交互降采样, 避免"停手补高清"那一帧突跳停顿。 // 全程统一全质量(GPU 足够快, 实测 ~7ms/帧):关掉交互降采样, 避免"停手补高清"那一帧突跳停顿。
mapper->SetAutoAdjustSampleDistances(0); sm->SetAutoAdjustSampleDistances(0);
mapper->SetInteractiveAdjustSampleDistances(0); sm->SetInteractiveAdjustSampleDistances(0);
mapper = sm;
}
vtkNew<vtkVolumeProperty> prop; vtkNew<vtkVolumeProperty> prop;
prop->SetColor(color); prop->SetColor(color);
prop->SetScalarOpacity(opacity); prop->SetScalarOpacity(opacity);
prop->SetInterpolationTypeToLinear(); prop->SetInterpolationTypeToLinear();
prop->ShadeOff(); prop->ShadeOff();
// 不透明度单位距离 = 包围盒对角 × kOpacityUnitFraction控制沿深度的累积速度使色阶「不透明度」滑块
// 有层次。取对角/10100%(每单位=1.0)→沿体累积到≈实心、10% 很淡。太大(=整条对角)→100% 也偏透;
// 太小(=体素)→ 低不透明度也累积到全不透明。
constexpr double kOpacityUnitFraction = 0.1;
if (diag > 0) prop->SetScalarOpacityUnitDistance(kOpacityUnitFraction * diag);
auto volume = vtkSmartPointer<vtkVolume>::New(); auto volume = vtkSmartPointer<vtkVolume>::New();
volume->SetMapper(mapper); volume->SetMapper(mapper);
@ -74,14 +143,52 @@ vtkSmartPointer<vtkVolume> assembleVolumeI16(vtkImageData* img,
vtkNew<vtkPiecewiseFunction> opacity; vtkNew<vtkPiecewiseFunction> opacity;
opacity->AddPoint(static_cast<double>(geopro::core::ScalarVolumeI16::kBlank), 0.0); opacity->AddPoint(static_cast<double>(geopro::core::ScalarVolumeI16::kBlank), 0.0);
opacity->AddPoint(qminD, 0.0); for (int t = 0; t < kTransferSamples; ++t) {
opacity->AddPoint(qmaxD, kMaxOpacity); const double qd = qminD + (qmaxD - qminD) * t / (kTransferSamples - 1);
const auto qvLevel = static_cast<std::int16_t>(std::lround(qd));
const double phys = q.toPhys(qvLevel);
opacity->AddPoint(qd, cs.colorAt(phys).a / 255.0 * cs.globalOpacity());
}
return assembleVolume(img, color, opacity); // 由预建 short 体扫出二值 maskkBlank→0 跳过)。稠密体(无 kBlank→ 全 255等价无 mask。
int dims[3];
img->GetDimensions(dims);
vtkUnsignedCharArray* mArr = nullptr;
auto mask = makeMaskLike(dims[0], dims[1], dims[2], img->GetOrigin()[0], img->GetOrigin()[1],
img->GetOrigin()[2], img->GetSpacing()[0], img->GetSpacing()[1],
img->GetSpacing()[2], mArr);
if (auto* sc = vtkShortArray::SafeDownCast(img->GetPointData()->GetScalars())) {
const vtkIdType n = sc->GetNumberOfTuples();
for (vtkIdType id = 0; id < n; ++id)
mArr->SetValue(id, sc->GetValue(id) == geopro::core::ScalarVolumeI16::kBlank ? 0 : 255);
}
return assembleVolume(img, color, opacity, mask);
} }
} // namespace } // namespace
void setVolumeGpuSupported(bool ok) { g_gpuVolumeSupported = ok; }
void updateVolumeColors(vtkVolume* volume, const geopro::core::ColorScale& cs, double vmin,
double vmax) {
if (!volume || !volume->GetProperty()) return;
if (vmin >= vmax) vmax = vmin + 1.0;
const double blank = sentinel(vmin);
// 与 buildVoxel(float 路径) 同口径重建颜色/不透明度传函,原地换到已有 actor 上(不重建 image →
// 切片基底不变、不被关闭)。
vtkNew<vtkColorTransferFunction> color;
vtkNew<vtkPiecewiseFunction> opacity;
opacity->AddPoint(blank, 0.0);
for (int t = 0; t < kTransferSamples; ++t) {
const double val = vmin + (vmax - vmin) * t / (kTransferSamples - 1);
const auto c = cs.colorAt(val);
color->AddRGBPoint(val, c.r / 255.0, c.g / 255.0, c.b / 255.0);
opacity->AddPoint(val, c.a / 255.0 * cs.globalOpacity());
}
volume->GetProperty()->SetColor(color);
volume->GetProperty()->SetScalarOpacity(opacity);
}
vtkSmartPointer<vtkVolume> buildVoxel(const geopro::core::ScalarVolume& vol, vtkSmartPointer<vtkVolume> buildVoxel(const geopro::core::ScalarVolume& vol,
const geopro::core::ColorScale& cs, const geopro::core::ColorScale& cs,
double ox, double oy, double oz, double ox, double oy, double oz,
@ -100,7 +207,13 @@ vtkSmartPointer<vtkVolume> buildVoxel(const geopro::core::ScalarVolume& vol,
img->SetOrigin(ox, oy, oz); img->SetOrigin(ox, oy, oz);
img->SetSpacing(dx, dy, dz); img->SetSpacing(dx, dy, dz);
vtkNew<vtkDoubleArray> sc; // 标量用 float非 doubleOpenGL 无原生 double 体纹理GPU 体绘制对 double 处理不稳/部分驱动间歇
// 出空偶发不渲染根因之一且省一半显存。float 精度对可视化足够。
// 二值 maskNaN 空格→0光线投射跳过真白化有值→255。与标量同循环填免二次扫描。
vtkUnsignedCharArray* mArr = nullptr;
auto mask = makeMaskLike(nx, ny, nz, ox, oy, oz, dx, dy, dz, mArr);
vtkNew<vtkFloatArray> sc;
sc->SetName("v"); sc->SetName("v");
sc->SetNumberOfTuples(static_cast<vtkIdType>(nx) * ny * nz); sc->SetNumberOfTuples(static_cast<vtkIdType>(nx) * ny * nz);
// 点序 i 最快、j 次之、k 最慢(匹配 vtkImageData 与 ScalarVolume::idx // 点序 i 最快、j 次之、k 最慢(匹配 vtkImageData 与 ScalarVolume::idx
@ -109,7 +222,9 @@ vtkSmartPointer<vtkVolume> buildVoxel(const geopro::core::ScalarVolume& vol,
for (int i = 0; i < nx; ++i) { for (int i = 0; i < nx; ++i) {
const double v = vol.at(i, j, k); const double v = vol.at(i, j, k);
const vtkIdType id = (static_cast<vtkIdType>(k) * ny + j) * nx + i; const vtkIdType id = (static_cast<vtkIdType>(k) * ny + j) * nx + i;
sc->SetValue(id, std::isnan(v) ? blank : v); // NaN → 哨兵 const bool isBlank = std::isnan(v);
sc->SetValue(id, static_cast<float>(isBlank ? blank : v)); // NaN → 哨兵
mArr->SetValue(id, isBlank ? 0 : 255);
} }
img->GetPointData()->SetScalars(sc); img->GetPointData()->SetScalars(sc);
outImage = img; outImage = img;
@ -122,13 +237,17 @@ vtkSmartPointer<vtkVolume> buildVoxel(const geopro::core::ScalarVolume& vol,
color->AddRGBPoint(val, c.r / 255.0, c.g / 255.0, c.b / 255.0); color->AddRGBPoint(val, c.r / 255.0, c.g / 255.0, c.b / 255.0);
} }
// 不透明度传递函数:哨兵 → 0透明[vmin,vmax] 线性递增到 kMaxOpacity。 // 不透明度传递函数:哨兵 → 0透明区间内由色阶 alpha 驱动,再乘体密度主控 kMaxOpacity。
// 体素不透明度 = (色阶 alpha/255) × kMaxOpacity整体透明度已在配置时乘进 alpha
// alpha=0 → 真透明alpha=255无 alpha 色阶默认)→ 维持 kMaxOpacity 的通透手感,不回归。
vtkNew<vtkPiecewiseFunction> opacity; vtkNew<vtkPiecewiseFunction> opacity;
opacity->AddPoint(blank, 0.0); opacity->AddPoint(blank, 0.0);
opacity->AddPoint(vmin, 0.0); for (int t = 0; t < kTransferSamples; ++t) {
opacity->AddPoint(vmax, kMaxOpacity); const double val = vmin + (vmax - vmin) * t / (kTransferSamples - 1);
opacity->AddPoint(val, cs.colorAt(val).a / 255.0 * cs.globalOpacity());
}
return assembleVolume(img, color, opacity); return assembleVolume(img, color, opacity, mask);
} }
vtkSmartPointer<vtkVolume> buildVoxelI16(const geopro::core::ScalarVolumeI16& vol, vtkSmartPointer<vtkVolume> buildVoxelI16(const geopro::core::ScalarVolumeI16& vol,
@ -149,6 +268,10 @@ vtkSmartPointer<vtkVolume> buildVoxelI16(const geopro::core::ScalarVolumeI16& vo
img->SetOrigin(ox, oy, oz); img->SetOrigin(ox, oy, oz);
img->SetSpacing(dx, dy, dz); img->SetSpacing(dx, dy, dz);
// 二值 maskkBlank 空格→0真白化跳过有值→255。与标量同循环填。
vtkUnsignedCharArray* mArr = nullptr;
auto mask = makeMaskLike(nx, ny, nz, ox, oy, oz, dx, dy, dz, mArr);
vtkNew<vtkShortArray> sc; vtkNew<vtkShortArray> sc;
sc->SetName("v"); sc->SetName("v");
sc->SetNumberOfTuples(static_cast<vtkIdType>(nx) * ny * nz); sc->SetNumberOfTuples(static_cast<vtkIdType>(nx) * ny * nz);
@ -160,6 +283,7 @@ vtkSmartPointer<vtkVolume> buildVoxelI16(const geopro::core::ScalarVolumeI16& vo
const std::int16_t qv = vol.at(i, j, k); const std::int16_t qv = vol.at(i, j, k);
const vtkIdType id = (static_cast<vtkIdType>(k) * ny + j) * nx + i; const vtkIdType id = (static_cast<vtkIdType>(k) * ny + j) * nx + i;
sc->SetValue(id, qv); sc->SetValue(id, qv);
mArr->SetValue(id, qv == geopro::core::ScalarVolumeI16::kBlank ? 0 : 255);
} }
img->GetPointData()->SetScalars(sc); img->GetPointData()->SetScalars(sc);
outImage = img; outImage = img;
@ -180,13 +304,17 @@ vtkSmartPointer<vtkVolume> buildVoxelI16(const geopro::core::ScalarVolumeI16& vo
color->AddRGBPoint(qd, c.r / 255.0, c.g / 255.0, c.b / 255.0); color->AddRGBPoint(qd, c.r / 255.0, c.g / 255.0, c.b / 255.0);
} }
// 不透明度传递函数量化域kBlank → 0透明[qmin,qmax] 线性递增到 kMaxOpacity。 // 不透明度传递函数量化域kBlank → 0透明区间内由色阶 alpha 驱动 × 体密度主控 kMaxOpacity。
vtkNew<vtkPiecewiseFunction> opacity; vtkNew<vtkPiecewiseFunction> opacity;
opacity->AddPoint(static_cast<double>(geopro::core::ScalarVolumeI16::kBlank), 0.0); opacity->AddPoint(static_cast<double>(geopro::core::ScalarVolumeI16::kBlank), 0.0);
opacity->AddPoint(qminD, 0.0); for (int t = 0; t < kTransferSamples; ++t) {
opacity->AddPoint(qmaxD, kMaxOpacity); const double qd = qminD + (qmaxD - qminD) * t / (kTransferSamples - 1);
const auto qvLevel = static_cast<std::int16_t>(std::lround(qd));
const double phys = q.toPhys(qvLevel);
opacity->AddPoint(qd, cs.colorAt(phys).a / 255.0 * cs.globalOpacity());
}
return assembleVolume(img, color, opacity); return assembleVolume(img, color, opacity, mask);
} }
vtkSmartPointer<vtkVolume> buildVoxel(const geopro::core::ScalarVolume& vol, vtkSmartPointer<vtkVolume> buildVoxel(const geopro::core::ScalarVolume& vol,
@ -208,4 +336,34 @@ vtkSmartPointer<vtkVolume> buildVoxelI16FromImage(vtkImageData* shortImg,
return assembleVolumeI16(shortImg, q, cs, vminPhys, vmaxPhys); return assembleVolumeI16(shortImg, q, cs, vminPhys, vmaxPhys);
} }
vtkSmartPointer<vtkActor> buildIsosurface(vtkImageData* img, const geopro::core::ColorScale& cs,
double vmin, double vmax, double isoValue)
{
if (!img) return nullptr;
if (vmin >= vmax) vmax = vmin + 1.0;
// 阈值钳进 (vmin,vmax)=vmin 会沿留空哨兵边界成面、=vmax 抽不出。
const double eps = 1e-6 * (vmax - vmin);
isoValue = std::max(vmin + eps, std::min(vmax - eps, isoValue));
vtkNew<vtkFlyingEdges3D> fe;
fe->SetInputData(img);
fe->SetValue(0, isoValue);
fe->ComputeNormalsOn();
fe->ComputeGradientsOff();
fe->ComputeScalarsOff();
fe->Update();
if (!fe->GetOutput() || fe->GetOutput()->GetNumberOfPoints() == 0) return nullptr; // 无超阈区
vtkNew<vtkPolyDataMapper> mapper;
mapper->SetInputConnection(fe->GetOutputPort());
mapper->ScalarVisibilityOff(); // 用 actor 实色,不按标量着色
auto actor = vtkSmartPointer<vtkActor>::New();
actor->SetMapper(mapper);
const auto c = cs.colorAt(isoValue); // 阈值处的色(高值多为暖红,复刻参考图红块)
actor->GetProperty()->SetColor(c.r / 255.0, c.g / 255.0, c.b / 255.0);
actor->GetProperty()->SetOpacity(1.0); // 不透明实心
return actor;
}
} // namespace geopro::render } // namespace geopro::render

View File

@ -1,4 +1,5 @@
#pragma once #pragma once
#include <vtkActor.h>
#include <vtkSmartPointer.h> #include <vtkSmartPointer.h>
#include <vtkVolume.h> #include <vtkVolume.h>
#include <vtkImageData.h> #include <vtkImageData.h>
@ -7,6 +8,21 @@
#include "model/ScalarVolumeI16.hpp" #include "model/ScalarVolumeI16.hpp"
namespace geopro::render { namespace geopro::render {
// 设置是否支持 GPU 体绘制启动探测后调一次。false → 体绘制回退 SmartVolumeMapper(CPU 自适应)、
// 不建 mask空值仍透明仅边缘少了 mask 的干净度)。默认 true。
void setVolumeGpuSupported(bool ok);
// 原地更新已渲染体的颜色/不透明度(仅换传函,不重建 image色阶改动时用避免重建 image 把切片基底
// 换掉、连带关闭未保存切片。float 体路径口径(标准 addVolume 产物)。
void updateVolumeColors(vtkVolume* volume, const geopro::core::ColorScale& cs, double vmin,
double vmax);
// 体上抽等值面marching cubes/FlyingEdges→ 不透明实心 actor凸显超阈异常体参考图红块
// img 为 buildVoxel 暴露的 vtkImageData标量=物理值,留空=哨兵 vmin-1低于任意 isoValue 不成面)。
// isoValue 在 [vmin,vmax] 内;颜色取 ColorScale 在 isoValue 处的实色、不透明。无超阈区 → 返回 nullptr。
vtkSmartPointer<vtkActor> buildIsosurface(vtkImageData* img, const geopro::core::ColorScale& cs,
double vmin, double vmax, double isoValue);
// 把 core 规则标量体IDW 输出,含 NaN 留空)转 vtkImageData再建 GPU 光线投射体绘制。 // 把 core 规则标量体IDW 输出,含 NaN 留空)转 vtkImageData再建 GPU 光线投射体绘制。
// 颜色按 ColorScale 在 [vmin,vmax] 采样NaN/留空格 → 不透明度 0透明 // 颜色按 ColorScale 在 [vmin,vmax] 采样NaN/留空格 → 不透明度 0透明
// 返回 vtkVolume由调用方加入 renderer // 返回 vtkVolume由调用方加入 renderer

View File

@ -17,8 +17,6 @@
#include <vtkProperty.h> #include <vtkProperty.h>
#include <vtkRenderWindowInteractor.h> #include <vtkRenderWindowInteractor.h>
#include <vtkRenderer.h> #include <vtkRenderer.h>
#include <vtkTextActor.h>
#include <vtkTextProperty.h>
namespace geopro::render::interact { namespace geopro::render::interact {
@ -27,6 +25,7 @@ constexpr double kEps = 1e-9;
constexpr double kObserverPriority = 5.0; // 高于切片 widget 与右键菜单(1.0),绘制期独占 constexpr double kObserverPriority = 5.0; // 高于切片 widget 与右键菜单(1.0),绘制期独占
constexpr double kDoubleClickMs = 350.0; // 左键双击闭合阈值 constexpr double kDoubleClickMs = 350.0; // 左键双击闭合阈值
constexpr int kClickSlopPx = 6; // 双击位置相近阈值(px) constexpr int kClickSlopPx = 6; // 双击位置相近阈值(px)
constexpr int kCloseSnapPx = 12; // 面:光标/点击邻近起点的吸附阈值(px)
double nowMs() { double nowMs() {
return std::chrono::duration<double, std::milli>( return std::chrono::duration<double, std::milli>(
@ -40,10 +39,11 @@ AnomalyDrawTool::AnomalyDrawTool(vtkRenderWindowInteractor* interactor, vtkRende
AnomalyDrawTool::~AnomalyDrawTool() { removeObservers(); } AnomalyDrawTool::~AnomalyDrawTool() { removeObservers(); }
void AnomalyDrawTool::start(const Vec3& planeOrigin, const Vec3& planeNormal, void AnomalyDrawTool::start(DrawMode mode, const Vec3& planeOrigin, const Vec3& planeNormal,
std::function<void(const std::vector<Vec3>&)> onFinish, std::function<void(const std::vector<Vec3>&)> onFinish,
std::function<void()> onCancel) { std::function<void()> onCancel) {
if (active_) cancel(); if (active_) cancel();
mode_ = mode;
origin_ = planeOrigin; origin_ = planeOrigin;
normal_ = normalize(planeNormal); normal_ = normalize(planeNormal);
onFinish_ = std::move(onFinish); onFinish_ = std::move(onFinish);
@ -53,18 +53,8 @@ void AnomalyDrawTool::start(const Vec3& planeOrigin, const Vec3& planeNormal,
hasCursor_ = false; hasCursor_ = false;
active_ = true; active_ = true;
installObservers(); installObservers();
// 操作提示由 app 层 QLabel 浮层承担(VTK 内置字体不含中文字形 → vtkTextActor 渲染不出中文)。
// 屏幕操作提示(左上角),解决"不知如何闭合"。
if (renderer_) {
hint_ = vtkSmartPointer<vtkTextActor>::New();
hint_->SetInput("圈定异常:左键逐点 · 双击或右键完成 · Esc 取消");
hint_->GetTextProperty()->SetFontSize(16);
hint_->GetTextProperty()->SetColor(1.0, 0.9, 0.0);
hint_->GetPositionCoordinate()->SetCoordinateSystemToNormalizedViewport();
hint_->GetPositionCoordinate()->SetValue(0.02, 0.94);
renderer_->AddViewProp(hint_);
if (interactor_) interactor_->Render(); if (interactor_) interactor_->Render();
}
} }
void AnomalyDrawTool::cancel() { void AnomalyDrawTool::cancel() {
@ -80,11 +70,9 @@ void AnomalyDrawTool::teardownActive() {
if (renderer_) { if (renderer_) {
if (preview_) renderer_->RemoveViewProp(preview_); if (preview_) renderer_->RemoveViewProp(preview_);
if (rubber_) renderer_->RemoveViewProp(rubber_); if (rubber_) renderer_->RemoveViewProp(rubber_);
if (hint_) renderer_->RemoveViewProp(hint_);
} }
preview_ = nullptr; preview_ = nullptr;
rubber_ = nullptr; rubber_ = nullptr;
hint_ = nullptr;
active_ = false; active_ = false;
hasCursor_ = false; hasCursor_ = false;
pts_.clear(); pts_.clear();
@ -115,7 +103,20 @@ Vec3 AnomalyDrawTool::pickOnPlane() const {
return Vec3{{nearP[0] + t * dir[0], nearP[1] + t * dir[1], nearP[2] + t * dir[2]}}; return Vec3{{nearP[0] + t * dir[0], nearP[1] + t * dir[1], nearP[2] + t * dir[2]}};
} }
bool AnomalyDrawTool::nearFirstVertex(int sx, int sy) const {
if (pts_.empty() || !renderer_) return false;
renderer_->SetWorldPoint(pts_.front()[0], pts_.front()[1], pts_.front()[2], 1.0);
renderer_->WorldToDisplay();
double d[3];
renderer_->GetDisplayPoint(d);
return std::abs(d[0] - sx) <= kCloseSnapPx && std::abs(d[1] - sy) <= kCloseSnapPx;
}
void AnomalyDrawTool::addVertex() { void AnomalyDrawTool::addVertex() {
// 点模式:单点,再次左键 = 重定位(微调),不累积;线/面模式:累积顶点。
if (mode_ == DrawMode::Point && !pts_.empty())
pts_[0] = pickOnPlane();
else
pts_.push_back(pickOnPlane()); pts_.push_back(pickOnPlane());
updatePreview(); updatePreview();
} }
@ -158,7 +159,8 @@ void AnomalyDrawTool::updatePreview() {
preview_->SetMapper(mapper); preview_->SetMapper(mapper);
preview_->GetProperty()->SetColor(1.0, 0.9, 0.0); // 亮黄 preview_->GetProperty()->SetColor(1.0, 0.9, 0.0); // 亮黄
preview_->GetProperty()->SetLineWidth(2.0); preview_->GetProperty()->SetLineWidth(2.0);
preview_->GetProperty()->SetPointSize(9.0); // 醒目圆点 preview_->GetProperty()->SetPointSize(mode_ == DrawMode::Point ? 16.0 : 9.0); // 点模式更醒目
preview_->GetProperty()->SetRenderPointsAsSpheres(true); // 顶点渲染为小球(图钉感)
renderer_->AddActor(preview_); renderer_->AddActor(preview_);
interactor_->Render(); interactor_->Render();
} }
@ -167,16 +169,18 @@ void AnomalyDrawTool::updateRubber() {
if (!renderer_) return; if (!renderer_) return;
if (rubber_) renderer_->RemoveViewProp(rubber_); if (rubber_) renderer_->RemoveViewProp(rubber_);
rubber_ = nullptr; rubber_ = nullptr;
if (pts_.empty() || !hasCursor_) { // 点模式:单点标注,不拉末点→光标的橡皮筋线(否则点完还甩出一条线,用户反馈)。
if (mode_ == DrawMode::Point || pts_.empty() || !hasCursor_) {
if (interactor_) interactor_->Render(); if (interactor_) interactor_->Render();
return; return;
} }
// 末点 → 当前光标投影点 的虚线橡皮筋(跟手反馈)。 // 末点 → 光标 的虚线橡皮筋(跟手反馈);面模式光标邻近起点 → 指向起点,预览闭合
const Vec3& a = pts_.back(); const Vec3& a = pts_.back();
const Vec3 endP = cursorNearStart_ ? pts_.front() : cursorPt_;
vtkNew<vtkPoints> points; vtkNew<vtkPoints> points;
points->SetNumberOfPoints(2); points->SetNumberOfPoints(2);
points->SetPoint(0, a[0], a[1], a[2]); points->SetPoint(0, a[0], a[1], a[2]);
points->SetPoint(1, cursorPt_[0], cursorPt_[1], cursorPt_[2]); points->SetPoint(1, endP[0], endP[1], endP[2]);
vtkNew<vtkPolyData> poly; vtkNew<vtkPolyData> poly;
poly->SetPoints(points); poly->SetPoints(points);
vtkNew<vtkPolyLine> line; vtkNew<vtkPolyLine> line;
@ -200,7 +204,9 @@ void AnomalyDrawTool::updateRubber() {
} }
void AnomalyDrawTool::finish() { void AnomalyDrawTool::finish() {
if (pts_.size() < 3) { // 不足以成面 → 取消 const std::size_t minPts =
mode_ == DrawMode::Point ? 1 : (mode_ == DrawMode::Line ? 2 : 3); // 点1/线2/面3
if (pts_.size() < minPts) { // 不足以成形 → 取消
cancel(); cancel();
return; return;
} }
@ -221,6 +227,9 @@ void AnomalyDrawTool::installObservers() {
// 鼠标移动:更新末点→光标的虚线橡皮筋(跟手反馈)。不 abort不干扰其它悬停。 // 鼠标移动:更新末点→光标的虚线橡皮筋(跟手反馈)。不 abort不干扰其它悬停。
self->cursorPt_ = self->pickOnPlane(); self->cursorPt_ = self->pickOnPlane();
self->hasCursor_ = true; self->hasCursor_ = true;
const int* mp = self->interactor_->GetEventPosition();
self->cursorNearStart_ = self->mode_ == DrawMode::Face && self->pts_.size() >= 3 &&
self->nearFirstVertex(mp[0], mp[1]);
self->updateRubber(); self->updateRubber();
return; return;
} }
@ -229,9 +238,21 @@ void AnomalyDrawTool::installObservers() {
if (self->cmd_) self->cmd_->SetAbortFlag(1); if (self->cmd_) self->cmd_->SetAbortFlag(1);
switch (eid) { switch (eid) {
case vtkCommand::LeftButtonPressEvent: { case vtkCommand::LeftButtonPressEvent: {
// 左键双连击 = 闭合(标准多边形交互);否则加顶点。 // 点:单击即落点并完成(业界通用,无需双击/回车)。
if (self->mode_ == DrawMode::Point) {
self->addVertex();
self->finish();
break;
}
const double now = nowMs(); const double now = nowMs();
const int* p = self->interactor_->GetEventPosition(); const int* p = self->interactor_->GetEventPosition();
// 面:点回起点(屏幕邻近)闭合(≥3点),不加点。
if (self->mode_ == DrawMode::Face && self->pts_.size() >= 3 &&
self->nearFirstVertex(p[0], p[1])) {
self->finish();
break;
}
// 线:左键双连击 = 完成;否则加顶点。
const bool dbl = self->lastClickMs_ >= 0.0 && const bool dbl = self->lastClickMs_ >= 0.0 &&
(now - self->lastClickMs_) < kDoubleClickMs && (now - self->lastClickMs_) < kDoubleClickMs &&
std::abs(p[0] - self->lastClickX_) <= kClickSlopPx && std::abs(p[0] - self->lastClickX_) <= kClickSlopPx &&
@ -239,17 +260,29 @@ void AnomalyDrawTool::installObservers() {
self->lastClickMs_ = now; self->lastClickMs_ = now;
self->lastClickX_ = p[0]; self->lastClickX_ = p[0];
self->lastClickY_ = p[1]; self->lastClickY_ = p[1];
if (dbl) if (dbl) {
// 双击结束:第一下已落点(=双击位置),保留为末顶点直接完成(含双击位置,同地图工具)。
self->finish(); self->finish();
else } else {
self->addVertex(); self->addVertex();
}
break; break;
} }
case vtkCommand::RightButtonPressEvent: self->finish(); break; case vtkCommand::RightButtonPressEvent:
// 绘制中右键不提交(保留给「创建异常」菜单语义);已 abort 消费,不打开菜单。
break;
case vtkCommand::KeyPressEvent: { case vtkCommand::KeyPressEvent: {
const char* key = self->interactor_->GetKeySym(); const char* key = self->interactor_->GetKeySym();
if (key && (std::string(key) == "Escape")) self->cancel(); const std::string k = key ? std::string(key) : std::string();
else if (key && (std::string(key) == "Return")) self->finish(); if (k == "Escape")
self->cancel();
else if (k == "Return" || k == "KP_Enter")
self->finish();
else if ((k == "BackSpace" || k == "Delete") && !self->pts_.empty()) {
self->pts_.pop_back(); // 撤上一点
self->updatePreview();
self->updateRubber();
}
break; break;
} }
default: break; default: break;

View File

@ -9,26 +9,28 @@
class vtkRenderWindowInteractor; class vtkRenderWindowInteractor;
class vtkRenderer; class vtkRenderer;
class vtkActor; class vtkActor;
class vtkTextActor;
class vtkCallbackCommand; class vtkCallbackCommand;
namespace geopro::render::interact { namespace geopro::render::interact {
// 异常圈定工具(#4b在给定切片平面上交互式画多边形。 // 异常圈定工具(#4b在给定切片平面上交互式画 点 / 线 / 面。
// 左键逐点加顶点(屏幕射线与平面求交,落在平面上);右键 / 双击 / 回车 闭合 → onFinish(worldPts) // 左键逐点加顶点(屏幕射线与平面求交,落在平面上);**双击 / 回车 提交** → onFinish(worldPts)
// Esc / 不足 3 点闭合 → onCancel。绘制中实时预览折线。 // Esc 取消Backspace 撤上一点;点模式再次左键=重定位单点(微调)。右键绘制中不响应(保留给菜单语义)。
// 高优先级(2.0)交互器观察者抢输入:先于切片 widget 与 InteractionManager 右键菜单,绘制期独占左右键。 // 点≥1 / 线≥2(开放) / 面≥3(闭合);闭合与否由上层据 markType 渲染,本工具只产顶点。
// 高优先级(5.0)交互器观察者抢输入:先于切片 widget 与 InteractionManager 右键菜单,绘制期独占左右键。
// render 层:只碰 VTK不认业务产物(平面上的世界点)经回调交上层组装 core::Anomaly。 // render 层:只碰 VTK不认业务产物(平面上的世界点)经回调交上层组装 core::Anomaly。
class AnomalyDrawTool { class AnomalyDrawTool {
public: public:
enum class DrawMode { Point, Line, Face }; // 点(1)/线(2,开放)/面(3,闭合)
AnomalyDrawTool(vtkRenderWindowInteractor* interactor, vtkRenderer* renderer); AnomalyDrawTool(vtkRenderWindowInteractor* interactor, vtkRenderer* renderer);
~AnomalyDrawTool(); ~AnomalyDrawTool();
AnomalyDrawTool(const AnomalyDrawTool&) = delete; AnomalyDrawTool(const AnomalyDrawTool&) = delete;
AnomalyDrawTool& operator=(const AnomalyDrawTool&) = delete; AnomalyDrawTool& operator=(const AnomalyDrawTool&) = delete;
// 开始在平面(origin/normal)上圈定。onFinish 收闭合多边形顶点(世界系)onCancel 取消。 // 开始在平面(origin/normal)上按 mode 圈定。onFinish 收顶点(世界系)onCancel 取消。
void start(const Vec3& planeOrigin, const Vec3& planeNormal, void start(DrawMode mode, const Vec3& planeOrigin, const Vec3& planeNormal,
std::function<void(const std::vector<Vec3>&)> onFinish, std::function<void(const std::vector<Vec3>&)> onFinish,
std::function<void()> onCancel); std::function<void()> onCancel);
bool active() const { return active_; } bool active() const { return active_; }
@ -38,8 +40,9 @@ private:
void addVertex(); // 左键:加顶点 void addVertex(); // 左键:加顶点
void updatePreview(); // 重建已点几何(顶点圆点 + 实线折线;单点也可见) void updatePreview(); // 重建已点几何(顶点圆点 + 实线折线;单点也可见)
void updateRubber(); // 鼠标移动:末点→光标的虚线橡皮筋 void updateRubber(); // 鼠标移动:末点→光标的虚线橡皮筋
void finish(); // 右键/双击/回车:闭合 void finish(); // 双击/回车/(面)点起点:完成
Vec3 pickOnPlane() const; // 当前鼠标屏幕点 → 射线与平面交点 Vec3 pickOnPlane() const; // 当前鼠标屏幕点 → 射线与平面交点
bool nearFirstVertex(int sx, int sy) const; // 屏幕点是否邻近起点(面闭合判定/提示)
void installObservers(); void installObservers();
void removeObservers(); void removeObservers();
@ -49,6 +52,7 @@ private:
vtkRenderer* renderer_; vtkRenderer* renderer_;
bool active_ = false; bool active_ = false;
DrawMode mode_ = DrawMode::Face;
Vec3 origin_{{0, 0, 0}}, normal_{{0, 0, 1}}; Vec3 origin_{{0, 0, 0}}, normal_{{0, 0, 1}};
std::vector<Vec3> pts_; std::vector<Vec3> pts_;
std::function<void(const std::vector<Vec3>&)> onFinish_; std::function<void(const std::vector<Vec3>&)> onFinish_;
@ -56,9 +60,9 @@ private:
vtkSmartPointer<vtkActor> preview_; // 已点几何(顶点圆点 + 实线折线) vtkSmartPointer<vtkActor> preview_; // 已点几何(顶点圆点 + 实线折线)
vtkSmartPointer<vtkActor> rubber_; // 末点→光标 虚线橡皮筋 vtkSmartPointer<vtkActor> rubber_; // 末点→光标 虚线橡皮筋
vtkSmartPointer<vtkTextActor> hint_; // 屏幕操作提示
Vec3 cursorPt_{{0, 0, 0}}; // 当前鼠标在切面上的投影点 Vec3 cursorPt_{{0, 0, 0}}; // 当前鼠标在切面上的投影点
bool hasCursor_ = false; bool hasCursor_ = false;
bool cursorNearStart_ = false; // 面模式光标邻近起点 → 橡皮筋指向起点预览闭合
vtkSmartPointer<vtkCallbackCommand> cmd_; vtkSmartPointer<vtkCallbackCommand> cmd_;
unsigned long tagLeft_ = 0, tagMove_ = 0, tagRight_ = 0, tagKey_ = 0, tagDbl_ = 0; unsigned long tagLeft_ = 0, tagMove_ = 0, tagRight_ = 0, tagKey_ = 0, tagDbl_ = 0;
// 双击闭合检测(左键两连击):记上次左键时刻 + 屏幕位置。 // 双击闭合检测(左键两连击):记上次左键时刻 + 屏幕位置。

View File

@ -29,6 +29,15 @@ std::array<double, 6> imageBounds(vtkImageData* img) {
if (img) img->GetBounds(b.data()); if (img) img->GetBounds(b.data());
return b; return b;
} }
// 据三维体总不透明度(0~1)把切片不透明度模式解析为具体总不透明度(0~1)。
double resolveSliceOpacity(SliceOpacityMode mode, double volumeOpacity01) {
switch (mode) {
case SliceOpacityMode::Full: return 1.0; // 100% 不透明
case SliceOpacityMode::VolumePlus50: return std::min(1.0, volumeOpacity01 + 0.5);
case SliceOpacityMode::FollowVolume: return volumeOpacity01;
}
return 1.0;
}
} // namespace } // namespace
InteractionManager::InteractionManager(vtkRenderWindowInteractor* interactor, InteractionManager::InteractionManager(vtkRenderWindowInteractor* interactor,
@ -110,8 +119,19 @@ void InteractionManager::setVolumeImage(const std::string& volumeDsId, vtkImageD
const geopro::core::ColorScale& cs, double vmin, double vmax) { const geopro::core::ColorScale& cs, double vmin, double vmax) {
if (volumeDsId.empty()) return; if (volumeDsId.empty()) return;
auto it = volumes_.find(volumeDsId); auto it = volumes_.find(volumeDsId);
// 同体 image 变更(重建/改色阶):旧 image 即将失效 → 先关该体已显示切片(上层 syncSlices 用新 image 重现)。 if (it != volumes_.end() && it->second.image != image) {
if (it != volumes_.end() && it->second.image != image) closeSlicesOfVolume(volumeDsId); // image 变(体重建):旧 image 即将失效 → 关该体切片(上层 syncSlices 用新 image 重现已保存切片)。
closeSlicesOfVolume(volumeDsId);
} else if (it != volumes_.end()) {
// image 不变、仅色阶变(原地改色):该体下【未保存】切片跟随改色(颜色 + 按模式重解析总不透明度);
// 已保存切片用自己的色阶、不动。切片不被关闭,解决"改体色阶刷掉未保存切片"。
for (const auto& s : slices_) {
if (s->volumeDsId() != volumeDsId || !s->dsId().empty()) continue;
s->setColorScale(cs);
s->setOpacity(s->opacityMode(), resolveSliceOpacity(s->opacityMode(), cs.globalOpacity()));
}
safeRender();
}
volumes_[volumeDsId] = VolumeImg{image, cs, vmin, vmax}; volumes_[volumeDsId] = VolumeImg{image, cs, vmin, vmax};
} }
@ -168,6 +188,7 @@ void InteractionManager::showSavedSlice(const std::string& dsId, int axis, const
tool->setVolumeDsId(volumeDsId); tool->setVolumeDsId(volumeDsId);
SliceTool* tp = tool.get(); SliceTool* tp = tool.get();
tool->onInteract = [this, tp]() { selectByTool(tp); }; tool->onInteract = [this, tp]() { selectByTool(tp); };
tool->setInteractive(false); // 已保存切片定稿锁定:不可移动/旋转(用户要求);仍可拾取选中/右键
slices_.push_back(std::move(tool)); slices_.push_back(std::move(tool));
selected_ = static_cast<int>(slices_.size()) - 1; selected_ = static_cast<int>(slices_.size()) - 1;
updateSelectionVisual(); // 程序化显示(syncSlices):不发 onSliceSelectionChanged避免列表选中被刷 updateSelectionVisual(); // 程序化显示(syncSlices):不发 onSliceSelectionChanged避免列表选中被刷
@ -205,6 +226,13 @@ bool InteractionManager::selectSavedSlice(const std::string& dsId) {
return false; return false;
} }
void InteractionManager::deselectSlice() {
if (selected_ < 0) return;
selected_ = -1;
updateSelectionVisual(); // 清高亮(无选中切片)
safeRender();
}
void InteractionManager::selectByTool(const SliceTool* tool) { void InteractionManager::selectByTool(const SliceTool* tool) {
int idx = -1; int idx = -1;
for (std::size_t i = 0; i < slices_.size(); ++i) for (std::size_t i = 0; i < slices_.size(); ++i)
@ -252,6 +280,27 @@ void InteractionManager::closeAll() {
safeRender(); safeRender();
} }
PickInteractorStyle* InteractionManager::pickStyle() const { return style_; }
void InteractionManager::setMode2D(bool is2D) {
// 进入二维分析:主动取消「三维前视图」的所有选中。否则残留的选中切片会让 onWheel 持续消费滚轮
// (二维下无法缩放),且切回三维仍残留高亮。清 selected_ + 切片高亮;再经 onSliceSelectionChanged("")
// 联动清三维分析列表选中行与异常高亮app 层接线)。与 VtkSceneView::setAnalysisMode2D 离开二维时
// clearMapLineSelection 清足迹选中相对称。
if (is2D) {
if (selected_ >= 0) {
selected_ = -1;
updateSelectionVisual(); // 清切片高亮(切回三维不残留选中)
}
if (onSliceSelectionChanged) onSliceSelectionChanged(std::string{});
}
// 切片属三维内容:二维分析隐藏(不销毁→切回零重建)、三维分析显示。
for (auto& s : slices_)
if (s) s->setVisible(!is2D);
if (style_) style_->setLock2D(is2D); // 二维=禁旋转、左键平移(仅平移+缩放)
// 不在此渲染:相机/地形/底图/维度显隐及统一 Render 由 VtkSceneView::setAnalysisMode2D 收尾。
}
void InteractionManager::flipView() { void InteractionManager::flipView() {
if (!renderer_) return; if (!renderer_) return;
auto* cam = renderer_->GetActiveCamera(); auto* cam = renderer_->GetActiveCamera();
@ -289,6 +338,54 @@ std::string InteractionManager::selectedSliceVolumeDsId() const {
void InteractionManager::tagSelectedSlice(const std::string& dsId) { void InteractionManager::tagSelectedSlice(const std::string& dsId) {
if (selected_ < 0 || selected_ >= static_cast<int>(slices_.size())) return; if (selected_ < 0 || selected_ >= static_cast<int>(slices_.size())) return;
slices_[static_cast<std::size_t>(selected_)]->setDsId(dsId); slices_[static_cast<std::size_t>(selected_)]->setDsId(dsId);
slices_[static_cast<std::size_t>(selected_)]->setInteractive(false); // 保存即定稿锁定(不可改)
}
void InteractionManager::setSelectedSliceOpacity(SliceOpacityMode mode) {
if (selected_ < 0 || selected_ >= static_cast<int>(slices_.size())) return;
SliceTool* s = slices_[static_cast<std::size_t>(selected_)].get();
const VolumeImg* v = volumeOf(s->volumeDsId());
const double volOp = v ? v->cs.globalOpacity() : 1.0; // 三维体的总不透明度(参照量)
s->setOpacity(mode, resolveSliceOpacity(mode, volOp));
safeRender();
}
SliceOpacityMode InteractionManager::selectedSliceOpacityMode() const {
if (selected_ < 0 || selected_ >= static_cast<int>(slices_.size())) return SliceOpacityMode::Full;
return slices_[static_cast<std::size_t>(selected_)]->opacityMode();
}
double InteractionManager::selectedSliceOpacity() const {
if (selected_ < 0 || selected_ >= static_cast<int>(slices_.size())) return 1.0;
return slices_[static_cast<std::size_t>(selected_)]->opacity();
}
geopro::core::ColorScale InteractionManager::selectedSliceColorScaleSnapshot() const {
if (selected_ < 0 || selected_ >= static_cast<int>(slices_.size())) return {};
const SliceTool* s = slices_[static_cast<std::size_t>(selected_)].get();
geopro::core::ColorScale cs = s->colorScale(); // 颜色快照(来自三维体)
cs.setGlobalOpacity(s->opacity()); // 当前总不透明度并入 → 切片自己的色阶对象
return cs;
}
void InteractionManager::setSelectedSliceColorScale(const geopro::core::ColorScale& cs) {
if (selected_ < 0 || selected_ >= static_cast<int>(slices_.size())) return;
SliceTool* s = slices_[static_cast<std::size_t>(selected_)].get();
s->setColorScale(cs);
s->setOpacity(SliceOpacityMode::Full, cs.globalOpacity()); // 已保存切片:不透明度取自其色阶
safeRender();
}
bool InteractionManager::setSliceColorScaleByDsId(const std::string& dsId,
const geopro::core::ColorScale& cs) {
for (const auto& s : slices_) {
if (s->dsId() != dsId) continue;
s->setColorScale(cs);
s->setOpacity(SliceOpacityMode::Full, cs.globalOpacity()); // 不透明度并入其色阶 globalOpacity
safeRender();
return true;
}
return false;
} }
vtkImageData* InteractionManager::selectedSliceImage() const { vtkImageData* InteractionManager::selectedSliceImage() const {
@ -297,37 +394,29 @@ vtkImageData* InteractionManager::selectedSliceImage() const {
} }
vtkSmartPointer<vtkImageData> InteractionManager::selectedSliceColorImage() const { vtkSmartPointer<vtkImageData> InteractionManager::selectedSliceColorImage() const {
vtkImageData* scalar = selectedSliceImage(); if (selected_ < 0 || selected_ >= static_cast<int>(slices_.size())) return nullptr;
if (scalar == nullptr) return nullptr; // 与屏幕切片**同源**的着色输出(widget 自己的 ColorMap 输出, 逐像素一致, RGBA 外区透明)。
// 原先另建 LUT 上色, 与屏幕配色可能不一致(用户实测异常截图与切面差异大) → 改取 widget 着色结果。
auto colored = slices_[static_cast<std::size_t>(selected_)]->coloredResliceImage();
if (colored == nullptr) return nullptr;
// 高清导出切片重采样像素维度受体素网格分辨率限制常仅几十px→ 先上采样到目标分辨率 // 高清化:切片重采样像素维度受体素分辨率限制(常仅几十px) → 上采样到目标分辨率(双线性, 与屏幕
// (最长边 kExportLongSide保持长宽比、插值再上色得到清晰大图。 // TextureInterpolateOn 同口径), 得清晰大图。对 RGBA 直接插值(色已定, 不再过 LUT)
constexpr int kExportLongSide = 2048; constexpr int kExportLongSide = 2048;
int dims[3]; int dims[3];
scalar->GetDimensions(dims); colored->GetDimensions(dims);
const int nx = dims[0], ny = dims[1]; const int nx = dims[0], ny = dims[1];
const int longest = std::max(nx, ny); const int longest = std::max(nx, ny);
double f = (longest > 0) ? static_cast<double>(kExportLongSide) / longest : 1.0; double f = (longest > 0) ? static_cast<double>(kExportLongSide) / longest : 1.0;
if (f < 1.0) f = 1.0; // 不缩小(已够大则原样) if (f < 1.0) f = 1.0; // 不缩小
vtkNew<vtkImageResize> resize; vtkNew<vtkImageResize> resize;
resize->SetInputData(scalar); resize->SetInputData(colored);
resize->SetResizeMethodToOutputDimensions(); resize->SetResizeMethodToOutputDimensions();
resize->SetOutputDimensions(std::max(1, static_cast<int>(nx * f)), resize->SetOutputDimensions(std::max(1, static_cast<int>(nx * f)),
std::max(1, static_cast<int>(ny * f)), 1); std::max(1, static_cast<int>(ny * f)), 1);
resize->Update(); resize->Update();
// 用与切片显示同一色阶 LUT 上色:取选中切片所属体的色阶(多体并发各体色阶不同)。
const VolumeImg* v = (selected_ >= 0 && selected_ < static_cast<int>(slices_.size()))
? volumeOf(slices_[static_cast<std::size_t>(selected_)]->volumeDsId())
: nullptr;
auto lut = v ? buildLut(v->cs, v->vmin, v->vmax) : buildLut(geopro::core::ColorScale{}, 0.0, 1.0);
vtkNew<vtkImageMapToColors> map;
map->SetInputConnection(resize->GetOutputPort());
map->SetLookupTable(lut);
map->SetOutputFormatToRGB();
map->Update();
auto out = vtkSmartPointer<vtkImageData>::New(); auto out = vtkSmartPointer<vtkImageData>::New();
out->DeepCopy(map->GetOutput()); // 深拷贝脱离 filter 生命周期 out->DeepCopy(resize->GetOutput()); // 脱离 filter 生命周期
return out; return out;
} }

View File

@ -60,11 +60,17 @@ public:
std::vector<std::string> shownSavedSliceIds() const; // 当前已显示的已保存切片 dsId 列表 std::vector<std::string> shownSavedSliceIds() const; // 当前已显示的已保存切片 dsId 列表
// 选中已显示的某 dsId 切片(列表操作定位到对应渲染切片);找到返回 true。 // 选中已显示的某 dsId 切片(列表操作定位到对应渲染切片);找到返回 true。
bool selectSavedSlice(const std::string& dsId); bool selectSavedSlice(const std::string& dsId);
// 清除切片选中(列表选中切到别的对象/异常时调用,否则 VTK 切片仍高亮,用户反馈)。
void deselectSlice();
// 关闭选中切片E56。无选中则忽略。 // 关闭选中切片E56。无选中则忽略。
void closeSelected(); void closeSelected();
// 关闭并释放所有切片(切到二维 / 清场 / 体素重建前调)。 // 关闭并释放所有切片(切到二维 / 清场 / 体素重建前调)。
void closeAll(); void closeAll();
// 切二维分析(is2D=true)/三维分析(false):翻所有切片显隐(不销毁,切回零重建) + 锁/解锁交互样式
// (二维=仅平移+缩放、禁旋转)。地形/底图/相机由 VtkSceneView::setAnalysisMode2D 处理,此处不渲染。
void setMode2D(bool is2D);
// 关闭并释放某体下的所有切片(该体移除/重建时;不动其它体的切片)。 // 关闭并释放某体下的所有切片(该体移除/重建时;不动其它体的切片)。
void closeSlicesOfVolume(const std::string& volumeDsId); void closeSlicesOfVolume(const std::string& volumeDsId);
@ -86,6 +92,20 @@ public:
std::string selectedSliceVolumeDsId() const; std::string selectedSliceVolumeDsId() const;
// 给当前选中(未保存)切片打 dsId 标签:保存=把当前切片链接到新数据集(不重绘、不重复)。 // 给当前选中(未保存)切片打 dsId 标签:保存=把当前切片链接到新数据集(不重绘、不重复)。
void tagSelectedSlice(const std::string& dsId); void tagSelectedSlice(const std::string& dsId);
// ── 切片不透明度(与三维体解耦;总不透明度据所属体不透明度解析)──────────────
// 设置选中切片不透明度模式。无选中则忽略。
void setSelectedSliceOpacity(SliceOpacityMode mode);
// 选中切片当前不透明度模式(菜单勾选当前项用)。无选中返回 Full。
SliceOpacityMode selectedSliceOpacityMode() const;
// 选中切片当前解析后的总不透明度(0~1)(保存切片取具体值用)。无选中返回 1。
double selectedSliceOpacity() const;
// 选中切片色阶快照(颜色 + 当前总不透明度并入 globalOpacity):保存切片建自己色阶对象用。
geopro::core::ColorScale selectedSliceColorScaleSnapshot() const;
// 用给定色阶覆盖选中切片(已保存切片编辑自己色阶时用;不透明度取该色阶 globalOpacity
void setSelectedSliceColorScale(const geopro::core::ColorScale& cs);
// 用给定色阶覆盖指定 dsId 的已显示切片(还原/编辑已保存切片色阶时用,不依赖选中)。找到返回 true。
bool setSliceColorScaleByDsId(const std::string& dsId, const geopro::core::ColorScale& cs);
// 选中切片的重采样 2D 标量影像(导出 dat 用);无选中返回 nullptr。 // 选中切片的重采样 2D 标量影像(导出 dat 用);无选中返回 nullptr。
vtkImageData* selectedSliceImage() const; vtkImageData* selectedSliceImage() const;
// 选中切片"上色后"的 2D 影像(导出图片用):重采样标量经切片色阶 LUT → RGB 图;无选中返回 nullptr。 // 选中切片"上色后"的 2D 影像(导出图片用):重采样标量经切片色阶 LUT → RGB 图;无选中返回 nullptr。
@ -106,6 +126,10 @@ public:
void installStyle(); void installStyle();
void uninstallStyle(); void uninstallStyle();
// 暴露交互样式:供 app 层注入二维分析 B 期的足迹拾取/Z 拖动回调onPick2D/onDrag2D/onDrag2DEnd
// 定义在 .cpp此处 PickInteractorStyle 仅前置声明vtkSmartPointer→裸指针下转需完整类型
PickInteractorStyle* pickStyle() const;
private: private:
// 拾取回调实现PickInteractorStyle 注入)。 // 拾取回调实现PickInteractorStyle 注入)。
void onPicked(const Vec3& worldPoint); // 选中所在切片 + 焦点 void onPicked(const Vec3& worldPoint); // 选中所在切片 + 焦点

View File

@ -1,7 +1,9 @@
#include "interact/PickInteractorStyle.hpp" #include "interact/PickInteractorStyle.hpp"
#include <chrono> #include <chrono>
#include <cmath>
#include <vtkCallbackCommand.h>
#include <vtkCamera.h> #include <vtkCamera.h>
#include <vtkCellPicker.h> #include <vtkCellPicker.h>
#include <vtkMath.h> #include <vtkMath.h>
@ -47,6 +49,22 @@ bool PickInteractorStyle::pickWorld(Vec3& out) {
void PickInteractorStyle::OnLeftButtonDown() { void PickInteractorStyle::OnLeftButtonDown() {
auto* iren = this->GetInteractor(); auto* iren = this->GetInteractor();
// 二维分析:左键命中足迹→进入高程 Z 拖动(B 期);否则=平移(等同中键),禁旋转。抬键由 OnLeftButtonUp 收尾。
if (lock2D_) {
const int* p = iren ? iren->GetEventPosition() : nullptr;
if (p) this->FindPokedRenderer(p[0], p[1]);
if (!this->CurrentRenderer) return;
const bool additive = iren && iren->GetControlKey(); // Ctrl=多选
if (onPick2D && p && onPick2D(p[0], p[1], additive)) { // 命中足迹 → Z 拖动
dragging2D_ = true;
lastDragY_ = p[1];
this->GrabFocus(this->EventCallbackCommand);
return;
}
this->GrabFocus(this->EventCallbackCommand); // 未命中 → 平移
this->StartPan();
return;
}
Vec3 world; Vec3 world;
const bool hit = pickWorld(world); const bool hit = pickWorld(world);
@ -82,6 +100,7 @@ void PickInteractorStyle::OnLeftButtonDown() {
} }
void PickInteractorStyle::Rotate() { void PickInteractorStyle::Rotate() {
if (lock2D_) return; // 二维分析禁旋转(仅平移+缩放)
Vec3 c; Vec3 c;
if (!this->CurrentRenderer || !getRotateCenter || !getRotateCenter(c)) { if (!this->CurrentRenderer || !getRotateCenter || !getRotateCenter(c)) {
Superclass::Rotate(); // 无选中物 → 默认绕焦点旋转 Superclass::Rotate(); // 无选中物 → 默认绕焦点旋转
@ -126,12 +145,62 @@ void PickInteractorStyle::Rotate() {
rwi->Render(); rwi->Render();
} }
double PickInteractorStyle::worldPerPixelZ() const {
if (!this->CurrentRenderer) return 1.0;
auto* cam = this->CurrentRenderer->GetActiveCamera();
auto* rw = this->CurrentRenderer->GetRenderWindow();
if (!cam || !rw) return 1.0;
const int* sz = rw->GetSize();
const double h = (sz && sz[1] > 0) ? static_cast<double>(sz[1]) : 800.0;
if (cam->GetParallelProjection())
return 2.0 * cam->GetParallelScale() / h; // 平行投影:可见世界高度=2*parallelScale
// 透视:可见世界高度 = 2*d*tan(viewAngle/2)d=相机到焦点距离。
double pos[3], fp[3];
cam->GetPosition(pos);
cam->GetFocalPoint(fp);
const double dx = pos[0] - fp[0], dy = pos[1] - fp[1], dz = pos[2] - fp[2];
const double d = std::sqrt(dx * dx + dy * dy + dz * dz);
const double va = vtkMath::RadiansFromDegrees(cam->GetViewAngle());
return 2.0 * d * std::tan(va * 0.5) / h;
}
void PickInteractorStyle::OnMouseMove() {
if (dragging2D_) { // B 期:竖向拖动 → 选中足迹 Z 增量(仅改 Z)。鼠标上移(y 增)→ 抬高。
auto* rwi = this->Interactor;
if (rwi) {
const int y = rwi->GetEventPosition()[1];
const int dyPix = y - lastDragY_;
lastDragY_ = y;
if (dyPix != 0 && onDrag2D) onDrag2D(worldPerPixelZ() * dyPix);
}
return; // 不走基类(不平移/不旋转)
}
Superclass::OnMouseMove();
}
void PickInteractorStyle::OnLeftButtonUp() {
if (dragging2D_) { // 结束 Z 拖动
dragging2D_ = false;
if (this->Interactor) this->ReleaseFocus();
if (onDrag2DEnd) onDrag2DEnd();
return;
}
Superclass::OnLeftButtonUp(); // 平移/旋转/缩放等由基类按 State 收尾
}
namespace {
constexpr double kWheelStepPx = 24.0; // 滚轮一格升降 ≈ 拖动 24 像素的世界 Z 量(与拖动手感一致)
}
void PickInteractorStyle::OnMouseWheelForward() { void PickInteractorStyle::OnMouseWheelForward() {
// 二维分析有选中足迹 → 滚轮抬升其高程(消费滚轮);否则按切片推进 / 默认缩放。
if (lock2D_ && onWheel2D && onWheel2D(worldPerPixelZ() * kWheelStepPx)) return;
if (onWheelStep && onWheelStep(+1)) return; // 有选中切片 → 推进,消费滚轮 if (onWheelStep && onWheelStep(+1)) return; // 有选中切片 → 推进,消费滚轮
Superclass::OnMouseWheelForward(); // 否则默认缩放 Superclass::OnMouseWheelForward(); // 否则默认缩放
} }
void PickInteractorStyle::OnMouseWheelBackward() { void PickInteractorStyle::OnMouseWheelBackward() {
if (lock2D_ && onWheel2D && onWheel2D(-worldPerPixelZ() * kWheelStepPx)) return;
if (onWheelStep && onWheelStep(-1)) return; if (onWheelStep && onWheelStep(-1)) return;
Superclass::OnMouseWheelBackward(); Superclass::OnMouseWheelBackward();
} }

View File

@ -31,6 +31,23 @@ public:
// 在"按下开始拖动"时调用一次,把焦点设到该中心(位置同步补偿,画面不变)→ 之后绕它旋转、不跳。 // 在"按下开始拖动"时调用一次,把焦点设到该中心(位置同步补偿,画面不变)→ 之后绕它旋转、不跳。
std::function<bool(Vec3& center)> getRotateCenter; std::function<bool(Vec3& center)> getRotateCenter;
// 二维分析锁:开 → 左键拖动改为平移、禁旋转(仅平移+缩放);关 → 恢复三维拾取/旋转交互。
void setLock2D(bool on) { lock2D_ = on; }
bool isLock2D() const { return lock2D_; }
// ── 二维分析 B 期:选中足迹沿高程 Z 拖动 ──(仅 lock2D 下生效;回调由 app 层注入)
// onPick2D左键按下时在(x,y)拾取足迹(additive=Ctrl 多选),返回是否有选中→有则进入 Z 拖动、否则平移。
// onDrag2D拖动中把竖向像素换算成的世界 Z 增量(本类按相机算)交给 app 施加到选中足迹(仅改 Z)。
// onDrag2DEnd松开结束拖动(供 app 收起高程读数浮层)。
std::function<bool(int x, int y, bool additive)> onPick2D;
std::function<void(double worldDz)> onDrag2D;
std::function<void()> onDrag2DEnd;
// 滚轮升降:有选中足迹时滚轮改其高程 Z(本类按相机算 worldDz)app 施加并返回是否消费(无选中→false→默认缩放)。
std::function<bool(double worldDz)> onWheel2D;
void OnMouseMove() override;
void OnLeftButtonUp() override;
void OnLeftButtonDown() override; void OnLeftButtonDown() override;
void OnMouseWheelForward() override; void OnMouseWheelForward() override;
void OnMouseWheelBackward() override; void OnMouseWheelBackward() override;
@ -44,11 +61,20 @@ protected:
private: private:
// 在当前鼠标位置拾取世界点;命中返回 true 并填 out。 // 在当前鼠标位置拾取世界点;命中返回 true 并填 out。
bool pickWorld(Vec3& out); bool pickWorld(Vec3& out);
// 当前相机下:竖向一屏幕像素对应的世界 Z米/像素),用于把拖动像素换算成 Z 增量。
double worldPerPixelZ() const;
// 手动双击判定QVTK+Windows 下 vtkRenderWindowInteractor::GetRepeatCount() 不可靠(评审 M5 // 手动双击判定QVTK+Windows 下 vtkRenderWindowInteractor::GetRepeatCount() 不可靠(评审 M5
// 记上次左键按下时刻+屏幕位置,两次按下间隔 < kDoubleClickMs 且位置相近视为双击。 // 记上次左键按下时刻+屏幕位置,两次按下间隔 < kDoubleClickMs 且位置相近视为双击。
double lastDownTime_ = -1.0; // 单调时钟(毫秒)-1=无 double lastDownTime_ = -1.0; // 单调时钟(毫秒)-1=无
int lastDownPos_[2] = {0, 0}; int lastDownPos_[2] = {0, 0};
// 二维分析模式:左键=平移、禁旋转(仅平移+缩放)。由 InteractionManager 在切 tab 时设。
bool lock2D_ = false;
// B 期足迹 Z 拖动状态:左键命中足迹时进入,记上次鼠标 y 以算增量。
bool dragging2D_ = false;
int lastDragY_ = 0;
}; };
} // namespace geopro::render::interact } // namespace geopro::render::interact

View File

@ -6,6 +6,7 @@
#include <vtkCallbackCommand.h> #include <vtkCallbackCommand.h>
#include <vtkCommand.h> #include <vtkCommand.h>
#include <vtkImageData.h> #include <vtkImageData.h>
#include <vtkImageMapToColors.h>
#include <vtkImagePlaneWidget.h> #include <vtkImagePlaneWidget.h>
#include <vtkLookupTable.h> #include <vtkLookupTable.h>
#include <vtkProperty.h> #include <vtkProperty.h>
@ -33,9 +34,37 @@ void SliceTool::initWidget(const geopro::core::ColorScale& cs, double vmin, doub
widget_->TextureInterpolateOn(); widget_->TextureInterpolateOn();
widget_->DisplayTextOff(); widget_->DisplayTextOff();
// 色阶 LUT 套用:用户自管 LUT不让 widget 用默认灰度窗位)。 // 色阶/区间存为单一真源;总不透明度(opacity_)默认 100%。LUT 由 rebuildLut 统一建。
auto lut = buildLut(cs, vmin, vmax); cs_ = cs;
vmin_ = vmin;
vmax_ = vmax;
rebuildLut();
}
void SliceTool::rebuildLut() {
if (!widget_) return;
double vmin = vmin_, vmax = vmax_;
if (vmin >= vmax) vmax = vmin + 1.0;
// 切片渲染单一真源:颜色/单色 alpha 取自 cs_总不透明度由 opacity_ 覆盖(与三维体解耦)。
geopro::core::ColorScale c = cs_;
c.setGlobalOpacity(opacity_);
// transparentBelowRange=true体的留空哨兵(vmin-1.0)在切片上映射为透明,白化区不再填蓝。
auto lut = buildLut(c, vmin, vmax, 256, /*transparentBelowRange=*/true);
widget_->SetLookupTable(lut); widget_->SetLookupTable(lut);
// 关键:钉死 window/level 到 [vmin,vmax]。否则 vtkImagePlaneWidget 会按【输入标量范围】
// (含哨兵)自动拉伸映射,把哨兵顶到最低色格填蓝(实测 tests/spike/slice_alpha_probe 确诊)。
widget_->SetWindowLevel(vmax - vmin, 0.5 * (vmin + vmax));
}
void SliceTool::setOpacity(SliceOpacityMode mode, double resolved01) {
opacityMode_ = mode;
opacity_ = resolved01 < 0.0 ? 0.0 : (resolved01 > 1.0 ? 1.0 : resolved01);
rebuildLut();
}
void SliceTool::setColorScale(const geopro::core::ColorScale& cs) {
cs_ = cs;
rebuildLut();
} }
void SliceTool::applyMarginsAndActivate() { void SliceTool::applyMarginsAndActivate() {
@ -170,6 +199,27 @@ vtkImageData* SliceTool::reslicedOutput() const {
return widget_ ? widget_->GetResliceOutput() : nullptr; return widget_ ? widget_->GetResliceOutput() : nullptr;
} }
void SliceTool::setInteractive(bool on) {
interactive_ = on; // 记录锁定态setVisible 重显时复原
if (widget_) widget_->SetInteraction(on ? 1 : 0); // 关=锁移动/旋转/光标,纹理仍显示
}
void SliceTool::setVisible(bool on) {
if (!widget_) return;
widget_->SetEnabled(on ? 1 : 0); // 翻显隐(不销毁):几何/纹理保留、切回零重建
if (on) widget_->SetInteraction(interactive_ ? 1 : 0); // SetEnabled 可能重置交互→复原锁定态
}
vtkSmartPointer<vtkImageData> SliceTool::coloredResliceImage() const {
if (!widget_) return nullptr;
vtkImageMapToColors* cm = widget_->GetColorMap(); // widget 内部把 reslice 经 LUT 上色 → 纹理
if (cm == nullptr) return nullptr;
cm->Update();
auto out = vtkSmartPointer<vtkImageData>::New();
out->DeepCopy(cm->GetOutput()); // 即屏幕切片所贴像素(RGBA, 外区 alpha=0)
return out;
}
double SliceTool::distanceToPlane(const Vec3& p) const { double SliceTool::distanceToPlane(const Vec3& p) const {
const Vec3 c = center(); const Vec3 c = center();
const Vec3 n = normal(); const Vec3 n = normal();

View File

@ -16,6 +16,10 @@ class vtkTrivialProducer;
namespace geopro::render::interact { namespace geopro::render::interact {
// 切片不透明度模式用户右键设置100%/ 三维体+50% / 跟随三维体。
// 实际渲染用的"总不透明度"= 据所属三维体的不透明度解析(见 InteractionManager::resolveSliceOpacity
enum class SliceOpacityMode { Full, VolumePlus50, FollowVolume };
// 单个切片工具:封装 vtkImagePlaneWidget。 // 单个切片工具:封装 vtkImagePlaneWidget。
// 内部对体素 vtkImageData 做 reslice + 纹理着色spec §9.1 钉死 reslice 路线,非 cutter // 内部对体素 vtkImageData 做 reslice + 纹理着色spec §9.1 钉死 reslice 路线,非 cutter
// 轴向(UpDown/FrontBack/LeftRight)SetPlaneOrientationToX/Y/Z角度固定。 // 轴向(UpDown/FrontBack/LeftRight)SetPlaneOrientationToX/Y/Z角度固定。
@ -77,12 +81,34 @@ public:
// 当前切面重采样得到的 2D 标量影像(导出 dat 用widget 已释放则 nullptr。 // 当前切面重采样得到的 2D 标量影像(导出 dat 用widget 已释放则 nullptr。
vtkImageData* reslicedOutput() const; vtkImageData* reslicedOutput() const;
// 与屏幕切片纹理同源的着色输出(widget 自己的 ColorMap 输出, RGBA, 逐像素一致, 外区透明)。
// 异常截图/导出用它而非另建 LUT避免与屏幕配色不一致(用户实测差异大)。
vtkSmartPointer<vtkImageData> coloredResliceImage() const;
// 开/关 widget 鼠标交互(移动/旋转/光标)。关=锁定但仍显示(已保存切片定稿不可改)
// 拾取选中/右键菜单由 PickInteractorStyle 独立处理,不受此影响。
void setInteractive(bool on);
// 显/隐切片(切到二维分析时隐藏,切回再显)SetEnabled 翻显隐而非销毁,几何/位置保留、
// 切回零重建。重显时复原锁定态(SetEnabled 可能把交互重置为开)。
void setVisible(bool on);
// ── 不透明度(切片独立,与三维体解耦)──────────────────────────────────
// 设置不透明度模式 + 已解析的总不透明度(0~1)。颜色映射/单色 alpha 仍由色阶(cs_)给,
// 这里只决定整条切片的"总不透明度"= cs_.globalOpacity 在切片渲染时被它覆盖)。
void setOpacity(SliceOpacityMode mode, double resolved01);
SliceOpacityMode opacityMode() const { return opacityMode_; }
double opacity() const { return opacity_; } // 已解析的总不透明度(0~1)
// 切换本切片色阶(颜色):未保存切片跟随三维体改色、已保存切片用自己的色阶。重建 LUT。
void setColorScale(const geopro::core::ColorScale& cs);
const geopro::core::ColorScale& colorScale() const { return cs_; }
// 关闭Off() 并解除 interactor 绑定(幂等)。 // 关闭Off() 并解除 interactor 绑定(幂等)。
void close(); void close();
private: private:
SliceAxis axis_; SliceAxis axis_;
bool interactive_ = true; // 当前是否允许交互(setInteractive 记录):重显(setVisible)时复原锁定态
std::string dsId_; // 已保存切片归属标签(空=临时交互切片) std::string dsId_; // 已保存切片归属标签(空=临时交互切片)
std::string volumeDsId_; // 所属三维体 dsId多体并发用 std::string volumeDsId_; // 所属三维体 dsId多体并发用
vtkImageData* image_; // 非拥有;生命周期由调用方(VtkSceneView 的 currentVolumeImage_)保证 vtkImageData* image_; // 非拥有;生命周期由调用方(VtkSceneView 的 currentVolumeImage_)保证
@ -93,7 +119,15 @@ private:
void initWidget(const geopro::core::ColorScale& cs, double vmin, double vmax); // 共享 widget 配置 void initWidget(const geopro::core::ColorScale& cs, double vmin, double vmax); // 共享 widget 配置
void applyMarginsAndActivate(); // 按 axis 设旋转锁 + On() + 装交互观察者 void applyMarginsAndActivate(); // 按 axis 设旋转锁 + On() + 装交互观察者
void rebuildLut(); // 据 cs_ + opacity_ 重建 LUT 并钉死 window/level
std::array<double, 6> imageBounds() const; std::array<double, 6> imageBounds() const;
// 色阶(颜色) + 区间 + 总不透明度切片渲染单一真源cs_ 提供颜色/单色 alphaopacity_ 覆盖总不透明度)。
geopro::core::ColorScale cs_;
double vmin_ = 0.0;
double vmax_ = 1.0;
double opacity_ = 1.0; // 已解析的总不透明度(0~1),默认 100%
SliceOpacityMode opacityMode_ = SliceOpacityMode::Full;
}; };
} // namespace geopro::render::interact } // namespace geopro::render::interact

View File

@ -29,6 +29,8 @@ target_sources(geopro_tests PRIVATE core/test_local_frame.cpp)
target_sources(geopro_tests PRIVATE core/test_model.cpp) target_sources(geopro_tests PRIVATE core/test_model.cpp)
target_sources(geopro_tests PRIVATE core/test_color_scale.cpp) target_sources(geopro_tests PRIVATE core/test_color_scale.cpp)
target_sources(geopro_tests PRIVATE core/test_idw.cpp) target_sources(geopro_tests PRIVATE core/test_idw.cpp)
# buildVolume + maxDist=0 Surfer Blanking
target_sources(geopro_tests PRIVATE core/test_volume_builder.cpp)
target_sources(geopro_tests PRIVATE core/test_crs_transform.cpp) target_sources(geopro_tests PRIVATE core/test_crs_transform.cpp)
target_sources(geopro_tests PRIVATE core/test_model_data.cpp) target_sources(geopro_tests PRIVATE core/test_model_data.cpp)
target_sources(geopro_tests PRIVATE core/test_geo_frame.cpp) target_sources(geopro_tests PRIVATE core/test_geo_frame.cpp)

Some files were not shown because too many files have changed in this diff Show More