Compare commits

...

14 Commits

Author SHA1 Message Date
gaozheng 579bd46616 docs(vtk): 交接文档-#6 体/切片详情对话框完成,附 installer 打包工具说明 2026-06-18 20:29:55 +08:00
gaozheng 05041299fa chore(installer): Windows 安装包打包工具(Inno Setup)
- build_installer.ps1 一键打包:stage→windeployqt补Qt运行时(绕过ADS卡死)→VC运行时→ISCC
- geopro.iss:中文/英文向导,Program Files安装,自动装vc_redist,快捷方式,卸载
- lang/ChineseSimplified.isl 向导简体中文语言包
- .gitignore 排除 installer 生成物(staging/dist/redist)
2026-06-18 20:28:57 +08:00
gaozheng b97ea68109 feat(vtk): 三维体/切片数据详情对话框(#6)-右键属性表+体统计持久化
- VolumePropertiesDialog/SlicePropertiesDialog 只读属性对话框(仿异常详情)
- Api3dRepository::volumeInfo 取参数+统计;StoredVolume 持久化聚合测点数(finalizeVolume)
- main.cpp 右键「数据详情」按 ddCode 分派(dd_voxel→体/dd_slice→切片);接口与 LocalSample 零改动
- 切片不含统计(切面网格仓储不持久化,守 YAGNI);体统计仅 loaded 时显示,否则占位
- 单测 +2(volumeInfo loaded前/未知id) → 230/230 绿
2026-06-18 20:28:15 +08:00
gaozheng c27bb6ab8f docs(vtk): 交接文档全量刷新-#1~#4全完成,§3当前状态+下一步候选表,供换会话无缝接手 2026-06-18 19:30:10 +08:00
gaozheng 9d8f36ff90 docs(vtk): 交接文档-#4异常功能收口(4a→4c-3全做完),标记剩余已知限制 2026-06-18 19:26:54 +08:00
gaozheng c83f63a8f5 feat(vtk): 异常属性对话框(#4c-3, R83)-双击异常列表弹只读属性
- AnomalyPropertiesDialog:名称/类型/标记类型/归属三维体/异常体/顶点世界坐标/备注,只读
- Column3DAnalysis:留存 anomalies_,双击 itemDoubleClicked 按 id 回查发 anomalyPropertiesRequested
- main:接线打开对话框
- 截图字段:模型/端点无,不展示(保存对话框截图为mock未持久化)

编译绿(build.bat app);用户实测通过。#4 异常功能收口。
2026-06-18 19:26:02 +08:00
gaozheng f1309240a4 docs(vtk): 交接文档同步-异常4b/4c-1/4c-2已完成,余4c-3属性面板 2026-06-18 19:10:03 +08:00
gaozheng 44d31a824d feat(vtk): 异常列表选中→VTK高亮联动(#4c-2, R84 list→VTK)
- I3dSceneView::setSelectedAnomaly + VtkSceneView 实现(选中异常 actor 加粗高亮、其余恢复);
  anomalyProps_ 改 vtkActor 以设线宽/点尺寸
- main:Column3DAnalysis::anomalySelected → sceneView->setSelectedAnomaly
- FakeView 补 setSelectedAnomaly 空实现

编译全绿(build.bat all);用户实测通过。
反向(VTK点异常→回选列表)本期未做(需异常 actor 拾取)。
2026-06-18 19:06:14 +08:00
gaozheng 324d4ac605 feat(vtk): 三维分析区 3D 异常控制(#4c-1)-列表+显示过滤+单条显隐+删除
三维分析栏加 3D 异常控制(异常挂三维体,mock):
- 布局:数据集树 + 「异常」分组框(显示过滤下拉 + 异常列表)放进可拖拽竖向 Splitter,数据集树占多
- 显示过滤(R86-87):全部显示/随GS/随数据集/全部隐藏 —— 独立于体勾选控制 VTK 异常可见性
  (随GS 暂同随数据集;loadAnomalyTree 空key=全部、非空=该体)
- 单条显隐勾选 → setAnomalyVisible;右键「删除异常」→ deleteAnomaly + 刷新
- refreshAnomalies:按档位算异常集合 → clear+addAnomaly 重渲染 + 填列表 + Render
  (修过滤切换后 VTK 不重绘、与列表脱节的 bug)
- 创建异常后经 refreshAnomalies 入列表

编译链接绿(build.bat app exit 0);用户实测通过。
待做:4c-2 列表选中→VTK 高亮联动(R84,需视图 setAnomalySelected);4c-3 异常属性面板(R83);单条显隐态持久化。
2026-06-18 18:57:26 +08:00
gaozheng 6210d615f3 feat(vtk): 异常圈定+保存闭环(#4b)+多项交互修复
#4b 异常圈定→保存→渲染→持久 闭环(异常挂三维体,mock 持久化):
- AnomalyDrawTool:切片平面上圈定多边形(屏幕射线-平面求交落点);左键加点(醒目黄圆点)、
  鼠标移动虚线橡皮筋(末点→光标跟手)、双击/右键/回车闭合、Esc 取消、左上屏幕提示;高优先级观察者绘制期独占输入
- AnomalySaveDialog:名称/异常类型(mock)/备注 + 截图预览及尺寸(R50)
- 切片右键「创建异常」接通:圈定→草稿渲染→截图→保存对话框→saveAnomaly(挂三维体)→红色多边形渲染
- onVolumeChanged→reloadAnomalies:体到场重载其异常(= 显示过滤"随数据集"默认档)
- SliceExport 加 captureRenderWindowPng(异常截图,带尺寸);Anomaly 补 exceptionTypeId/remark

交互修复(用户实测确认):
- 生成三维体**按勾选集合**(checkbox)收集源,不再受行高亮/右键项影响(撤销错误的多选子类)
- buildVolume 网格**覆盖全程**:包络过大时放大间距(fitAxis)而非截断 → 跨 TM 多剖面不再丢远端剖面
- 滚轮回退为"推进当前选中切片";点击切片外**取消选中**(取消后滚轮恢复相机缩放)
- 修圈定闭合误触切片:闭合事件提前 abort,避免漏给切片 widget 触发 slice-motion

文档:plans/2026-06-18-vtk-3d-anomaly.md §6 摆放决策(3D异常控制在三维分析区/右侧总表为全集)+§7/§8 状态;
  design spec 顶部更正块(异常挂三维体/remarkSourceType=标注形态/无截图字段/独立显隐);HANDOFF 更新到 4a/4b。

编译链接绿(build.bat app exit 0);4b 闭环 + 交互修复已用户实测通过。下一步 4c:三维分析区 3D 异常控制(树+过滤R86-87+VTK选中联动R84+显隐+删除)。
2026-06-18 18:31:46 +08:00
gaozheng 4e1b8e7635 feat(vtk): 异常3D基础(#4a)-Anomaly补3D几何+buildAnomaly3D+视图异常方法+Api mock持久化(挂三维体)
异常功能地基(对象→三维体→异常;异常挂三维体非切片,见记忆 vtk-3d-persistence-structure):
- core::Anomaly 补 id/volumeDsId(=remarkSourceId)/consortiumId/worldPts(3D世界点)/planeNormal·planeOrigin,
  保留 2D localPts(剖面详情兼容)
- render::buildAnomaly3D(单异常→世界坐标 点/折线/闭合多边形 actor,不翻y/不压z;抽 buildActor 共享 2D/3D)
- I3dSceneView + VtkSceneView:addAnomaly/removeAnomaly/clearAnomalies/setAnomalyVisible(按 id 跟踪 actor,
  worldPts 已含VE 故不再 SetScale;clear 一并清异常)
- Api3dRepository:StoredAnomaly + saveAnomaly/loadAnomalyTree(按 volumeDsId 过滤+consortiumId 分组异常体)/
  deleteAnomaly/deleteAnomalyGroup 内存 mock(取代 stub onErr)

同时修复测试漂移(此前 3a 加 isVolumeDataset 纯虚 + 3c 改 loadVolume 回调签名后,geopro_tests 一直未随之更新):
- FakeView 补异常方法;FakeSceneRepo 补 isVolumeDataset(可配置 volumeIds)+修 loadVolume 签名
- test_3d_repo loadVolume 回调改 (VolumeGrid,ColorScale)
- 控制器测试 View3DWithVoxelAddsVolume 按新"类型分流"语义重写为 View3DVolumeDatasetAddsVolume(体素XOR帘面)

编译全绿(build.bat all);228/228 单元测试通过。4a 为地基(无 UI 接线、尚不可见),圈定/保存见 4b。
2026-06-18 13:04:14 +08:00
gaozheng 0e7a5c1bf7 docs(vtk): 交接更新-切片生命周期(3a/3b/3c)完成;下一主线=#4异常(接真实后端);#5分析栏接线完成(色阶仍占位) 2026-06-18 09:41:23 +08:00
gaozheng d56e35f93d feat(vtk): 切片生命周期重构(3b/3c)-已存切片重渲染+保存链接+场景列表同步
切片"未保存↔已保存"统一状态模型,修复多项交互不一致(用户实测通过):
- 保存=链接当前切片到新 dd_slice(不重绘)+三维分析栏自动展开勾选(syncSlices 按 dsId 去重)→
  不再出现"保存后旧渲染还在、再勾选出现两个"的重复切片
- 持久化存精确三点(Origin/Point1/Point2)+axis(SliceSpec),重渲染逐点精确还原 → 尺寸/朝向一致
  (修"重渲染切片明显变大")
- VTK视图「关闭」已保存切片 → onSliceClosed → 取消列表勾选(场景↔列表双向同步)
- VTK视图「保存」按"未保存/已保存"分派:未保存→createSlice+link+autocheck;已保存→saveSlice 覆盖位姿
- 已保存切片在三维分析栏勾选→在当前活动体上按 spec 还原渲染;取消→移除;靠 onVolumeChanged→syncSlices
  解决"父体异步到场"排序(SliceTool 还原构造/dsId 标签;InteractionManager showSavedSlice/hideSavedSlice/
  selectSavedSlice;Api isSliceDataset/sliceSpec)
- 菜单统一/精简:VTK视图与列表导出统一为「导出▸(图片·dat)」;移除列表(三维体/切片)的"显示/隐藏"(勾选即显隐);
  列表保存=覆盖位姿、保存为=另存新切片
- 修 Column3DAnalysis::setDatasets:按 dsId 保留勾选态 + 仅勾选集变化才发信号 →
  保存切片不再连带取消三维体勾选/重置列表

编译链接绿(build.bat app exit 0);上述场景已用户实测通过。
2026-06-18 09:40:19 +08:00
gaozheng afdd98f416 feat(vtk): 切片右键菜单(VTK视图)+切片持久化mock+导出图片(切片本身,高清)/dat (#3a)
- VTK视图切片右键菜单(设计§2.3):正视图/翻转/关闭(接现有交互)、导出图片/导出dat、保存、创建异常(占位→#4)
  - 右键经 InteractionManager 高优先级(1.0)交互器观察者实现:先于 vtkImagePlaneWidget 消费右键
    (widget 会抢右键并 abort,故 InteractorStyle::OnRightButtonDown 不触发);命中切片或回退选中切片→弹菜单
- 切片持久化 mock:Api3dRepository createSlice/saveSlice/deleteSlice 内存态 + sliceRows()
  (ddCode=dd_slice,parentId=所属体→三维分析栏树中挂父体下,符合需求 R19 对象/三维体/切片结构)
- 导出为图片=导出切片本身(需求 R52):切片重采样 2D → 上采样至最长边2048(保长宽比) → 切片色阶LUT上色 → PNG
  (非整窗截图);导出dat=切片重采样标量网格
- 三维分析栏接线(#5部分):显示/隐藏(VtkSceneView::toggleDatasetVisibility)、切片删除、色阶占位
- main.cpp refreshAnalysis 合并 volumeRows+sliceRows 注入三维分析栏

编译链接绿(build.bat app exit 0);右键菜单/保存/导出图片(切片·高清)已用户实测通过。
3b 待做:已保存切片在分析栏勾选后的重渲染(从 spec 重建)、分析栏保存/另存/导出;#4 异常接真实后端。
2026-06-18 08:09:15 +08:00
43 changed files with 3079 additions and 196 deletions

5
.gitignore vendored
View File

@ -47,3 +47,8 @@ docs/_validate/
# ---- Large redundant archive (sample data kept unpacked in folder) ----
docs/剖面网格数据的色阶数据2等文件.tar
# ---- Installer build artifacts (生成物,见 installer/README.md) ----
/installer/staging/
/installer/dist/
/installer/redist/

View File

@ -1,6 +1,10 @@
# 交接VTK 三维视图feat/vtk-3d-view
> 给下一个会话无缝接手用。更新日期 2026-06-17。分支 `feat/vtk-3d-view`,工作树:仅根目录 `grid-list-original.png`/`grid-list-small.png`/`grid-snap.yml`/`orig-dataview.png` 及 `docs/superpowers/specs/2026-06-17-web-embed-subpage-mount-design.md` 是**既有未跟踪文件,非本任务产物,勿动/勿提交**(曾被 `git add -A` 误纳、已撤回)。
> 给下一个会话无缝接手用。更新日期 2026-06-18#4 异常功能全部收口后)。分支 `feat/vtk-3d-view`,工作树:仅根目录 `grid-list-original.png`/`grid-list-small.png`/`grid-snap.yml`/`orig-dataview.png` 及 `docs/superpowers/specs/2026-06-17-web-embed-subpage-mount-design.md` 是**既有未跟踪文件,非本任务产物,勿动/勿提交**(曾被 `git add -A` 误纳、已撤回)。
>
> **进度速览2026-06-18**:补充需求 #1 生成三维体 ✅、#2 切片交互 ✅、#3 切片生命周期 ✅、#4 异常(圈定/保存/列表/异常体/过滤/显隐/删除/选中高亮/属性对话框)✅、#6 体/切片数据详情对话框 ✅ 全部完成、编译绿、用户实测通过。最新提交 `b97ea68`#6 详情对话框)。**剩下全是收尾/打磨项**(见 §4 末「下一步候选」),无进行中的半成品。下一会话应先问用户选哪个方向,或直接接其指定项。
>
> **附**Windows 安装包打包工具已落地(`installer/`,提交 `0504129``build.bat app` 后 `powershell -File installer\build_installer.ps1` 一键出 Inno Setup 安装包(自动 windeployqt 补 Qt 运行时+绕过 ADS 卡死、补 VC 运行时、中文向导)。
## 1. 背景
- 项目geopro 桌面客户端Qt6 + VTK9 + Qt-ADS dockWindows/MSVC+Ninja`build.bat`。
@ -33,12 +37,13 @@
- 默认底图=天地图;首个剖面重锚 frame 后经 `onFrameReanchored` 在数据位置加载底图。
- VTK 全屏含左侧三栏drawer 在 vtkDock 内 + 进入全屏展开)。
## 3. 当前状态
## 3. 当前状态2026-06-18 更新)
- 底图/地形/剖面配准/增量渲染:**完成且可用**。编译绿。所有改动已提交到 `feat/vtk-3d-view`
- **#1 客户端「生成三维体」流程已实现编译链接绿exit 0未提交、未 GUI 实测**Claude 无法验 VTK 渲染,待用户启动 app 实测)。详见计划 `docs/superpowers/plans/2026-06-17-vtk-3d-volume-create-flow.md`
- 已落地:`data::VolumeBuildParams`(不冻结 gridSpec源 ds 锁定不变式);`core::buildVolume` 共享管线LocalSample/Api 同源,消 TODO 漂移);`Api3dRepository` 内存体存储 + `createVolume/volumeRows/isVolumeDataset` + 多源 `loadVolume`(复用 `loadSection`,竖向=g.y 高程,与帘面构造性对齐);`loadVolume` 回调改交付 `(VolumeGrid, ColorScale)`(体色阶=源剖面色阶);`Column3DDataset`(源数据栏) 多选+右键「生成三维体」+ `VolumeParamsDialog`**生成的体归三维分析栏**(`Column3DAnalysis`,设计 §2.1,非数据集栏)main.cpp `lastAnalysisRows`+`refreshAnalysis` 合并注入(体行不被后端刷新冲掉)+ **两栏勾选聚合** `pushChecked`(剖面+体/切片并集下发,避免互相清除)`VtkSceneController` 按 `isVolumeDataset` 分流体素/帘面(取代全局 showVoxel/showCurtain
- **待实测**:在**三维数据集栏**多选反演剖面→右键「生成三维体」→参数→新体行出现在**三维分析栏**→在三维分析栏勾选→渲染体;体是否与帘面对齐;色阶是否正确;剖面与体可同时勾选共存。
- 下一阶段(切片/异常 #2#6**仅完成设计定稿**,未开始编码。
- **#1 客户端「生成三维体」流程:已实现并经本会话大量使用验证**(切片/异常都在生成的体上操作过,间接验收)。详见计划 `docs/superpowers/plans/2026-06-17-vtk-3d-volume-create-flow.md`
- 已落地:`data::VolumeBuildParams`(不冻结 gridSpec源 ds 锁定不变式);`core::buildVolume` 共享管线LocalSample/Api 同源);`Api3dRepository` 内存体存储 + `createVolume/volumeRows/isVolumeDataset` + 多源 `loadVolume`(复用 `loadSection`,竖向=g.y 高程,与帘面对齐;`fitAxis` 按 extent 增大 cell 以覆盖跨 TM 全范围);`loadVolume` 回调交付 `(VolumeGrid, ColorScale)``Column3DDataset` 多选+右键「生成三维体」(按**勾选框**选中集,非行选)+ `VolumeParamsDialog`**生成的体归三维分析栏**(`Column3DAnalysis`)main.cpp `refreshAnalysis` 合并注入 + `pushChecked` 两栏勾选聚合;`VtkSceneController` 按 `isVolumeDataset` 分流体素/帘面。
- **#2/#3 切片:完成且用户实测通过**(提交 afdd98f+d56e35f。四向创建/滚轮推进/双击正视/翻转/关闭/选中高亮VTK 右键菜单(创建异常/保存/导出▸图片·dat/正视/翻转/关闭);未保存↔已保存统一状态模型(保存按状态分派、无重复切片);精确三点几何持久化;场景↔列表勾选双向同步。导出 `SliceExport`。切片持久化=`Api3dRepository` 内存 mock。
- **#4 异常:完成且用户实测通过**(见 §4 第 4 项4a→4c-3最新 c83f63a。圈定→保存→渲染→列表→异常体分组→过滤→显隐→删除→选中高亮→双击属性全链 mock。
- **剩余 = 收尾/打磨项**§4 末「下一步候选」表):体/切片详情面板、真实色阶编辑、三级树根层、坐标轴弹框、真实后端对接(阻塞)、收口 PR。**无进行中的半成品**。
## 4. 下一步计划(三维体 / 切片 / 异常)
**权威设计文档**`docs/superpowers/specs/2026-06-17-vtk-3d-volume-slice-anomaly-design.md`(数据模型 + 交互流 + 后端vs mock + 代码现状 + 实现拆解 + 持久化策略)。已与用户拍板的关键决策:
@ -50,14 +55,37 @@
**实现拆解(设计文档 §6按依赖排序**
1. ~~三维体 mock 渲染~~ **✅ 已实现(编译绿,待 GUI 实测)**——见 §3 与计划 `2026-06-17-vtk-3d-volume-create-flow.md`。`Api3dRepository::loadVolume` 已接通(多源复用 loadSection → IDW → VolumeGrid + 色阶交付);`VolumeBuildParams` 必存参数、values 惰性重算+缓存(**不冻结 gridSpec**,改用源 ds 锁定不变式,留校验 TODO
2. 切片交互接通三维体(现有 `SliceTool`/`InteractionManager` 已能切;补滚轮推进、双击正视)。
3. 切片保存/另存/导出/删除(保存删除 mock 内存;导出图片/dat 客户端做)+ VTK 视图切片右键菜单接线。
4. 异常:切片右键创建异常(圈定+保存对话框含截图)→ **接真实端点**
5. 分析栏右键菜单接线:色阶/显示隐藏(客户端)+ 切片增删(接 #3)。`Column3DAnalysis` 信号已定义main.cpp 目前**只接了 `sliceRequested`+`detailRequested`**,其余未连。
6. 三维体/切片/异常详情面板(源数据/插值参数/色阶/测量点数体积/异常列表)。
2. ~~切片交互接通三维体~~ **✅ 已有**`SliceTool`/`InteractionManager`:四向创建/滚轮推进/双击正视/翻转/关闭/选中高亮全在)。
3. **✅ 完成(3a/3b/3c已提交 afdd98f+d56e35f用户实测通过)**——切片完整生命周期(未保存↔已保存统一状态模型)
- VTK 视图切片右键菜单(`PickInteractorStyle`右键→`InteractionManager`高优先级(1.0)交互器观察者抢右键→`onSliceContextMenuRequested`→main 弹 QMenu):创建异常(占位#4)/保存/导出▸(图片·dat)/正视图/翻转/关闭。
- **保存按状态分派**:未保存→`createSlice`+`tagSelectedSlice`链接当前切片+列表自动勾选(`setItemChecked`);已保存→`saveSlice`覆盖位姿。**无重复切片**。
- **精确几何持久化**`SliceSpec`存 axis+Origin/Point1/Point2 三点;`SliceTool`还原构造逐点重建→重渲染尺寸/朝向一致。
- **已保存切片重渲染**:分析栏勾选→`syncSlices`在当前活动体上还原(`showSavedSlice`),取消→移除;靠`onVolumeChanged→syncSlices`解决父体异步到场。dd_slice 不进控制器(避免 loadSection 失败)main 编排走 InteractionManager。
- **场景↔列表同步**VTK「关闭」已保存切片→`onSliceClosed`→列表取消勾选。`Column3DAnalysis::setDatasets`按 dsId 保留勾选+仅勾选集变化才发信号(修"保存切片连带取消体勾选/列表重置")。
- 导出:`SliceExport.{hpp,cpp}`(图片=切片上采样2048上色 PNGdat=重采样标量网格)。切片持久化=`Api3dRepository` createSlice/saveSlice/deleteSlice 内存 mock + sliceRows/isSliceDataset/sliceSpec。
4. 异常(**进行中**,全量含异常体/列表/过滤,计划见 `plans/2026-06-18-vtk-3d-anomaly.md`)。**异常挂三维体**(非切片非源ds见记忆 vtk-3d-persistence-structure)mock 持久化(三维体/切片端点未就绪)。
- **4a ✅ 已提交(4e1b8e7)**`core::Anomaly` 补 3D(id/volumeDsId/consortiumId/worldPts/plane)`buildAnomaly3D``I3dSceneView`+`VtkSceneView` addAnomaly/removeAnomaly/clearAnomalies/setAnomalyVisible(按id跟踪actor)`Api3dRepository` 异常 mock(saveAnomaly/loadAnomalyTree按volumeDsId+consortiumId分组/delete)。**附带修复测试漂移→228/228 绿**。地基、尚不可见。
- **4b ✅ 已提交**:圈定工具(切片平面画多边形,黄点+橡皮筋虚线,双击/右键/Enter 闭合,Esc 取消,屏幕提示)+保存对话框(名称/类型 mock/备注/截图预览)+切片右键「创建异常」接通 → 画→存→显示→删闭环。修复:闭合手势误触切片(abort 先于 finish/teardown)。
- **4c-1 ✅ 已提交**:三维分析栏「异常」组(QSplitter:数据集树 + 异常 QGroupBox);过滤下拉(全部显示/随GS/随数据集/全部隐藏,默认随数据集);删除按 consortiumId 分组;`refreshAnomalies` 各路径补 `renderWindow->Render()`(修过滤勾选与 VTK 显隐脱节)。
- **4c-2 ✅ 已提交(44d31a8)**列表选中异常→VTK 高亮联动(R84,list→VTK)`setSelectedAnomaly`(选中 actor 加粗线宽/点尺寸,其余恢复);anomalyProps_ 改 vtkActor。**反向(VTK点异常→回选列表)未做**(需异常 actor 拾取)。
- **4c-3 ✅ 已提交(c83f63a)**:异常属性对话框(R83,双击异常列表项弹只读:名称/类型/标记类型/归属三维体/异常体/顶点世界坐标/备注)`AnomalyPropertiesDialog`。**截图字段:模型/端点均无,不展示**(保存对话框截图为 mock 未持久化)。
- **#4 异常功能收口** ✅(4a→4c-3 全做完,编译绿+用户实测通过)。**剩余已知限制**:① 反向(VTK 点异常→回选列表)未做(需异常 actor 拾取);② 单条显隐状态跨 refresh 不持久;③ 全链 mock(三维体/切片端点未就绪),端点就绪后切真实。
5. 分析栏右键接线:**已完成**(切片 保存/保存为/导出▸/删除 全接;体 切片▸/详情)`colorScaleRequested` 仍占位("色阶开发中")。已移除"显示/隐藏"(勾选即显隐)。
6. 三维体/切片/异常详情:**✅ 全部完成**——异常详情对话框(4c-3);体/切片详情对话框(#6,提交 b97ea68)。形态统一为只读属性对话框,非停靠面板。
- **其它小欠项**:三维分析栏完整三级树"对象→三维体→切片"里"对象"根层未套(体目前是顶层);真实色阶编辑。
**其它小项**坐标轴「O点位置」「字体」弹框仍是 stubmain.cpp:382 TODO P4
### 下一步候选2026-06-18主体功能已完结以下均为收尾/打磨;下一会话先与用户确认方向)
| 候选 | 性质 | 阻塞 | 要点 |
|---|---|---|---|
| ~~**#6 三维体/切片 数据详情**~~ ✅ 已完成 | 功能补全 | 否 | **已做(提交 b97ea68)**:只读属性对话框(非面板,仿异常详情)`VolumePropertiesDialog`/`SlicePropertiesDialog`,右键「数据详情」按 ddCode 分派。体=参数+统计(值域/网格/测点数/范围,仅 loaded 时显);切片=位姿/参数(不含统计,切面网格仓储不持久化)。`Api3dRepository::volumeInfo` getter + `StoredVolume.pointCount` 持久化;接口/LocalSample 零改。设计见 `specs/2026-06-18-vtk-3d-volume-slice-detail-dialog-design.md`。**已知限制**:切片采样分辨率/值域需渲染层回写仓储才有,当前不展示。 |
| **真实色阶编辑** | 功能 | 否 | `colorScaleRequested` 现占位("色阶开发中")。做成色阶编辑器,影响 体/切片/帘面 渲染观感。 |
| **收口提 PR 合 main** | 流程 | 否 | 分支已积大量提交。`git diff main...HEAD` 起草摘要+测试计划,`-u` 推送。注意勿纳未跟踪文件。 |
| **三级树 对象→三维体→切片** | 结构打磨 | 否 | `Column3DAnalysis` 体目前是顶层,缺"对象"根层(R 结构)。 |
| **坐标轴 O点/字体弹框** | 打磨 | 否 | main.cpp 内 stub(TODO P4)落实。 |
| **真实后端对接** | 切真实 | **是** | 三维体/切片端点未就绪,现全 mock(`Api3dRepository` 内存)。端点就绪后只换 `I3dSceneRepository` 实现,接口不动。异常端点已挖到(§4 头),但其挂载目标(三维体)仍 mock故异常也整链 mock。 |
## 5. 相关文档
- **`docs/superpowers/specs/2026-06-17-vtk-3d-volume-slice-anomaly-design.md`** ← 下一阶段主依据。
- `docs/questions/2026-06-16-反演剖面竖向字段(y-z-elevation)语义待确认.md` ← 已解y=高程z=+y 与原版一致;跨数据集 y 不一致是数据层问题(原版同样存在),非客户端 bug。

View File

@ -0,0 +1,100 @@
# 实现计划VTK 三维异常(#4全量含异常体/列表/过滤)
- 日期2026-06-18
- 分支:`feat/vtk-3d-view`
- 上位设计:`docs/superpowers/specs/2026-06-17-vtk-3d-volume-slice-anomaly-design.md`;补充需求 R49-56(切片右键创建异常) + R58-65(三维体详情·异常) + R69-88(异常/异常体列表/属性/过滤)。
- 关键决策(用户 2026-06-18 定):
- **异常挂「三维体」**`remarkSourceId`=三维体 ds id不挂切片(切片是临时圈定载体)、不挂源 ds。见记忆 `vtk-3d-persistence-structure`
- **全做**:圈定 + 保存(含截图) + 3D 渲染 + 异常/异常体列表(对象→异常体→异常) + 选中联动 + 显示过滤 + 删除/删除分组。
- **不参考 Geopro1.0**:按需求 + 行业最佳实践(标准多边形圈定)。
- **持久化 mock**:三维体/切片/异常端点后端均未就绪 → 全 mock(内存)走 `I3dSceneRepository`,整链端点就绪再切真实。截图先存本地(R88 截图属性待后端新增)。
## 0. 现状(可复用 vs 新建,实证见探查)
| 资产 | 现状 |
|---|---|
| `core::Anomaly`(name/typeName/markType点线面/localPts Vec2/线样式) | ✅ 有,但**2D**(localPts=x距离·y深度),需补 3D 几何 |
| `I3dSceneRepository` 异常接口(loadAnomalyTree/saveAnomaly/deleteAnomaly/deleteAnomalyGroup + AnomalyTree/AnomalyBody) | ✅ 接口齐,**实现是 stub**(Api 回 onErr/空树) |
| `ObjectExceptionPanel`(对象→异常体→异常 树) | ✅ 只读树完整,**无勾选/选中/删除/过滤交互** |
| `render::buildAnomalies`(点/线/面 vtkActor) | ✅ 有,但坐标=2D(x,depth,0),需 3D(世界点) |
| 异常 DTO(parseExceptions/groupByConsortium) + 真实读取(loadExceptionsByTmAsync) | ✅ 真实读取链路通(后端就绪后用) |
| `I3dSceneView` 异常方法 / VtkSceneController 异常逻辑 / 3D 圈定工具 / 选中联动(3D) / 过滤 | ❌ 全无,需新建 |
## 1. 数据模型core::Anomaly 补 3D 几何
`src/core/model/Anomaly.hpp` 增(保留现有 2D 字段,新增 3D
- `struct Vec3 { double x,y,z; };`
- `std::vector<Vec3> worldPts;`:异常多边形/折线/点的**世界 3D 坐标**(落在所在切片平面上)。
- `Vec3 planeNormal{0,0,1}, planeOrigin{};`:所在切片平面(法向+一点)——供重定位/正视,及与切片解耦后仍能定位。
- 持久化补充字段(不入 core入仓储存储或 Anomaly 扩展)`id`、`volumeDsId`(=remarkSourceId)、`exceptionTypeId`/`typeName`、`remark`、`screenshotPath`、`consortiumId`(异常体分组,空=未分组)。
> core::Anomaly 保持渲染/几何纯数据id/归属/截图等持久化元数据放仓储的 StoredAnomaly 包装(同 StoredVolume/StoredSlice)。
## 2. 渲染3D 异常 actor + I3dSceneView 接口
- `render::buildAnomalies3D(const std::vector<core::Anomaly>&)`(新增或改造 AnomalyActor`worldPts` 直接建点/折线/闭合多边形 actor世界坐标不再 ×1 深度);样式复用(lineColor/width/dashed);选中高亮(加粗/变色)。
- `I3dSceneView` 新增:
- `addAnomaly(const core::Anomaly&)` / `removeAnomaly(id)` / `clearAnomalies()`
- `setAnomalyVisible(id, bool)` / `setAnomalySelected(id, bool)`(选中联动)
- `pickedAnomalyId()` 或经回调 `onAnomalyPicked(id)`VTK 点选异常→列表)
- `VtkSceneView``map<id, actor>`,实现上述。
## 3. 圈定工具(切片平面上画多边形)
`src/render/interact/AnomalyDrawTool.{hpp,cpp}`(新):
- 输入:当前选中切片的平面(origin/normal) + interactor + renderer。
- 交互(行业标准):左键逐点加顶点(投影到切片平面);右键/双击/回车闭合Esc 取消;实时预览折线。点类型=单击一点;面=多边形闭合;(线/文字按 markType)。
- 产物:`worldPts`(平面上的世界点) + planeNormal/origin → 回调上层。
- 入口VTK 视图切片右键「创建异常」(已占位) → 启动本工具(以光标拾取点为起点R49)。
## 4. 保存对话框 + 截图
`src/app/AnomalySaveDialog.{hpp,cpp}`(新,参考 VolumeParamsDialog 风格):
- 字段:异常名称、异常类型(下拉,**mock 几个类型**;真实类型端点 `exceptionType/*` 只读、后续可接)、备注。
- 截图(R50):圈定结束截当前 VTK 视图(或异常包络区) → 存本地文件 → 路径+大小入异常记录(`SliceExport` 同款 PNG 写)。
- accept → 组装 `core::Anomaly`(markType/worldPts/plane/样式) + 元数据(name/typeId/remark/screenshot) → `saveAnomaly`
## 5. 持久化 mockApi3dRepository挂三维体
- `StoredAnomaly { core::Anomaly geom; id; volumeDsId; exceptionTypeId/typeName; remark; screenshotPath; consortiumId; }``map<id, StoredAnomaly> anomalies_`。
- `saveAnomaly(a, screenshotPath, onOk(id), onErr)`:生成 `anomaly-N`,存,回 id。接口已含 screenshotPath 参数)
- `loadAnomalyTree(objectId, onOk(tree), onErr)`:按 objectId 下所有三维体聚合异常 → 组 `AnomalyTree`(bodies=异常体分组 + loose=未分组)。mock 阶段:以 volumeDsId 关联,未分组进 loose。
- `deleteAnomaly(id)` / `deleteAnomalyGroup(bodyId)`:删/删组。
- 异常体(consortium)分组mock 内存(`map<bodyId, {name,typeName,memberIds}>`);真实端点 `exceptionConsortium/*` 后续接。
- 接口签名不变;后端整链就绪仅换实现。
## 6. 异常展示与控制的摆放(用户 2026-06-18 定,需求实证 R28/R36/R58-88
需求结构R58/R67/R69/R90 均为 C1 顶级分节 = **数据详情栏**的各类详情内容R28/R36「数据详情 → 在数据详情栏显示」。R84 选中联动、R86-87 VTK 显示过滤 = **3D 场景操作**。结论(职责拆分,互补不重复)
- **三维分析区 = 3D 异常的"场景控制"**(本期 4c 重点3D 异常现为 mock
- 树:对象 → 三维体 → 异常异常挂三维体R61非切片非源 ds见记忆 `vtk-3d-persistence-structure`)。
- **显示过滤 4 档(R86-87)**:全部显示 / 随GS / 随数据集 / 全部隐藏 —— **独立于体勾选**控制 VTK 异常可见性(解决"异常被体勾选绑死")。
- 每条异常**单独显隐**(复用 AnomalyListPanel 的"眼睛")。
- **VTK 选中双向联动(R84)**:列表选中 ↔ VTK 高亮。
- 删除异常 / 删除分组(R79-81, deleteAnomaly/deleteAnomalyGroup)。
- **右侧「对象异常」面板(现有 `ObjectExceptionPanel`) = 异常全集 master**:对象下所有异常总表。**本期保持不动**(仍连后端 2D 异常);后端三维体/切片/异常整链就绪后3D 异常并入此处成全集。
- **三维体数据详情(R58-65)**:源数据/切片/**异常列表(R61,只读摘要)**/插值参数/色阶/测量——经右键「数据详情」打开。
> 不在右侧总表里塞 3D 场景控制(过滤/联动属 3D 操作,归三维分析区);不在三维分析区重复全集总表。
## 7. main.cpp 编排
- 切片右键「创建异常」→ 启动 `AnomalyDrawTool`(当前选中切片平面) → 圈定 → `AnomalySaveDialog``saveAnomaly` → 渲染(addAnomaly) + 刷新三维分析区异常列表。**[4b 已实现]**
- 体到场/移除(onVolumeChanged) → `loadAnomalyTree(volumeId)` → 渲染该体已存异常(reloadAnomalies)。**[4b 已实现,= "随数据集" 档默认]**
- 三维分析区异常列表:选中/显隐/过滤/删除 → 驱动 view 的 setAnomalyVisible/Selected + 仓储删VTK 点选异常 → 列表选中(联动)。**[4c]**
## 8. 阶段(每阶段编译绿 + 用户实测)
- **4a 基础 ✅ 已提交(4e1b8e7)**§1 模型 + §2 渲染/接口 + §5 mock 持久化 + 测试修复(228/228 绿)。
- **4b 圈定+保存 ✅ 已实现(未提交,用户已测通)**§3 `AnomalyDrawTool`(切片平面圈定,射线-平面求交,左键加点/双击·右键·回车闭合/Esc 取消/屏幕提示) + §4 `AnomalySaveDialog`(名称/类型 mock/备注/截图预览) + 切片右键「创建异常」接通 + onVolumeChanged→reloadAnomalies(随体重载渲染)。闭环:画→存→显示→跨重勾持久。
- 同批交互修复(待提交):生成体**按勾选集合**(非行高亮/右键项)、buildVolume 网格**覆盖全程**(跨 TM 多剖面不截断)、滚轮推进选中切片(点切片外取消选中→恢复缩放)。
- **4c 三维分析区 3D 异常控制(下一步)**§6 —— 三维分析区异常树(对象→三维体→异常) + **显示过滤 4 档(R86-87)** + **VTK 选中双向联动(R84)** + 每条显隐 + 删除/删组 + 异常属性(R83)。异常体分组 mock。右侧总表不动。
- **后续**:三维体/切片数据详情(R58-65/R67);真实端点整链就绪后切真实(异常并入右侧全集)。
## 9. 风险/待确认
- **core::Anomaly 改动影响 2D 路径**:补字段不动现有 2D 字段2D 渲染(ContourPlotItem/buildAnomalies)不受影响3D 走新 worldPts 路径。
- **异常体(consortium)创建入口**:需求 R71 有异常体,但"如何把异常归入异常体"的 UI 入口需求未细化 → 4c 落地时按最佳实践补(多选异常→成组),或先只做 loose + 展示分组。
- **截图属性后端缺**(R88 待新增):先本地存,后端加字段再上传。
- **真实类型/异常体端点只读可接**mock 阶段先 mock降耦合可选接真实只读。

View File

@ -5,6 +5,13 @@
- 依据①《Geopro3.0 需求表.xlsx》「补充需求」页行号见引用② 与产品方就 6 个设计问题的确认;③ 现有代码。
- 原则:缺后端端点的**先本地 mock**(保证功能可见可用),端点就绪后切真实;能纯客户端做的先做。
> **⚠ 更正2026-06-18本文档以下异常部分已被修订以此为准**——实现计划见 `plans/2026-06-18-vtk-3d-anomaly.md`,结构铁律见记忆 `vtk-3d-persistence-structure`
> 1. **异常挂「三维体」**`remarkSourceId` = 三维体 ds id**不挂切片**§1.3 的 `parentSliceId` 作废)——切片是临时圈定载体,业务语义上异常属于三维体(需求 R61
> 2. **`remarkSourceType` = 标注形态**1点/2线/3面/4文字**不是**"来源实体类型"§3 原表述更正,实证 `commercial-admin/contourPage.vue:386`)。接口不限定挂载实体类型,`remarkSourceId` 放谁 id 挂谁。
> 3. 异常请求体**无截图字段**;补充需求 **R88「增加截图属性」**证实截图是待新增属性 → 现 mock 本地存。
> 4. **摆放**3D 异常的"场景控制"(树+显示过滤 R86-87+VTK 选中联动 R84+显隐+删除)放**三维分析区**;右侧「对象异常」面板 = 异常全集 master暂连后端 2D整链就绪后并入 3D
> 5. 异常**独立显隐**靠 R86-87 过滤(全部显示/随GS/随数据集/全部隐藏),**不被三维体勾选绑死**。
---
## 1. 核心数据模型

View File

@ -0,0 +1,116 @@
# 设计:三维体/切片 数据详情(只读属性对话框)
> 日期 2026-06-18。分支 `feat/vtk-3d-view`。收尾/打磨项 #6(见 `docs/superpowers/HANDOFF-vtk-3d.md` §4 末「下一步候选」)。
> 异常详情已用对话框做掉(`AnomalyPropertiesDialog`),本设计为**三维体 / 切片**补同类只读详情。
## 1. 目标与范围
三维分析栏右键「数据详情」时,弹出只读属性对话框展示该三维体 / 切片的元数据与统计。
- **形态**:只读 `QDialog`(仿 `AnomalyPropertiesDialog`),非停靠面板页签。
- 取舍理由:现成 `DatasetDetailController/Panel` 绑定 2D 的 `IAsyncDatasetRepository` + chartRegistry而体/切片数据在 `Api3dRepository`(独立 3D 仓储),硬接需跨仓储桥接 + 新策略/视图,代价大、动共享设施风险高。对话框与刚落地的异常详情 UX 一致、零侵入 2D 管线。
- **内容范围**:参数/位姿随时可取;三维体统计(值域/测点数/范围体被生成loadVolume 缓存)后才显示,未生成显「—(生成/渲染后可见)」。
## 2. 架构与新增文件
仿 `src/app/AnomalyPropertiesDialog.{hpp,cpp}``QFormLayout` + `QLabel` 只读表:
| 文件 | 职责 |
|------|------|
| `src/app/VolumePropertiesDialog.{hpp,cpp}` | 三维体属性(参数 + 统计) |
| `src/app/SlicePropertiesDialog.{hpp,cpp}` | 切片属性(位姿 + 参数) |
两个对话框各自独立、构造即填充、`exec()` 模态,无网络、无加载态。
## 3. 数据获取
只改具体类 `src/data/api/Api3dRepository.{hpp,cpp}`**接口 `I3dSceneRepository``LocalSample3dRepository` 不动**`main.cpp` 持有具体 `scene3dRepo`,见 main.cpp:266全程直接用
### 3.1 三维体 getter新增
```cpp
// Api3dRepository.hpp 内嵌结构 + 方法
struct VolumeInfo {
VolumeBuildParams params;
std::string name;
bool loaded = false; // cachedGrid 是否已就绪(= loadVolume 跑过)
// 以下仅 loaded 时有效:
double vmin = 0.0, vmax = 0.0; // 来自 cachedGrid
int nx = 0, ny = 0, nz = 0; // 网格维度
double dx = 0, dy = 0, dz = 0; // 单元间距(来自 cachedGrid.spacing
std::size_t pointCount = 0; // 聚合后参与插值的散点数
};
bool volumeInfo(const std::string& dsId, VolumeInfo& out) const; // 非体返回 false
```
- `loaded``StoredVolume::cachedGrid.has_value()`;统计字段从 `cachedGrid`vmin/vmax、`vol.nx()/ny()/nz()`、`spacing`)填。
- **测点数持久化**`StoredVolume` 增 `std::optional<std::size_t> pointCount`,在 `finalizeVolume`(散点聚合完成处)写入 `pts.v.size()`。`volumeInfo` 透出。
### 3.2 切片数据
复用已有 `bool sliceSpec(const std::string& dsId, SliceSpec& out) const`main.cpp 已在用)取位姿;名称用 `detailRequested` 信号已携带的 `name`,不新增 getter。
## 4. 触发与接线(`main.cpp`
`detailRequested` 仅来自三维分析栏(`Column3DAnalysis`,项非体即切片;右键菜单「数据详情」已接,无需改 Column3DAnalysis现连接 `detailCtrl.openDataset`(对 3D dsId 会降级失败)。改为按 ddCode 分派:
```cpp
QObject::connect(ca, &Column3DAnalysis::detailRequested, &window,
[&window, scene3dRepo](const QString& dsId, const QString& ddCode, const QString& name) {
if (ddCode == QStringLiteral("dd_slice")) {
I3dSceneRepository::SliceSpec sp;
if (scene3dRepo->sliceSpec(dsId.toStdString(), sp)) {
SlicePropertiesDialog dlg(name, sp, &window); dlg.exec();
}
} else { // dd_voxel
Api3dRepository::VolumeInfo info;
if (scene3dRepo->volumeInfo(dsId.toStdString(), info)) {
VolumePropertiesDialog dlg(name, info, &window); dlg.exec();
}
}
});
```
`src/app/CMakeLists.txt` 加两个新 `.cpp`
## 5. 内容字段
### 三维体(`VolumePropertiesDialog`
- 名称
- 源数据集(`sourceDatasetIds`,逗号连接)
- 插值模型IDW / Kriging+ 幂指数IDW 时显 `power`
- 网格间距(`XY=cellXY m Z=cellZ m`
- 超距(`maxDist m`
- 色阶来源(`colorScaleId`,空显「首个源数据集」)
- **统计**loaded 才有,否则全显「—(生成/渲染后可见)」):
- 值域(`vmin ~ vmax`
- 网格(`nx × ny × nz`
- 测点数(`pointCount`
- 范围(`nx·dx × ny·dy × nz·dz` 米)
### 切片(`SlicePropertiesDialog`
- 名称
- 所属三维体(`volumeDsId`
- 轴向0 上下 / 1 前后 / 2 左右 / 3 任意)
- 平面三点 Origin / Point1 / Point2`(x, y, z)`2 位小数)
- 色阶来源(`colorScaleId`,空显「首个源数据集」)
> 切片**不含统计项**:采样分辨率/值域来自渲染时的切面网格,仓储层不持久化(`StoredSlice` 仅存 `spec`+`name`)。回写渲染产物属额外 plumbing守 YAGNI 不做。位姿/参数已完整。
## 6. 错误处理
- `volumeInfo` / `sliceSpec` 取不到(非体/非切片)→ 返回 false不弹空对话框理论不发生触发来自该行
- 统计未就绪 → 占位「—(生成/渲染后可见)」,不报错。
## 7. 测试
- 新增 gtest`tests/` 内 Api3dRepository 测套,若无则新建)覆盖 `volumeInfo`
- `createVolume` 后、`loadVolume` 前:`volumeInfo` 返回 true、`params`/`name` 正确、`loaded=false`、`pointCount=0`。
- `loadVolume` 成功后:`loaded=true`、`vmin<vmax`、`nx/ny/nz>0`、`pointCount>0`。
- 非体 dsId返回 false。
- 对话框为纯只读 UI无逻辑分支不做单测靠 GUI 实测Claude 无法 GUI 验证,交用户)。
## 8. 影响面 / 不变量
- 接口 `I3dSceneRepository``LocalSample3dRepository` 零改动 → 真实后端就绪后切换不受影响。
- `finalizeVolume` 仅多写一个 `pointCount`,不改插值/渲染行为。
- 不与 VTK 三维视图交互(详情只读查阅,职责清晰)。

66
installer/README.md Normal file
View File

@ -0,0 +1,66 @@
# Geopro Windows 安装包
把已构建的 `geopro_desktop` 打包成单个 Inno Setup 安装程序(带安装向导、开始菜单/桌面快捷方式、卸载程序,并自动安装 VC++ 运行时)。
## 一键打包
```powershell
# 1) 先构建 Release若尚未构建
build.bat app
# 2) 打包(默认版本 3.0.0,文件名带当天日期)
powershell -ExecutionPolicy Bypass -File installer\build_installer.ps1
```
产物:`installer\dist\Geopro_Setup_<版本>-<yyyyMMdd>.exe`
### 常用参数
| 参数 | 说明 |
|------|------|
| `-Version 3.1.0` | 指定版本号(最终文件名 `Geopro_Setup_3.1.0-<日期>.exe` |
| `-Rebuild` | 打包前先 `build.bat rebuild` 干净重编 |
| `-QtPrefix D:/Qt/6.11.1/msvc2022_64` | 指定 Qt 路径(默认从 `CMakePresets.json` 解析) |
| `-SkipDeploy` | 跳过 windeployqt不推荐仅 staging 已补齐时用) |
## 打包流程build_installer.ps1 做了什么)
1. **stage** — 把 `build/release/src/app` 复制到 `installer/staging`,剔除构建产物
`CMakeFiles/`、`*_autogen/`、`*.pdb`、`*.log`、`*.cmake`)。
2. **windeployqt** — 在 staging 上补齐 Qt 运行时缺件:`D3Dcompiler_47.dll`、`opengl32sw.dll`
(软件 OpenGL 回退、WebEngine QML、各类插件。
> 自动绕过已知坑:`qt6advanceddocking.dll` 名字带 `qt6` 前缀会被 windeployqt 误判为 Qt 模块、
> 去 `Qt\bin` 找它而报错中止——脚本临时把它拷进 `Qt\bin`,跑完即删。
3. **redist** — 确保 `vc_redist.x64.exe` 就位(缺则从本机 Visual Studio 复制)。
4. **ISCC** — 调用 Inno Setup 编译 `geopro.iss`LZMA2/max 固实压缩,输出到 `dist/`
## 安装包行为
- 默认装入 `C:\Program Files\Geopro`(需管理员权限)。
- 仅在系统**未安装** VC++ 2015-2022 x64 运行时时,静默安装 `vc_redist.x64.exe`
- 创建开始菜单项;桌面快捷方式为可选项(默认不勾)。
- 程序日志/配置写入 `%LOCALAPPDATA%\Geomative\Geopro3`,与安装目录解耦。
- 向导支持简体中文 / 英文。
## 前置依赖(打包机)
| 工具 | 获取方式 |
|------|----------|
| Inno Setup 6 | `winget install --id JRSoftware.InnoSetup -e` |
| Qt 6.11.1 (msvc2022_64) | 含 `windeployqt.exe`,已是构建依赖 |
| Visual Studio 2022/2026 (C++) | 提供 `vc_redist.x64.exe`,已是构建依赖 |
## 仓库内/生成物
入库(打包工具本体):
- `geopro.iss` — Inno Setup 脚本
- `build_installer.ps1` — 一键打包工具
- `lang/ChineseSimplified.isl` — 向导简体中文语言包
- `README.md`
不入库(每次生成,见 `.gitignore`
- `staging/` — 临时部署副本
- `redist/` — 复制来的 `vc_redist.x64.exe`
- `dist/` — 最终安装包

View File

@ -0,0 +1,157 @@
<#
.SYNOPSIS
Geopro Windows 安装包一键打包工具
.DESCRIPTION
把已构建的 geopro_desktop 部署目录打包成单个 Inno Setup 安装程序
1) stage build/release/src/app 复制到 installer\staging剔除构建产物
CMakeFiles / *_autogen / *.pdb / *.log / *.cmake
2) deploy staging 上跑 windeployqt 补齐 Qt 运行时缺件
D3Dcompiler_47 / opengl32sw / WebEngine QML / 各插件
自动绕过 ADS 卡死问题qt6advanceddocking.dll 被误判为 Qt 模块
3) redist 确保 VC++ 运行时安装器 vc_redist.x64.exe 就位缺则从 VS 复制
4) compile 调用 ISCC 编译 geopro.iss输出到 installer\dist\
工具链Qt / ISCC / VS vc_redist全部自动定位便于换机复用
.PARAMETER Version
产品版本号默认 3.0.0最终文件名为 Geopro_Setup_<Version>-<yyyyMMdd>.exe
.PARAMETER Rebuild
先执行 build.bat rebuild 做一次干净重编再打包默认使用现有构建产物
.PARAMETER SkipDeploy
跳过 windeployqt 步骤仅当确认 staging 已补齐时使用不推荐
.PARAMETER QtPrefix
Qt 安装前缀默认从 CMakePresets.json CMAKE_PREFIX_PATH 解析
.EXAMPLE
powershell -ExecutionPolicy Bypass -File installer\build_installer.ps1
.EXAMPLE
powershell -ExecutionPolicy Bypass -File installer\build_installer.ps1 -Version 3.1.0 -Rebuild
#>
[CmdletBinding()]
param(
[string]$Version = '3.0.0',
[switch]$Rebuild,
[switch]$SkipDeploy,
[string]$QtPrefix
)
$ErrorActionPreference = 'Stop'
$InstallerDir = $PSScriptRoot
$RepoRoot = Split-Path $InstallerDir -Parent
$BuildAppDir = Join-Path $RepoRoot 'build\release\src\app'
$StageDir = Join-Path $InstallerDir 'staging'
$RedistDir = Join-Path $InstallerDir 'redist'
$DistDir = Join-Path $InstallerDir 'dist'
$IssFile = Join-Path $InstallerDir 'geopro.iss'
$ExeName = 'geopro_desktop.exe'
$BuildDate = Get-Date -Format 'yyyyMMdd'
function Info($m){ Write-Host "[pack] $m" -ForegroundColor Cyan }
function Warn($m){ Write-Host "[pack] $m" -ForegroundColor Yellow }
function Die($m){ Write-Host "[pack] ERROR: $m" -ForegroundColor Red; exit 1 }
# --- 0. 可选:干净重编 -------------------------------------------------------
if ($Rebuild) {
Info '执行 build.bat rebuild干净重编...'
& (Join-Path $RepoRoot 'build.bat') rebuild
if ($LASTEXITCODE -ne 0) { Die "build.bat rebuild 失败 (exit $LASTEXITCODE)" }
}
# --- 1. 定位 Qt / windeployqt ----------------------------------------------
if (-not $QtPrefix) {
$presets = Join-Path $RepoRoot 'CMakePresets.json'
if (Test-Path $presets) {
try {
$j = Get-Content $presets -Raw | ConvertFrom-Json
foreach ($p in $j.configurePresets) {
if ($p.cacheVariables.CMAKE_PREFIX_PATH) {
$QtPrefix = $p.cacheVariables.CMAKE_PREFIX_PATH; break
}
}
} catch { }
}
}
if (-not $QtPrefix) { $QtPrefix = 'D:/Qt/6.11.1/msvc2022_64' }
$QtBin = Join-Path ($QtPrefix -replace '/','\') 'bin'
$WinDeploy = Join-Path $QtBin 'windeployqt.exe'
# --- 2. 定位 ISCC -----------------------------------------------------------
$IsccCandidates = @(
"$env:LOCALAPPDATA\Programs\Inno Setup 6\ISCC.exe",
"${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe",
"$env:ProgramFiles\Inno Setup 6\ISCC.exe"
)
$Iscc = $IsccCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1
if (-not $Iscc) { $Iscc = (Get-Command ISCC.exe -ErrorAction SilentlyContinue).Source }
if (-not $Iscc) {
Die '未找到 Inno Setup (ISCC.exe)。请先安装winget install --id JRSoftware.InnoSetup -e'
}
# --- 3. 校验构建产物 --------------------------------------------------------
$BuiltExe = Join-Path $BuildAppDir $ExeName
if (-not (Test-Path $BuiltExe)) {
Die "未找到构建产物 $BuiltExe`n请先构建build.bat app或加 -Rebuild 参数)"
}
Info "构建产物: $BuiltExe ($([math]::Round((Get-Item $BuiltExe).Length/1MB,2)) MB, 修改于 $((Get-Item $BuiltExe).LastWriteTime))"
# --- 4. stage复制部署目录、剔除构建产物 -----------------------------------
Info 'stage 部署副本(剔除 CMakeFiles / *_autogen / *.pdb / *.log / *.cmake...'
if (Test-Path $StageDir) { Remove-Item $StageDir -Recurse -Force }
New-Item -ItemType Directory -Force $StageDir | Out-Null
robocopy $BuildAppDir $StageDir /E `
/XD CMakeFiles geopro_desktop_autogen `
/XF *.pdb *.log cmake_install.cmake *.ilk *.exp `
/NFL /NDL /NJH /NJS /MT:8 | Out-Null
if ($LASTEXITCODE -ge 8) { Die "robocopy 失败 (exit $LASTEXITCODE)" }
# --- 5. windeployqt 补齐 Qt 运行时(绕过 ADS 卡死) -------------------------
if (-not $SkipDeploy) {
if (-not (Test-Path $WinDeploy)) { Die "未找到 windeployqt: $WinDeploy(用 -QtPrefix 指定 Qt 路径)" }
Info 'windeployqt 补齐 Qt 运行时缺件...'
# qt6advanceddocking.dll 名字带 qt6 前缀windeployqt 会误当 Qt 模块去 Qt\bin 找它并报错中止。
# 临时把它拷进 Qt\bin 让 windeployqt 能读其依赖(实为 Qt6Core/Gui/Widgets跑完即删。
$adsName = 'qt6advanceddocking.dll'
$adsTmp = Join-Path $QtBin $adsName
$adsPreexisted = Test-Path $adsTmp
if (-not $adsPreexisted) { Copy-Item (Join-Path $StageDir $adsName) $adsTmp -Force }
try {
& $WinDeploy --release --no-translations --compiler-runtime (Join-Path $StageDir $ExeName) | Out-Null
if ($LASTEXITCODE -ne 0) { Die "windeployqt 失败 (exit $LASTEXITCODE)" }
} finally {
if (-not $adsPreexisted) { Remove-Item $adsTmp -Force -ErrorAction SilentlyContinue }
}
}
# --- 6. VC++ 运行时安装器就位 -----------------------------------------------
New-Item -ItemType Directory -Force $RedistDir | Out-Null
$VcRedist = Join-Path $RedistDir 'vc_redist.x64.exe'
if (-not (Test-Path $VcRedist)) {
Info 'vc_redist.x64.exe 缺失,从 Visual Studio 复制...'
$vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe"
$vsPath = & $vswhere -all -prerelease -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath | Select-Object -Last 1
$found = Get-ChildItem (Join-Path $vsPath 'VC\Redist') -Filter 'vc_redist.x64.exe' -Recurse -ErrorAction SilentlyContinue |
Sort-Object FullName -Descending | Select-Object -First 1
if (-not $found) { Die '未找到 vc_redist.x64.exe请手动放入 installer\redist\' }
Copy-Item $found.FullName $VcRedist -Force
}
# --- 7. ISCC 编译 -----------------------------------------------------------
New-Item -ItemType Directory -Force $DistDir | Out-Null
$stageMB = [math]::Round((Get-ChildItem $StageDir -Recurse -File | Measure-Object Length -Sum).Sum/1MB,1)
Info "staging 载荷 $stageMB MB开始用 Inno Setup 编译安装包..."
& $Iscc "/DAppVersion=$Version" "/DBuildDate=$BuildDate" $IssFile
if ($LASTEXITCODE -ne 0) { Die "ISCC 编译失败 (exit $LASTEXITCODE)" }
# --- 8. 收尾报告 ------------------------------------------------------------
$out = Join-Path $DistDir "Geopro_Setup_$Version-$BuildDate.exe"
if (Test-Path $out) {
Info '打包完成 ✓'
Write-Host " 安装包: $out"
Write-Host " 大小 : $([math]::Round((Get-Item $out).Length/1MB,1)) MB"
} else {
Warn "ISCC 返回成功,但未找到预期产物 $out(检查 installer\dist\"
}

88
installer/geopro.iss Normal file
View File

@ -0,0 +1,88 @@
; ============================================================================
; Geopro — Windows 安装包脚本 (Inno Setup 6)
;
; 本脚本不直接手动编译,而是由 build_installer.ps1 调用:
; - 该脚本会先把 build/release/src/app 部署副本 stage 到 installer\staging
; 跑 windeployqt 补齐 Qt 运行时缺件D3Dcompiler / opengl32sw / WebEngine 等),
; 再用 /D 命令行宏把版本号与构建日期传进来。
; 也可手动编译(用 staging 现有内容、默认版本号):
; "%LOCALAPPDATA%\Programs\Inno Setup 6\ISCC.exe" geopro.iss
;
; 产物installer\dist\Geopro_Setup_<版本>-<日期>.exe
; ============================================================================
; ---- 版本/日期:默认值,可被 build_installer.ps1 的 /D 宏覆盖 ----
#ifndef AppVersion
#define AppVersion "3.0.0"
#endif
#ifndef BuildDate
#define BuildDate "dev"
#endif
#define AppName "Geopro"
#define AppPublisher "Geomative"
#define AppExeName "geopro_desktop.exe"
[Setup]
; AppId 必须保持稳定,升级/卸载据此识别同一程序——切勿修改此 GUID。
AppId={{B1C23792-2FFC-4326-89DA-B592D50DDF16}
AppName={#AppName}
AppVersion={#AppVersion}
AppVerName={#AppName} {#AppVersion} ({#BuildDate})
AppPublisher={#AppPublisher}
DefaultDirName={autopf}\{#AppName}
DefaultGroupName={#AppName}
DisableProgramGroupPage=yes
UninstallDisplayName={#AppName} {#AppVersion}
UninstallDisplayIcon={app}\{#AppExeName}
OutputDir={#SourcePath}\dist
OutputBaseFilename=Geopro_Setup_{#AppVersion}-{#BuildDate}
Compression=lzma2/max
SolidCompression=yes
WizardStyle=modern
; 仅 64 位Qt/VTK 均为 x64 构建)
ArchitecturesAllowed=x64compatible
ArchitecturesInstallIn64BitMode=x64compatible
; 装入 Program Files 并需安装 VC++ 运行时——要求管理员权限
PrivilegesRequired=admin
; 失败时在 %TEMP% 留安装日志,便于排障
SetupLogging=yes
[Languages]
Name: "zh"; MessagesFile: "{#SourcePath}\lang\ChineseSimplified.isl"
Name: "en"; MessagesFile: "compiler:Default.isl"
[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
[Files]
; 主载荷staging 全量exe + 全部 DLL + 插件目录 + WebEngine 资源),递归打包
Source: "{#SourcePath}\staging\*"; DestDir: "{app}"; Flags: recursesubdirs createallsubdirs ignoreversion
; VC++ 运行时安装器:临时落地、装完即删
Source: "{#SourcePath}\redist\vc_redist.x64.exe"; DestDir: "{tmp}"; Flags: deleteafterinstall
[Icons]
Name: "{group}\{#AppName}"; Filename: "{app}\{#AppExeName}"; WorkingDir: "{app}"
Name: "{group}\{cm:UninstallProgram,{#AppName}}"; Filename: "{uninstallexe}"
Name: "{autodesktop}\{#AppName}"; Filename: "{app}\{#AppExeName}"; WorkingDir: "{app}"; Tasks: desktopicon
[Run]
; 安装 Microsoft Visual C++ 运行时(仅在系统未安装时执行;退出码被 Inno 忽略,已装则静默跳过)
Filename: "{tmp}\vc_redist.x64.exe"; Parameters: "/install /quiet /norestart"; \
StatusMsg: "正在安装 Microsoft Visual C++ 运行时..."; Check: VCRedistNeeded
; 安装结束可选立即启动
Filename: "{app}\{#AppExeName}"; Description: "{cm:LaunchProgram,{#AppName}}"; \
WorkingDir: "{app}"; Flags: nowait postinstall skipifsilent
[Code]
// 检测 VC++ 2015-2022 x64 运行时是否已安装64 位 + WOW6432Node 两个视图都查)
function VCRedistNeeded: Boolean;
var
installed: Cardinal;
begin
Result := True;
if RegQueryDWordValue(HKLM, 'SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64', 'Installed', installed) and (installed = 1) then
Result := False
else if RegQueryDWordValue(HKLM, 'SOFTWARE\WOW6432Node\Microsoft\VisualStudio\14.0\VC\Runtimes\x64', 'Installed', installed) and (installed = 1) then
Result := False;
end;

View File

@ -0,0 +1,418 @@
; *** Inno Setup version 6.5.0+ Chinese Simplified messages ***
;
; To download user-contributed translations of this file, go to:
; https://jrsoftware.org/files/istrans/
;
; Note: When translating this text, do not add periods (.) to the end of
; messages that didn't have them already, because on those messages Inno
; Setup adds the periods automatically (appending a period would result in
; two periods being displayed).
;
; Maintained by Zhenghan Yang
; Email: 847320916@QQ.com
; Translation based on network resource
; The latest Translation is on https://github.com/kira-96/Inno-Setup-Chinese-Simplified-Translation
;
[LangOptions]
; The following three entries are very important. Be sure to read and
; understand the '[LangOptions] section' topic in the help file.
LanguageName=简体中文
; If Language Name display incorrect, uncomment next line
; LanguageName=<7B80><4F53><4E2D><6587>
; About LanguageID, to reference link:
; https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-lcid/a9eac961-e77d-41a6-90a5-ce1a8b0cdb9c
LanguageID=$0804
; About CodePage, to reference link:
; https://docs.microsoft.com/en-us/windows/win32/intl/code-page-identifiers
LanguageCodePage=936
; If the language you are translating to requires special font faces or
; sizes, uncomment any of the following entries and change them accordingly.
;DialogFontName=
;DialogFontSize=9
;DialogFontBaseScaleWidth=7
;DialogFontBaseScaleHeight=15
;WelcomeFontName=Segoe UI
;WelcomeFontSize=14
[Messages]
; *** 应用程序标题
SetupAppTitle=安装
SetupWindowTitle=安装 - %1
UninstallAppTitle=卸载
UninstallAppFullTitle=%1 卸载
; *** Misc. common
InformationTitle=信息
ConfirmTitle=确认
ErrorTitle=错误
; *** SetupLdr messages
SetupLdrStartupMessage=现在将安装 %1。您想要继续吗
LdrCannotCreateTemp=无法创建临时文件。安装程序已中止
LdrCannotExecTemp=无法执行临时目录中的文件。安装程序已中止
HelpTextNote=
; *** 启动错误消息
LastErrorMessage=%1。%n%n错误 %2: %3
SetupFileMissing=安装目录中缺少文件 %1。请修正这个问题或者获取程序的新副本。
SetupFileCorrupt=安装文件已损坏。请获取程序的新副本。
SetupFileCorruptOrWrongVer=安装文件已损坏,或是与这个安装程序的版本不兼容。请修正这个问题或获取新的程序副本。
InvalidParameter=无效的命令行参数:%n%n%1
SetupAlreadyRunning=安装程序正在运行。
WindowsVersionNotSupported=此程序不支持当前计算机运行的 Windows 版本。
WindowsServicePackRequired=此程序需要 %1 服务包 %2 或更高版本。
NotOnThisPlatform=此程序不能在 %1 上运行。
OnlyOnThisPlatform=此程序只能在 %1 上运行。
OnlyOnTheseArchitectures=此程序只能安装到为下列处理器架构设计的 Windows 版本中:%n%n%1
WinVersionTooLowError=此程序需要 %1 版本 %2 或更高。
WinVersionTooHighError=此程序不能安装于 %1 版本 %2 或更高。
AdminPrivilegesRequired=在安装此程序时您必须以管理员身份登录。
PowerUserPrivilegesRequired=在安装此程序时您必须以管理员身份或有权限的用户组身份登录。
SetupAppRunningError=安装程序发现 %1 当前正在运行。%n%n请先关闭正在运行的程序然后点击“确定”继续或点击“取消”退出。
UninstallAppRunningError=卸载程序发现 %1 当前正在运行。%n%n请先关闭正在运行的程序然后点击“确定”继续或点击“取消”退出。
; *** 启动问题
PrivilegesRequiredOverrideTitle=选择安装程序模式
PrivilegesRequiredOverrideInstruction=选择安装模式
PrivilegesRequiredOverrideText1=%1 可以为所有用户安装(需要管理员权限),或仅为您安装。
PrivilegesRequiredOverrideText2=%1 可以仅为您安装,或为所有用户安装(需要管理员权限)。
PrivilegesRequiredOverrideAllUsers=为所有用户安装(&A)
PrivilegesRequiredOverrideAllUsersRecommended=为所有用户安装(&A) (建议选项)
PrivilegesRequiredOverrideCurrentUser=仅为我安装(&M)
PrivilegesRequiredOverrideCurrentUserRecommended=仅为我安装(&M) (建议选项)
; *** 其他错误
ErrorCreatingDir=安装程序无法创建目录“%1”
ErrorTooManyFilesInDir=无法在目录“%1”中创建文件因为里面包含太多文件
; *** 安装程序公共消息
ExitSetupTitle=退出安装程序
ExitSetupMessage=安装程序尚未完成。如果现在退出,将不会安装该程序。%n%n您之后可以再次运行安装程序完成安装。%n%n现在退出安装程序吗
AboutSetupMenuItem=关于安装程序(&A)...
AboutSetupTitle=关于安装程序
AboutSetupMessage=%1 版本 %2%n%3%n%n%1 主页:%n%4
AboutSetupNote=
TranslatorNote=简体中文翻译由Kira(847320916@qq.com)维护。项目地址https://github.com/kira-96/Inno-Setup-Chinese-Simplified-Translation
; *** 按钮
ButtonBack=< 上一步(&B)
ButtonNext=下一步(&N) >
ButtonInstall=安装(&I)
ButtonOK=确定
ButtonCancel=取消
ButtonYes=是(&Y)
ButtonYesToAll=全是(&A)
ButtonNo=否(&N)
ButtonNoToAll=全否(&O)
ButtonFinish=完成(&F)
ButtonBrowse=浏览(&B)...
ButtonWizardBrowse=浏览(&R)...
ButtonNewFolder=新建文件夹(&M)
; *** “选择语言”对话框消息
SelectLanguageTitle=选择安装语言
SelectLanguageLabel=选择安装时使用的语言。
; *** 公共向导文字
ClickNext=点击“下一步”继续,或点击“取消”退出安装程序。
BeveledLabel=
BrowseDialogTitle=浏览文件夹
BrowseDialogLabel=在下面的列表中选择一个文件夹,然后点击“确定”。
NewFolderName=新建文件夹
; *** “欢迎”向导页
WelcomeLabel1=欢迎使用 [name] 安装向导
WelcomeLabel2=现在将安装 [name/ver] 到您的电脑中。%n%n建议您在继续安装前关闭所有其他应用程序。
; *** “密码”向导页
WizardPassword=密码
PasswordLabel1=这个安装程序有密码保护。
PasswordLabel3=请输入密码,然后点击“下一步”继续。密码区分大小写。
PasswordEditLabel=密码(&P)
IncorrectPassword=您输入的密码不正确,请重新输入。
; *** “许可协议”向导页
WizardLicense=许可协议
LicenseLabel=请在继续安装前阅读以下重要信息。
LicenseLabel3=请仔细阅读下列许可协议。在继续安装前您必须同意这些协议条款。
LicenseAccepted=我同意此协议(&A)
LicenseNotAccepted=我不同意此协议(&D)
; *** “信息”向导页
WizardInfoBefore=信息
InfoBeforeLabel=请在继续安装前阅读以下重要信息。
InfoBeforeClickLabel=准备好继续安装后,点击“下一步”。
WizardInfoAfter=信息
InfoAfterLabel=请在继续安装前阅读以下重要信息。
InfoAfterClickLabel=准备好继续安装后,点击“下一步”。
; *** “用户信息”向导页
WizardUserInfo=用户信息
UserInfoDesc=请输入您的信息。
UserInfoName=用户名(&U)
UserInfoOrg=组织(&O)
UserInfoSerial=序列号(&S)
UserInfoNameRequired=您必须输入用户名。
; *** “选择目标目录”向导页
WizardSelectDir=选择目标位置
SelectDirDesc=您想将 [name] 安装在哪里?
SelectDirLabel3=安装程序将安装 [name] 到下面的文件夹中。
SelectDirBrowseLabel=点击“下一步”继续。如果您想选择其他文件夹,点击“浏览”。
DiskSpaceGBLabel=至少需要有 [gb] GB 的可用磁盘空间。
DiskSpaceMBLabel=至少需要有 [mb] MB 的可用磁盘空间。
CannotInstallToNetworkDrive=安装程序无法安装到一个网络驱动器。
CannotInstallToUNCPath=安装程序无法安装到一个 UNC 路径。
InvalidPath=您必须输入一个带驱动器卷标的完整路径,例如:%n%nC:\APP%n%n或UNC路径%n%n\\server\share
InvalidDrive=您选定的驱动器或 UNC 共享不存在或不能访问。请选择其他位置。
DiskSpaceWarningTitle=磁盘空间不足
DiskSpaceWarning=安装程序至少需要 %1 KB 的可用空间才能安装,但选定驱动器只有 %2 KB 的可用空间。%n%n您一定要继续吗
DirNameTooLong=文件夹名称或路径太长。
InvalidDirName=文件夹名称无效。
BadDirName32=文件夹名称不能包含下列任何字符:%n%n%1
DirExistsTitle=文件夹已存在
DirExists=文件夹:%n%n%1%n%n已经存在。您一定要安装到这个文件夹中吗
DirDoesntExistTitle=文件夹不存在
DirDoesntExist=文件夹:%n%n%1%n%n不存在。您想要创建此文件夹吗
; *** “选择组件”向导页
WizardSelectComponents=选择组件
SelectComponentsDesc=您想安装哪些程序组件?
SelectComponentsLabel2=选中您想安装的组件;取消您不想安装的组件。然后点击“下一步”继续。
FullInstallation=完全安装
; if possible don't translate 'Compact' as 'Minimal' (I mean 'Minimal' in your language)
CompactInstallation=简洁安装
CustomInstallation=自定义安装
NoUninstallWarningTitle=组件已存在
NoUninstallWarning=安装程序检测到下列组件已安装在您的电脑中:%n%n%1%n%n取消选中这些组件不会卸载它们。%n%n确定要继续吗
ComponentSize1=%1 KB
ComponentSize2=%1 MB
ComponentsDiskSpaceGBLabel=当前选择的组件需要至少 [gb] GB 的磁盘空间。
ComponentsDiskSpaceMBLabel=当前选择的组件需要至少 [mb] MB 的磁盘空间。
; *** “选择附加任务”向导页
WizardSelectTasks=选择附加任务
SelectTasksDesc=您想要安装程序执行哪些附加任务?
SelectTasksLabel2=选择您想要安装程序在安装 [name] 时执行的附加任务,然后点击“下一步”。
; *** “选择开始菜单文件夹”向导页
WizardSelectProgramGroup=选择开始菜单文件夹
SelectStartMenuFolderDesc=安装程序应该在哪里放置程序的快捷方式?
SelectStartMenuFolderLabel3=安装程序将在下列“开始”菜单文件夹中创建程序的快捷方式。
SelectStartMenuFolderBrowseLabel=点击“下一步”继续。如果您想选择其他文件夹,点击“浏览”。
MustEnterGroupName=您必须输入一个文件夹名。
GroupNameTooLong=文件夹名或路径太长。
InvalidGroupName=无效的文件夹名字。
BadGroupName=文件夹名不能包含下列任何字符:%n%n%1
NoProgramGroupCheck2=不创建开始菜单文件夹(&D)
; *** “准备安装”向导页
WizardReady=准备安装
ReadyLabel1=安装程序准备就绪,现在可以开始安装 [name] 到您的电脑。
ReadyLabel2a=点击“安装”继续此安装程序。如果您想重新考虑或修改任何设置,点击“上一步”。
ReadyLabel2b=点击“安装”继续此安装程序。
ReadyMemoUserInfo=用户信息:
ReadyMemoDir=目标位置:
ReadyMemoType=安装类型:
ReadyMemoComponents=已选择组件:
ReadyMemoGroup=开始菜单文件夹:
ReadyMemoTasks=附加任务:
; *** TExtractionWizardPage 向导页面与 ExtractArchive
ExtractingLabel=正在解压文件...
ButtonStopExtraction=停止解压(&S)
StopExtraction=您确定要停止解压吗?
ErrorExtractionAborted=解压已中止
ErrorExtractionFailed=解压失败:%1
; *** 压缩文件解压失败详情
ArchiveIncorrectPassword=压缩文件密码不正确
ArchiveIsCorrupted=压缩文件已损坏
ArchiveUnsupportedFormat=不支持的压缩文件格式
; *** TDownloadWizardPage 向导页面和 DownloadTemporaryFile
DownloadingLabel2=正在下载文件...
ButtonStopDownload=停止下载(&S)
StopDownload=您确定要停止下载吗?
ErrorDownloadAborted=下载已中止
ErrorDownloadFailed=下载失败:%1 %2
ErrorDownloadSizeFailed=获取下载大小失败:%1 %2
ErrorProgress=无效的进度:%1 / %2
ErrorFileSize=文件大小错误:预期 %1实际 %2
; *** “正在准备安装”向导页
WizardPreparing=正在准备安装
PreparingDesc=安装程序正在准备安装 [name] 到您的电脑。
PreviousInstallNotCompleted=先前的程序安装或卸载未完成,您需要重启您的电脑以完成。%n%n在重启电脑后再次运行安装程序以完成 [name] 的安装。
CannotContinue=安装程序不能继续。请点击“取消”退出。
ApplicationsFound=以下应用程序正在使用将由安装程序更新的文件。建议您允许安装程序自动关闭这些应用程序。
ApplicationsFound2=以下应用程序正在使用将由安装程序更新的文件。建议您允许安装程序自动关闭这些应用程序。安装完成后,安装程序将尝试重新启动这些应用程序。
CloseApplications=自动关闭应用程序(&A)
DontCloseApplications=不要关闭应用程序(&D)
ErrorCloseApplications=安装程序无法自动关闭所有应用程序。建议您在继续之前,关闭所有在使用需要由安装程序更新的文件的应用程序。
PrepareToInstallNeedsRestart=安装程序必须重启您的计算机。计算机重启后,请再次运行安装程序以完成 [name] 的安装。%n%n是否立即重新启动
; *** “正在安装”向导页
WizardInstalling=正在安装
InstallingLabel=安装程序正在安装 [name] 到您的电脑,请稍候。
; *** “安装完成”向导页
FinishedHeadingLabel=[name] 安装完成
FinishedLabelNoIcons=安装程序已在您的电脑中安装了 [name]。
FinishedLabel=安装程序已在您的电脑中安装了 [name]。您可以通过已安装的快捷方式运行此应用程序。
ClickFinish=点击“完成”退出安装程序。
FinishedRestartLabel=为完成 [name] 的安装,安装程序必须重新启动您的电脑。要立即重启吗?
FinishedRestartMessage=为完成 [name] 的安装,安装程序必须重新启动您的电脑。%n%n要立即重启吗
ShowReadmeCheck=是,我想查阅自述文件
YesRadio=是,立即重启电脑(&Y)
NoRadio=否,稍后重启电脑(&N)
; used for example as 'Run MyProg.exe'
RunEntryExec=运行 %1
; used for example as 'View Readme.txt'
RunEntryShellExec=查阅 %1
; *** “安装程序需要下一张磁盘”提示
ChangeDiskTitle=安装程序需要下一张磁盘
SelectDiskLabel2=请插入磁盘 %1 并点击“确定”。%n%n如果这个磁盘中的文件可以在下列文件夹之外的文件夹中找到请输入正确的路径或点击“浏览”。
PathLabel=路径(&P)
FileNotInDir2=“%2”中找不到文件“%1”。请插入正确的磁盘或选择其他文件夹。
SelectDirectoryLabel=请指定下一张磁盘的位置。
; *** 安装阶段消息
SetupAborted=安装程序未完成安装。%n%n请修正这个问题并重新运行安装程序。
AbortRetryIgnoreSelectAction=选择操作
AbortRetryIgnoreRetry=重试(&T)
AbortRetryIgnoreIgnore=忽略错误并继续(&I)
AbortRetryIgnoreCancel=关闭安装程序
RetryCancelSelectAction=选择操作
RetryCancelRetry=重试(&T)
RetryCancelCancel=取消(&C)
; *** 安装状态消息
StatusClosingApplications=正在关闭应用程序...
StatusCreateDirs=正在创建目录...
StatusExtractFiles=正在提取文件...
StatusDownloadFiles=正在下载文件...
StatusCreateIcons=正在创建快捷方式...
StatusCreateIniEntries=正在创建 INI 条目...
StatusCreateRegistryEntries=正在创建注册表条目...
StatusRegisterFiles=正在注册文件...
StatusSavingUninstall=正在保存卸载信息...
StatusRunProgram=正在完成安装...
StatusRestartingApplications=正在重启应用程序...
StatusRollback=正在撤销更改...
; *** 其他错误
ErrorInternal2=内部错误:%1
ErrorFunctionFailedNoCode=%1 失败
ErrorFunctionFailed=%1 失败;错误代码 %2
ErrorFunctionFailedWithMessage=%1 失败;错误代码 %2.%n%3
ErrorExecutingProgram=无法执行文件:%n%1
; *** 注册表错误
ErrorRegOpenKey=打开注册表项时出错:%n%1\%2
ErrorRegCreateKey=创建注册表项时出错:%n%1\%2
ErrorRegWriteKey=写入注册表项时出错:%n%1\%2
; *** INI 错误
ErrorIniEntry=在文件“%1”中创建 INI 条目时出错。
; *** 文件复制错误
FileAbortRetryIgnoreSkipNotRecommended=跳过此文件(&S) (不推荐)
FileAbortRetryIgnoreIgnoreNotRecommended=忽略错误并继续(&I) (不推荐)
SourceIsCorrupted=源文件已损坏
SourceDoesntExist=源文件“%1”不存在
SourceVerificationFailed=源文件验证失败: %1
VerificationSignatureDoesntExist=签名文件“%1”不存在
VerificationSignatureInvalid=签名文件“%1”无效
VerificationKeyNotFound=签名文件“%1”使用了未知密钥
VerificationFileNameIncorrect=文件名不正确
VerificationFileTagIncorrect=文件标签不正确
VerificationFileSizeIncorrect=文件大小不正确
VerificationFileHashIncorrect=文件哈希值不正确
ExistingFileReadOnly2=无法替换现有文件,它是只读的。
ExistingFileReadOnlyRetry=移除只读属性并重试(&R)
ExistingFileReadOnlyKeepExisting=保留现有文件(&K)
ErrorReadingExistingDest=尝试读取现有文件时出错:
FileExistsSelectAction=选择操作
FileExists2=文件已经存在。
FileExistsOverwriteExisting=覆盖已存在的文件(&O)
FileExistsKeepExisting=保留现有的文件(&K)
FileExistsOverwriteOrKeepAll=为所有冲突文件执行此操作(&D)
ExistingFileNewerSelectAction=选择操作
ExistingFileNewer2=现有的文件比安装程序将要安装的文件还要新。
ExistingFileNewerOverwriteExisting=覆盖已存在的文件(&O)
ExistingFileNewerKeepExisting=保留现有的文件(&K) (推荐)
ExistingFileNewerOverwriteOrKeepAll=为所有冲突文件执行此操作(&D)
ErrorChangingAttr=尝试更改下列现有文件的属性时出错:
ErrorCreatingTemp=尝试在目标目录创建文件时出错:
ErrorReadingSource=尝试读取下列源文件时出错:
ErrorCopying=尝试复制下列文件时出错:
ErrorDownloading=下载文件时出错:
ErrorExtracting=解压压缩文件时出错:
ErrorReplacingExistingFile=尝试替换现有文件时出错:
ErrorRestartReplace=重启并替换失败:
ErrorRenamingTemp=尝试重命名下列目标目录中的一个文件时出错:
ErrorRegisterServer=无法注册 DLL/OCX%1
ErrorRegSvr32Failed=RegSvr32 失败;退出代码 %1
ErrorRegisterTypeLib=无法注册类库:%1
; *** 卸载显示名字标记
; used for example as 'My Program (32-bit)'
UninstallDisplayNameMark=%1 (%2)
; used for example as 'My Program (32-bit, All users)'
UninstallDisplayNameMarks=%1 (%2, %3)
UninstallDisplayNameMark32Bit=32 位
UninstallDisplayNameMark64Bit=64 位
UninstallDisplayNameMarkAllUsers=所有用户
UninstallDisplayNameMarkCurrentUser=当前用户
; *** 安装后错误
ErrorOpeningReadme=尝试打开自述文件时出错。
ErrorRestartingComputer=安装程序无法重启电脑,请手动重启。
; *** 卸载消息
UninstallNotFound=文件“%1”不存在。无法卸载。
UninstallOpenError=文件“%1”不能被打开。无法卸载。
UninstallUnsupportedVer=此版本的卸载程序无法识别卸载日志文件“%1”的格式。无法卸载
UninstallUnknownEntry=卸载日志中遇到一个未知条目 (%1)
ConfirmUninstall=您确认要完全移除 %1 及其所有组件吗?
UninstallOnlyOnWin64=仅允许在 64 位 Windows 中卸载此程序。
OnlyAdminCanUninstall=仅使用管理员权限的用户能完成此卸载。
UninstallStatusLabel=正在从您的电脑中移除 %1请稍候。
UninstalledAll=已顺利从您的电脑中移除 %1。
UninstalledMost=%1 卸载完成。%n%n有部分内容未能被删除但您可以手动删除它们。
UninstalledAndNeedsRestart=为完成 %1 的卸载,需要重启您的电脑。%n%n立即重启电脑吗
UninstallDataCorrupted=文件“%1”已损坏。无法卸载
; *** 卸载状态消息
ConfirmDeleteSharedFileTitle=删除共享的文件吗?
ConfirmDeleteSharedFile2=系统表示下列共享的文件已不有其他程序使用。您希望卸载程序删除这些共享的文件吗?%n%n如果删除这些文件但仍有程序在使用这些文件则这些程序可能出现异常。如果您不能确定请选择“否”在系统中保留这些文件以免引发问题。
SharedFileNameLabel=文件名:
SharedFileLocationLabel=位置:
WizardUninstalling=卸载状态
StatusUninstalling=正在卸载 %1...
; *** Shutdown block reasons
ShutdownBlockReasonInstallingApp=正在安装 %1。
ShutdownBlockReasonUninstallingApp=正在卸载 %1。
; The custom messages below aren't used by Setup itself, but if you make
; use of them in your scripts, you'll want to translate them.
[CustomMessages]
NameAndVersion=%1 版本 %2
AdditionalIcons=附加快捷方式:
CreateDesktopIcon=创建桌面快捷方式(&D)
CreateQuickLaunchIcon=创建快速启动栏快捷方式(&Q)
ProgramOnTheWeb=%1 网站
UninstallProgram=卸载 %1
LaunchProgram=运行 %1
AssocFileExtension=将 %2 文件扩展名与 %1 建立关联(&A)
AssocingFileExtension=正在将 %2 文件扩展名与 %1 建立关联...
AutoStartProgramGroupDescription=启动:
AutoStartProgram=自动启动 %1
AddonHostProgramNotFound=您选择的文件夹中无法找到 %1。%n%n您要继续吗

View File

@ -0,0 +1,74 @@
#include "AnomalyPropertiesDialog.hpp"
#include <QDialogButtonBox>
#include <QFormLayout>
#include <QLabel>
#include <QPlainTextEdit>
#include <QVBoxLayout>
namespace geopro::app {
namespace {
QString markTypeLabel(geopro::core::AnomalyMarkType t) {
switch (t) {
case geopro::core::AnomalyMarkType::Point: return QStringLiteral("");
case geopro::core::AnomalyMarkType::Polyline: return QStringLiteral("折线");
case geopro::core::AnomalyMarkType::Polygon: return QStringLiteral("多边形");
}
return QStringLiteral("");
}
QString orDash(const std::string& s) {
return s.empty() ? QStringLiteral("") : QString::fromStdString(s);
}
} // namespace
AnomalyPropertiesDialog::AnomalyPropertiesDialog(const geopro::core::Anomaly& a, QWidget* parent)
: QDialog(parent) {
setWindowTitle(QStringLiteral("异常属性"));
setModal(true);
auto* root = new QVBoxLayout(this);
auto* form = new QFormLayout();
form->addRow(QStringLiteral("名称"), new QLabel(orDash(a.name)));
form->addRow(QStringLiteral("类型"), new QLabel(orDash(a.typeName)));
form->addRow(QStringLiteral("标记类型"), new QLabel(markTypeLabel(a.markType)));
form->addRow(QStringLiteral("归属三维体"), new QLabel(orDash(a.volumeDsId)));
form->addRow(QStringLiteral("异常体"),
new QLabel(a.consortiumId.empty() ? QStringLiteral("(未分组)")
: QString::fromStdString(a.consortiumId)));
root->addLayout(form);
// 顶点世界坐标只读列表x/y/z 每行一个点)。
root->addWidget(new QLabel(QStringLiteral("顶点坐标(%1 个)").arg(a.worldPts.size())));
auto* pts = new QPlainTextEdit();
pts->setReadOnly(true);
pts->setFixedHeight(120);
QString text;
for (std::size_t i = 0; i < a.worldPts.size(); ++i) {
const auto& p = a.worldPts[i];
text += QStringLiteral("%1: (%2, %3, %4)\n")
.arg(i + 1)
.arg(p.x, 0, 'f', 2)
.arg(p.y, 0, 'f', 2)
.arg(p.z, 0, 'f', 2);
}
pts->setPlainText(text);
root->addWidget(pts);
// 备注(只读)。
root->addWidget(new QLabel(QStringLiteral("备注")));
auto* remark = new QPlainTextEdit();
remark->setReadOnly(true);
remark->setFixedHeight(60);
remark->setPlainText(QString::fromStdString(a.remark));
root->addWidget(remark);
auto* buttons = new QDialogButtonBox(QDialogButtonBox::Close);
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
root->addWidget(buttons);
}
} // namespace geopro::app

View File

@ -0,0 +1,17 @@
#pragma once
#include <QDialog>
#include "model/Anomaly.hpp"
namespace geopro::app {
// 异常属性对话框(#4c-3需求 R83双击异常列表项弹出只读展示选中异常的
// 名称/类型/标记类型/备注/归属三维体/异常体分组/顶点世界坐标。
// 截图:模型与异常端点均无截图字段(保存对话框的截图仅为 mock 预览、未持久化),故不展示。
class AnomalyPropertiesDialog : public QDialog {
Q_OBJECT
public:
AnomalyPropertiesDialog(const geopro::core::Anomaly& a, QWidget* parent = nullptr);
};
} // namespace geopro::app

View File

@ -0,0 +1,72 @@
#include "AnomalySaveDialog.hpp"
#include <QComboBox>
#include <QDialogButtonBox>
#include <QFormLayout>
#include <QLabel>
#include <QLineEdit>
#include <QPixmap>
#include <QPlainTextEdit>
#include <QVBoxLayout>
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,
QWidget* parent)
: QDialog(parent) {
setWindowTitle(QStringLiteral("保存异常"));
setModal(true);
auto* root = new QVBoxLayout(this);
auto* form = new QFormLayout();
name_ = new QLineEdit(QStringLiteral("异常"));
form->addRow(QStringLiteral("名称"), name_);
type_ = new QComboBox();
for (const auto& t : kMockTypes)
type_->addItem(QString::fromUtf8(t.label), QString::fromUtf8(t.id));
form->addRow(QStringLiteral("异常类型"), type_);
remark_ = new QPlainTextEdit();
remark_->setFixedHeight(60);
form->addRow(QStringLiteral("备注"), remark_);
root->addLayout(form);
// 截图预览 + 大小R50「确定截图大小」
if (!screenshotPath.isEmpty()) {
root->addWidget(new QLabel(QStringLiteral("截图(%1 × %2").arg(shotW).arg(shotH)));
QPixmap pm(screenshotPath);
if (!pm.isNull()) {
auto* img = new QLabel();
img->setPixmap(pm.scaledToWidth(320, Qt::SmoothTransformation));
root->addWidget(img);
}
}
auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel);
connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
root->addWidget(buttons);
}
QString AnomalySaveDialog::anomalyName() const {
const QString n = name_->text().trimmed();
return n.isEmpty() ? QStringLiteral("异常") : n;
}
QString AnomalySaveDialog::typeName() const { return type_->currentText(); }
QString AnomalySaveDialog::typeId() const { return type_->currentData().toString(); }
QString AnomalySaveDialog::remark() const { return remark_->toPlainText(); }
} // namespace geopro::app

View File

@ -0,0 +1,31 @@
#pragma once
#include <QDialog>
#include <QString>
class QLineEdit;
class QComboBox;
class QPlainTextEdit;
class QLabel;
namespace geopro::app {
// 异常保存对话框(#4b需求 R50名称 + 异常类型 + 备注 + 截图预览/大小。
// 异常类型本期 mock 列表(真实 exceptionType 端点只读、后续可接。accept 后取 name/typeName/typeId/remark。
class AnomalySaveDialog : public QDialog {
Q_OBJECT
public:
// screenshotPath圈定结束截图的本地路径为空则不显示预览w/h截图像素尺寸R50「确定截图大小」
AnomalySaveDialog(const QString& screenshotPath, int shotW, int shotH, QWidget* parent = nullptr);
QString anomalyName() const;
QString typeName() const;
QString typeId() const;
QString remark() const;
private:
QLineEdit* name_ = nullptr;
QComboBox* type_ = nullptr;
QPlainTextEdit* remark_ = nullptr;
};
} // namespace geopro::app

View File

@ -9,6 +9,7 @@ find_package(VTK REQUIRED COMPONENTS
InteractionWidgets
FiltersGeometry
FiltersModeling
IOImage # vtkPNGWriter
)
find_package(nlohmann_json CONFIG REQUIRED)
find_package(Qt6 REQUIRED COMPONENTS Svg)
@ -63,8 +64,13 @@ add_executable(geopro_desktop WIN32
ObjectFormDialog.cpp
ImportDatasetDialog.cpp
ExportDatasetDialog.cpp
AnomalySaveDialog.cpp
AnomalyPropertiesDialog.cpp
SettingsDialog.cpp
SliceExport.cpp
SlicePropertiesDialog.cpp
VolumeParamsDialog.cpp
VolumePropertiesDialog.cpp
Logging.cpp
DatasetDimension.cpp
TileBasemap.cpp)

65
src/app/SliceExport.cpp Normal file
View File

@ -0,0 +1,65 @@
#include "SliceExport.hpp"
#include <fstream>
#include <vtkDataArray.h>
#include <vtkImageData.h>
#include <vtkNew.h>
#include <vtkPNGWriter.h>
#include <vtkPointData.h>
#include <vtkRenderWindow.h>
#include <vtkWindowToImageFilter.h>
namespace geopro::app {
bool exportSliceImagePng(vtkImageData* colorImage, const std::string& path) {
if (colorImage == nullptr || path.empty()) return false;
vtkNew<vtkPNGWriter> writer;
writer->SetFileName(path.c_str());
writer->SetInputData(colorImage); // 已上色 RGB 的切片 2D 图(非整窗截图)
writer->Write();
return writer->GetErrorCode() == 0;
}
bool captureRenderWindowPng(vtkRenderWindow* win, const std::string& path, int& outW, int& outH) {
outW = outH = 0;
if (win == nullptr || path.empty()) return false;
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();
return writer->GetErrorCode() == 0;
}
bool exportSliceDat(vtkImageData* slice, const std::string& path) {
if (slice == nullptr || path.empty()) return false;
vtkDataArray* arr = slice->GetPointData() ? slice->GetPointData()->GetScalars() : nullptr;
if (arr == nullptr) return false;
int dims[3];
slice->GetDimensions(dims);
const int nx = dims[0], ny = dims[1];
if (nx < 1 || ny < 1) return false;
std::ofstream out(path);
if (!out) return false;
// 切片重采样为 2Ddims[2]=1写成行=j、列=i 的标量网格,每格取首分量。
for (int j = 0; j < ny; ++j) {
for (int i = 0; i < nx; ++i) {
const vtkIdType id = static_cast<vtkIdType>(j) * nx + i;
out << arr->GetComponent(id, 0) << (i + 1 < nx ? ' ' : '\n');
}
}
return static_cast<bool>(out);
}
} // namespace geopro::app

18
src/app/SliceExport.hpp Normal file
View File

@ -0,0 +1,18 @@
#pragma once
#include <string>
class vtkImageData;
class vtkRenderWindow;
namespace geopro::app {
// 把切片"上色后"的 2D RGB 影像写为 PNG切片右键「导出为图片」= 导出切片本身,非整窗截图)。
bool exportSliceImagePng(vtkImageData* colorImage, const std::string& path);
// 截整个渲染窗口为 PNG异常标识截图需求 R88成功返回 true并填回截图像素宽高。
bool captureRenderWindowPng(vtkRenderWindow* win, const std::string& path, int& outW, int& outH);
// 把切片重采样 2D 标量影像写为 .dat 文本网格(行=j、列=i空格分隔每格取标量首分量成功返回 true。
bool exportSliceDat(vtkImageData* slice, const std::string& path);
} // namespace geopro::app

View File

@ -0,0 +1,64 @@
#include "SlicePropertiesDialog.hpp"
#include <QDialogButtonBox>
#include <QFormLayout>
#include <QLabel>
#include <QVBoxLayout>
#include <array>
namespace geopro::app {
namespace {
using SliceSpec = geopro::data::I3dSceneRepository::SliceSpec;
QString axisLabel(int axis) {
switch (axis) {
case 0: return QStringLiteral("上下");
case 1: return QStringLiteral("前后");
case 2: return QStringLiteral("左右");
case 3: return QStringLiteral("任意");
default: return QStringLiteral("");
}
}
QString pointLabel(const std::array<double, 3>& p) {
return QStringLiteral("(%1, %2, %3)")
.arg(p[0], 0, 'f', 2)
.arg(p[1], 0, 'f', 2)
.arg(p[2], 0, 'f', 2);
}
} // namespace
SlicePropertiesDialog::SlicePropertiesDialog(const QString& name, const SliceSpec& spec,
QWidget* parent)
: QDialog(parent) {
setWindowTitle(QStringLiteral("切片属性"));
setModal(true);
auto* root = new QVBoxLayout(this);
auto* form = new QFormLayout();
form->addRow(QStringLiteral("名称"),
new QLabel(name.isEmpty() ? QStringLiteral("") : name));
form->addRow(QStringLiteral("所属三维体"),
new QLabel(spec.volumeDsId.empty() ? QStringLiteral("")
: QString::fromStdString(spec.volumeDsId)));
form->addRow(QStringLiteral("轴向"), new QLabel(axisLabel(spec.axis)));
form->addRow(QStringLiteral("Origin"), new QLabel(pointLabel(spec.origin)));
form->addRow(QStringLiteral("Point1"), new QLabel(pointLabel(spec.point1)));
form->addRow(QStringLiteral("Point2"), new QLabel(pointLabel(spec.point2)));
form->addRow(QStringLiteral("色阶来源"),
new QLabel(spec.colorScaleId.empty()
? QStringLiteral("首个源数据集")
: QString::fromStdString(spec.colorScaleId)));
root->addLayout(form);
auto* buttons = new QDialogButtonBox(QDialogButtonBox::Close);
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
root->addWidget(buttons);
}
} // namespace geopro::app

View File

@ -0,0 +1,20 @@
#pragma once
#include <QDialog>
#include <QString>
#include "repo/I3dSceneRepository.hpp" // I3dSceneRepository::SliceSpec
namespace geopro::app {
// 切片属性对话框(收尾项 #6三维分析栏右键「数据详情」弹出只读展示切片的
// 位姿/参数(所属三维体/轴向/平面三点/色阶)。
// 不含采样分辨率/值域等统计:切面网格来自渲染时计算、仓储层不持久化(守 YAGNI
class SlicePropertiesDialog : public QDialog {
Q_OBJECT
public:
SlicePropertiesDialog(const QString& name,
const geopro::data::I3dSceneRepository::SliceSpec& spec,
QWidget* parent = nullptr);
};
} // namespace geopro::app

View File

@ -0,0 +1,84 @@
#include "VolumePropertiesDialog.hpp"
#include <QDialogButtonBox>
#include <QFormLayout>
#include <QLabel>
#include <QStringList>
#include <QVBoxLayout>
namespace geopro::app {
namespace {
using VolumeInfo = geopro::data::Api3dRepository::VolumeInfo;
using Model = geopro::data::VolumeBuildParams::Model;
constexpr const char* kPending = "—(生成/渲染后可见)";
QString joinSources(const std::vector<std::string>& ids) {
if (ids.empty()) return QStringLiteral("");
QStringList list;
for (const auto& s : ids) list << QString::fromStdString(s);
return list.join(QStringLiteral(", "));
}
QString modelLabel(const geopro::data::VolumeBuildParams& p) {
if (p.interpModel == Model::Idw)
return QStringLiteral("IDW幂=%1").arg(p.power, 0, 'f', 1);
return QStringLiteral("Kriging");
}
} // namespace
VolumePropertiesDialog::VolumePropertiesDialog(const QString& name, const VolumeInfo& info,
QWidget* parent)
: QDialog(parent) {
setWindowTitle(QStringLiteral("三维体属性"));
setModal(true);
auto* root = new QVBoxLayout(this);
auto* form = new QFormLayout();
// ── 参数(随时可取)─────────────────────────────────────────────
form->addRow(QStringLiteral("名称"),
new QLabel(name.isEmpty() ? QStringLiteral("") : name));
form->addRow(QStringLiteral("源数据集"), new QLabel(joinSources(info.params.sourceDatasetIds)));
form->addRow(QStringLiteral("插值模型"), new QLabel(modelLabel(info.params)));
form->addRow(QStringLiteral("网格间距"),
new QLabel(QStringLiteral("XY=%1 m Z=%2 m")
.arg(info.params.cellXY, 0, 'f', 2)
.arg(info.params.cellZ, 0, 'f', 2)));
form->addRow(QStringLiteral("超距"),
new QLabel(QStringLiteral("%1 m").arg(info.params.maxDist, 0, 'f', 2)));
form->addRow(QStringLiteral("色阶来源"),
new QLabel(info.params.colorScaleId.empty()
? QStringLiteral("首个源数据集")
: QString::fromStdString(info.params.colorScaleId)));
// ── 统计(仅 loaded 时有效)──────────────────────────────────────
if (info.loaded) {
form->addRow(QStringLiteral("值域"), new QLabel(QStringLiteral("%1 ~ %2")
.arg(info.vmin, 0, 'f', 2)
.arg(info.vmax, 0, 'f', 2)));
form->addRow(QStringLiteral("网格"), new QLabel(QStringLiteral("%1 × %2 × %3")
.arg(info.nx)
.arg(info.ny)
.arg(info.nz)));
form->addRow(QStringLiteral("测点数"),
new QLabel(QString::number(static_cast<qulonglong>(info.pointCount))));
form->addRow(QStringLiteral("范围"),
new QLabel(QStringLiteral("%1 × %2 × %3 m")
.arg(info.nx * info.dx, 0, 'f', 1)
.arg(info.ny * info.dy, 0, 'f', 1)
.arg(info.nz * info.dz, 0, 'f', 1)));
} else {
form->addRow(QStringLiteral("统计"), new QLabel(QString::fromUtf8(kPending)));
}
root->addLayout(form);
auto* buttons = new QDialogButtonBox(QDialogButtonBox::Close);
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
root->addWidget(buttons);
}
} // namespace geopro::app

View File

@ -0,0 +1,20 @@
#pragma once
#include <QDialog>
#include <QString>
#include "api/Api3dRepository.hpp" // Api3dRepository::VolumeInfo
namespace geopro::app {
// 三维体属性对话框(收尾项 #6三维分析栏右键「数据详情」弹出只读展示三维体的
// 参数(源数据/插值模型/网格/超距/色阶)与统计(值域/网格/测点数/范围)。
// 统计仅在体被生成过loadVolume 缓存明细info.loaded=true时显示否则显占位。
class VolumePropertiesDialog : public QDialog {
Q_OBJECT
public:
VolumePropertiesDialog(const QString& name,
const geopro::data::Api3dRepository::VolumeInfo& info,
QWidget* parent = nullptr);
};
} // namespace geopro::app

View File

@ -9,6 +9,7 @@
#include <QString>
#include <vtkActor.h>
#include <vtkProperty.h>
#include <vtkBoundingBox.h>
#include <vtkCubeAxesActor.h>
#include <vtkProp.h>
@ -19,6 +20,7 @@
#include "CameraPreset.hpp"
#include "Scene.hpp"
#include "Theme.hpp"
#include "actors/AnomalyActor.hpp"
#include "actors/AxesActor.hpp"
#include "actors/CurtainActor.hpp"
#include "actors/MapLineActor.hpp"
@ -101,6 +103,7 @@ void VtkSceneView::clear() {
for (auto& kv : dsProps_) removeProps(kv.second);
dsProps_.clear();
removeProps(miscProps_);
clearAnomalies(); // 异常 actor 随清场一并移除
if (currentAxes_) {
scene_.renderer()->RemoveViewProp(currentAxes_);
currentAxes_ = nullptr;
@ -189,6 +192,44 @@ void VtkSceneView::removeDataset(const std::string& dsId) {
}
}
void VtkSceneView::addAnomaly(const geopro::core::Anomaly& a) {
if (a.id.empty()) return;
removeAnomaly(a.id); // 幂等:同 id 先移除旧 actor避免重复
auto actor = geopro::render::buildAnomaly3D(a);
if (!actor) return;
scene_.addActor(actor); // worldPts 已是世界系(含 VE),不再 SetScale
anomalyProps_[a.id] = actor;
}
void VtkSceneView::removeAnomaly(const std::string& anomalyId) {
auto it = anomalyProps_.find(anomalyId);
if (it == anomalyProps_.end()) return;
if (it->second) scene_.renderer()->RemoveViewProp(it->second);
anomalyProps_.erase(it);
}
void VtkSceneView::clearAnomalies() {
for (auto& kv : anomalyProps_)
if (kv.second) scene_.renderer()->RemoveViewProp(kv.second);
anomalyProps_.clear();
}
void VtkSceneView::setAnomalyVisible(const std::string& anomalyId, bool visible) {
auto it = anomalyProps_.find(anomalyId);
if (it != anomalyProps_.end() && it->second) it->second->SetVisibility(visible ? 1 : 0);
}
void VtkSceneView::setSelectedAnomaly(const std::string& anomalyId) {
// 选中者加粗高亮、其余恢复常态列表↔VTK 联动 R84
for (auto& kv : anomalyProps_) {
if (!kv.second) continue;
const bool sel = (kv.first == anomalyId);
kv.second->GetProperty()->SetLineWidth(sel ? 5.0 : 2.0);
kv.second->GetProperty()->SetPointSize(sel ? 12.0 : 8.0);
}
if (renderWindow_) renderWindow_->Render();
}
void VtkSceneView::setAxes(geopro::controller::AxesMode mode, geopro::controller::AxesUnit unit,
int fontSize) {
axesMode_ = mode;

View File

@ -17,6 +17,7 @@ namespace geopro::render { class Scene; }
class vtkRenderer;
class vtkRenderWindow;
class vtkProp;
class vtkActor;
namespace geopro::app {
@ -39,6 +40,11 @@ public:
const geopro::core::ColorScale& cs) override;
void addTerrain(const geopro::data::TerrainPaths& paths) override;
void removeDataset(const std::string& dsId) override;
void addAnomaly(const geopro::core::Anomaly& a) override;
void removeAnomaly(const std::string& anomalyId) override;
void clearAnomalies() override;
void setAnomalyVisible(const std::string& anomalyId, bool visible) override;
void setSelectedAnomaly(const std::string& anomalyId) override;
void setAxes(geopro::controller::AxesMode mode, geopro::controller::AxesUnit unit,
int fontSize) override;
void applyCameraView(geopro::controller::ViewDir dir) override;
@ -54,6 +60,7 @@ public:
double currentVmin() const { return currentVmin_; }
double currentVmax() const { return currentVmax_; }
bool hasVolume() const { return currentVolumeImage_ != nullptr; }
const std::string& currentVolumeDsId() const { return volumeOwnerDs_; } // 当前体归属 ds保存切片用
// 体素 image 变化addVolume 附着新 image / clear 置空)时回调,供上层把新 image 推给
// InteractionManager重附着或关闭切片。clear 时以 nullptr 触发。
@ -106,6 +113,7 @@ private:
std::map<std::string, std::vector<vtkSmartPointer<vtkProp>>> dsProps_;
std::vector<vtkSmartPointer<vtkProp>> miscProps_;
std::string volumeOwnerDs_; // 当前 currentVolumeImage_ 归属的 ds其被移除时置空切片源
std::map<std::string, vtkSmartPointer<vtkActor>> anomalyProps_; // 异常 id → 3D actor
};
} // namespace geopro::app

View File

@ -41,7 +41,13 @@
#include <QSlider>
#include <QGraphicsOpacityEffect>
#include <QDate>
#include <QAction>
#include <QCursor>
#include <QDir>
#include <QFileDialog>
#include <QInputDialog>
#include <QLabel>
#include <QLineEdit>
#include <QListWidget>
#include <QListWidgetItem>
#include <QJsonObject>
@ -91,9 +97,15 @@
#include "Logging.hpp"
#include "PanelHeader.hpp"
#include "Theme.hpp"
#include "AnomalySaveDialog.hpp"
#include "AnomalyPropertiesDialog.hpp"
#include "SettingsDialog.hpp"
#include "SlicePropertiesDialog.hpp"
#include "SliceExport.hpp"
#include "TopBar.hpp"
#include "VolumeParamsDialog.hpp"
#include "VolumePropertiesDialog.hpp"
#include "interact/AnomalyDrawTool.hpp"
#include "ProjectListDialog.hpp"
#include "ObjectFormDialog.hpp"
#include "ImportDatasetDialog.hpp"
@ -155,6 +167,7 @@
#include <vtkGenericOpenGLRenderWindow.h>
#include <vtkLookupTable.h>
#include <vtkProperty.h>
#include <vtkImageData.h>
#include <vtkRenderWindowInteractor.h>
#include <vtkRenderer.h>
#include <vtkSmartPointer.h>
@ -264,12 +277,14 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
// 安装自定义拾取样式 + 持活动切片。仅三维 + 有体素可用;切到二维 closeAll。
auto* interactionMgr = new geopro::render::interact::InteractionManager(
renderWindowPtr->GetInteractor(), renderWindowPtr, scene->renderer());
// 异常圈定工具(#4b在切片平面上画多边形高优先级观察者绘制期独占输入
auto* anomalyDrawTool = new geopro::render::interact::AnomalyDrawTool(
renderWindowPtr->GetInteractor(), scene->renderer());
// sceneView->onVolumeChanged 在三栏接线处设置(把体素 image 推给 InteractionManager见下
// 非 QObject 堆对象统一在此清理,按构造逆序:
// interactionMgr(持 interactor/切片观察者) → sceneView(持 scene&) → scene3dRepo → scene。
// interactionMgr 先析构closeAll() 解绑所有切片观察者,再拆 scene/interactor防悬挂崩溃。
// 非 QObject 堆对象统一在此清理,按构造逆序(持 interactor 观察者者先析构,防悬挂崩溃):
QObject::connect(vtkWidget, &QObject::destroyed,
[scene, scene3dRepo, sceneView, interactionMgr]() {
[scene, scene3dRepo, sceneView, interactionMgr, anomalyDrawTool]() {
delete anomalyDrawTool;
delete interactionMgr;
delete sceneView;
delete scene3dRepo;
@ -350,14 +365,75 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
centerLayout->addWidget(viewHeader);
centerLayout->addLayout(viewRow, 1);
// 体素变化(重建/清场)后把体素 image 推给 InteractionManager切片基底
sceneView->onVolumeChanged = [interactionMgr, sceneView]() {
// 3b三维分析栏勾选的已保存切片(dd_slice) id 集合 + 调和函数。
// syncSlices按"当前活动体 dsId"调和 InteractionManager 上显示的已保存切片——
// 勾选且父体=当前体 → 显示(按 spec 还原);否则移除。须在 onVolumeChanged(体到场/移除)末尾
// 及分析栏勾选变化时调用。注setVolumeImage 会 closeAll故体变更后由本函数重建。
auto checkedSliceIds = std::make_shared<std::set<std::string>>();
auto syncSlices = [interactionMgr, sceneView, scene3dRepo, checkedSliceIds]() {
const std::string curVol = sceneView->currentVolumeDsId();
// 移除:已显示但不再需要(未勾选 / 父体非当前体 / 无活动体)。
for (const std::string& shownId : interactionMgr->shownSavedSliceIds()) {
geopro::data::I3dSceneRepository::SliceSpec sp;
const bool wanted = !curVol.empty() && checkedSliceIds->count(shownId) > 0 &&
scene3dRepo->sliceSpec(shownId, sp) && sp.volumeDsId == curVol;
if (!wanted) interactionMgr->hideSavedSlice(shownId);
}
// 添加:勾选 + 父体=当前体 + 未显示showSavedSlice 内部去重)。按精确三点几何还原。
if (!curVol.empty()) {
for (const std::string& id : *checkedSliceIds) {
geopro::data::I3dSceneRepository::SliceSpec sp;
if (scene3dRepo->sliceSpec(id, sp) && sp.volumeDsId == curVol)
interactionMgr->showSavedSlice(id, sp.axis, sp.origin, sp.point1, sp.point2);
}
}
};
// 异常刷新渲染 + 填充三维分析栏异常列表(#4b/4c按显示过滤档位决定异常集合。
// 0 全部显示=所有异常1 随GS/2 随数据集=当前活动体的异常3 全部隐藏=不渲染、列表空。
// 随GS 暂同随数据集,无 GS 分组数据。loadAnomalyTree 空 key→全部非空→该体。mock 同步回调。)
auto refreshAnomalies = [sceneView, scene3dRepo, drawer, renderWindowPtr]() {
sceneView->clearAnomalies();
auto* ca = drawer->colAnalysis();
const int mode = ca->anomalyFilterMode();
if (mode == 3) { // 全部隐藏
ca->setAnomalies({});
renderWindowPtr->Render();
return;
}
std::string key; // 空 = 全部
if (mode != 0) { // 随GS/随数据集 → 当前活动体
key = sceneView->currentVolumeDsId();
if (key.empty()) { // 无活动体 → 空
ca->setAnomalies({});
renderWindowPtr->Render();
return;
}
}
std::vector<geopro::core::Anomaly> set;
scene3dRepo->loadAnomalyTree(
key,
[&set](geopro::data::I3dSceneRepository::AnomalyTree tree) {
for (auto& b : tree.bodies)
for (auto& a : b.members) set.push_back(a);
for (auto& a : tree.loose) set.push_back(a);
},
[](const std::string&) {});
for (const auto& a : set) sceneView->addAnomaly(a);
ca->setAnomalies(set); // 填充列表(每条显隐勾选默认显示)
renderWindowPtr->Render(); // 必须重绘clear+addAnomaly 改了 prop否则 VTK 不刷新(与列表脱节)
};
// 体素变化(重建/清场)后把体素 image 推给 InteractionManager切片基底并调和已保存切片 + 异常。
sceneView->onVolumeChanged = [interactionMgr, sceneView, syncSlices, refreshAnomalies]() {
if (sceneView->hasVolume())
interactionMgr->setVolumeImage(sceneView->currentVolumeImage(),
sceneView->currentColorScale(), sceneView->currentVmin(),
sceneView->currentVmax());
else
interactionMgr->setVolumeImage(nullptr, sceneView->currentColorScale(), 0.0, 0.0);
syncSlices(); // 体到场/移除后重建当前体下已勾选的切片
refreshAnomalies(); // 同步重载异常 actor + 刷新异常列表
};
// ── 三栏抽屉信号 → 控制器/交互Task 7 接线)──────────────────────────────
@ -369,7 +445,8 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
auto lastAnalysisRows = std::make_shared<std::vector<geopro::data::DsRow>>();
auto refreshAnalysis = [drawer, scene3dRepo, lastAnalysisRows]() {
std::vector<geopro::data::DsRow> rows = *lastAnalysisRows;
for (auto& vr : scene3dRepo->volumeRows()) rows.push_back(std::move(vr));
for (auto& vr : scene3dRepo->volumeRows()) rows.push_back(std::move(vr)); // 客户端三维体
for (auto& sr : scene3dRepo->sliceRows()) rows.push_back(std::move(sr)); // 已保存切片(挂父体下)
drawer->colAnalysis()->setDatasets(rows);
};
@ -383,6 +460,167 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
sceneCtrl->setCheckedDatasets(all);
};
// ── VTK 视图切片右键菜单(设计 §2.3)──────────────────────────────────────
// 右键命中切片 → InteractionManager 选中并回调本 lambda → 弹菜单QCursor 处定位)。
// 保存=按"未保存/已保存"分派(新建+链接+自动勾选 / 覆盖位姿)导出统一为「导出▸图片·dat」
// 正视/翻转/关闭=接现有交互(关闭已保存切片→onSliceClosed 取消列表勾选);创建异常=占位(#4)。
interactionMgr->onSliceContextMenuRequested =
[&window, interactionMgr, sceneView, scene3dRepo, refreshAnalysis, refreshAnomalies, drawer,
anomalyDrawTool, renderWindowPtr]() {
QMenu menu(&window);
QAction* aAnomaly = menu.addAction(QStringLiteral("创建异常"));
QAction* aSave = menu.addAction(QStringLiteral("保存"));
QMenu* expMenu = menu.addMenu(QStringLiteral("导出"));
QAction* aImg = expMenu->addAction(QStringLiteral("图片"));
QAction* aDat = expMenu->addAction(QStringLiteral("dat"));
menu.addSeparator();
QAction* aFace = menu.addAction(QStringLiteral("正视图"));
QAction* aFlip = menu.addAction(QStringLiteral("视图翻转"));
QAction* aClose = menu.addAction(QStringLiteral("关闭"));
QAction* chosen = menu.exec(QCursor::pos());
if (chosen == nullptr) return;
if (chosen == aFace) { interactionMgr->faceSelected(); return; }
if (chosen == aFlip) { interactionMgr->flipView(); return; }
if (chosen == aClose) { interactionMgr->closeSelected(); return; } // →onSliceClosed→取消列表勾选
if (chosen == aAnomaly) {
// 在选中切片平面上启动圈定(左键逐点、右键/回车闭合、Esc 取消)。
namespace ri = geopro::render::interact;
int axis = 3;
ri::Vec3 o{}, p1{}, p2{};
if (!interactionMgr->selectedSlicePlane(axis, o, p1, p2)) return;
const ri::Vec3 e1{{p1[0] - o[0], p1[1] - o[1], p1[2] - o[2]}};
const ri::Vec3 e2{{p2[0] - o[0], p2[1] - o[1], p2[2] - o[2]}};
const ri::Vec3 normal = ri::normalize(ri::cross(e1, e2));
const std::string volId = sceneView->currentVolumeDsId();
anomalyDrawTool->start(
o, normal,
[&window, sceneView, scene3dRepo, renderWindowPtr, refreshAnomalies, volId,
normal, o](const std::vector<ri::Vec3>& worldPts) {
// 草稿异常:先临时渲染(让用户在对话框前看到所画,且截图含异常)。
geopro::core::Anomaly a;
a.markType = geopro::core::AnomalyMarkType::Polygon;
a.volumeDsId = volId;
a.lineColor = "#ff3030";
a.lineWidth = 2.0;
a.dashed = false;
a.planeNormal = {normal[0], normal[1], normal[2]};
a.planeOrigin = {o[0], o[1], o[2]};
for (const auto& p : worldPts) a.worldPts.push_back({p[0], p[1], p[2]});
const std::string draftId = "draft-anomaly";
a.id = draftId;
sceneView->addAnomaly(a);
renderWindowPtr->Render();
// 截图(含异常)→ 临时文件。
const QString shot =
QDir(QDir::tempPath()).filePath(QStringLiteral("geopro_anomaly_shot.png"));
int sw = 0, sh = 0;
geopro::app::captureRenderWindowPng(renderWindowPtr, shot.toStdString(), sw, sh);
geopro::app::AnomalySaveDialog dlg(shot, sw, sh, &window);
if (dlg.exec() != QDialog::Accepted) {
sceneView->removeAnomaly(draftId);
renderWindowPtr->Render();
return;
}
a.id.clear(); // 让仓储生成真实 id
a.name = dlg.anomalyName().toStdString();
a.typeName = dlg.typeName().toStdString();
a.exceptionTypeId = dlg.typeId().toStdString();
a.remark = dlg.remark().toStdString();
scene3dRepo->saveAnomaly(
a, shot.toStdString(),
[sceneView, renderWindowPtr, refreshAnomalies, draftId](std::string) {
sceneView->removeAnomaly(draftId); // 撤草稿
refreshAnomalies(); // 重渲染 + 刷新异常列表(含新异常)
renderWindowPtr->Render();
},
[&window](const std::string& m) {
QMessageBox::warning(&window, QStringLiteral("保存异常"),
QString::fromStdString(m));
});
},
[]() { /* onCancel放弃无需处理 */ });
return;
}
if (chosen == aSave) {
int axis = 3;
geopro::render::interact::Vec3 o{}, p1{}, p2{};
if (!interactionMgr->selectedSlicePlane(axis, o, p1, p2)) return;
geopro::data::I3dSceneRepository::SliceSpec spec;
spec.volumeDsId = sceneView->currentVolumeDsId();
spec.axis = axis;
spec.origin = o;
spec.point1 = p1;
spec.point2 = p2;
const std::string existingId = interactionMgr->selectedSliceDsId();
if (!existingId.empty()) {
// 已保存切片 → 覆盖更新当前位姿(同一「保存」按钮按状态分派)。
scene3dRepo->saveSlice(existingId, spec, []() {},
[&window](const std::string& m) {
QMessageBox::warning(&window, QStringLiteral("保存切片"),
QString::fromStdString(m));
});
return;
}
// 未保存切片 → 新建 dd_slice + 链接当前切片(不重绘) + 列表自动展开勾选(去重不重复)。
if (spec.volumeDsId.empty()) {
QMessageBox::warning(&window, QStringLiteral("保存切片"),
QStringLiteral("当前切片无所属三维体,无法保存。"));
return;
}
bool ok = false;
const QString name = QInputDialog::getText(&window, QStringLiteral("保存切片"),
QStringLiteral("切片名称"),
QLineEdit::Normal,
QStringLiteral("切片"), &ok);
if (!ok) return;
scene3dRepo->createSlice(
spec, name.isEmpty() ? std::string("切片") : name.toStdString(),
[interactionMgr, refreshAnalysis, drawer](std::string newId) {
interactionMgr->tagSelectedSlice(newId); // 链接当前切片 → 新数据集(不重绘)
refreshAnalysis(); // 新行进列表(勾选集不变→不发多余信号)
drawer->colAnalysis()->setItemChecked(QString::fromStdString(newId),
true); // 自动展开+勾选(syncSlices 去重)
},
[&window](const std::string& m) {
QMessageBox::warning(&window, QStringLiteral("保存切片"),
QString::fromStdString(m));
});
return;
}
if (chosen == aImg) {
vtkSmartPointer<vtkImageData> colorImg = interactionMgr->selectedSliceColorImage();
if (colorImg == nullptr) {
QMessageBox::warning(&window, QStringLiteral("导出"),
QStringLiteral("无选中切片或切片无数据。"));
return;
}
const QString path = QFileDialog::getSaveFileName(
&window, QStringLiteral("导出为图片"), QStringLiteral("slice.png"),
QStringLiteral("PNG 图片 (*.png)"));
if (!path.isEmpty() &&
!geopro::app::exportSliceImagePng(colorImg, path.toStdString()))
QMessageBox::warning(&window, QStringLiteral("导出"), QStringLiteral("导出失败。"));
return;
}
if (chosen == aDat) {
vtkImageData* img = interactionMgr->selectedSliceImage();
if (img == nullptr) return;
const QString path = QFileDialog::getSaveFileName(
&window, QStringLiteral("导出到 dat"), QStringLiteral("slice.dat"),
QStringLiteral("数据文件 (*.dat)"));
if (!path.isEmpty() &&
!geopro::app::exportSliceDat(img, path.toStdString()))
QMessageBox::warning(&window, QStringLiteral("导出"), QStringLiteral("导出失败。"));
return;
}
};
// 关闭已保存切片(VTK 视图「关闭」) → 取消三维分析栏对应勾选(场景↔列表双向同步)。
interactionMgr->onSliceClosed = [drawer](const std::string& dsId) {
drawer->colAnalysis()->setItemChecked(QString::fromStdString(dsId), false);
};
QObject::connect(c3, &geopro::app::Column3DDataset::axesModeChanged, sceneCtrl,
&geopro::controller::VtkSceneController::setAxesMode);
QObject::connect(c3, &geopro::app::Column3DDataset::axesUnitChanged, sceneCtrl,
@ -424,19 +662,159 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
});
auto* ca = drawer->colAnalysis();
// 三维分析栏勾选(三维体/切片)→ 并入渲染勾选集(体走体素路径,由 isVolumeDataset 分流)。
// 三维分析栏勾选(三维体/切片):体走控制器体素路径;切片(dd_slice)不进控制器(否则 loadSection
// 会对 slice id 失败),单独经 syncSlices 在父体上还原渲染。
QObject::connect(ca, &geopro::app::Column3DAnalysis::checkedItemsChanged, sceneCtrl,
[checkedAnalysis, pushChecked](const QStringList& ids) {
*checkedAnalysis = ids;
pushChecked();
[checkedAnalysis, pushChecked, checkedSliceIds, syncSlices,
scene3dRepo](const QStringList& ids) {
QStringList nonSlice;
checkedSliceIds->clear();
for (const QString& id : ids) {
const std::string s = id.toStdString();
if (scene3dRepo->isSliceDataset(s))
checkedSliceIds->insert(s);
else
nonSlice << id;
}
*checkedAnalysis = nonSlice;
pushChecked(); // 体/其它 → 控制器(增删图元,可能触发 onVolumeChanged→syncSlices
syncSlices(); // 切片勾选变化即时调和(父体已在场时立即显隐)
});
QObject::connect(ca, &geopro::app::Column3DAnalysis::sliceRequested, vtkWidget,
[interactionMgr](geopro::render::interact::SliceAxis axis) {
interactionMgr->addSlice(axis);
});
QObject::connect(ca, &geopro::app::Column3DAnalysis::detailRequested, &detailCtrl,
[&detailCtrl](const QString& dsId, const QString& ddCode, const QString& name) {
detailCtrl.openDataset(dsId, ddCode, name);
// 三维分析栏「数据详情」项非体即切片dd_slice / dd_voxel按 ddCode 分派到只读属性
// 对话框(仿异常详情)。数据直接从具体 scene3dRepo 取(体/切片在 3D 仓储,非 detailCtrl 的 2D 管线)。
QObject::connect(ca, &geopro::app::Column3DAnalysis::detailRequested, &window,
[&window, scene3dRepo](const QString& dsId, const QString& ddCode,
const QString& name) {
if (ddCode == QStringLiteral("dd_slice")) {
geopro::data::I3dSceneRepository::SliceSpec sp;
if (scene3dRepo->sliceSpec(dsId.toStdString(), sp)) {
geopro::app::SlicePropertiesDialog dlg(name, sp, &window);
dlg.exec();
}
} else { // dd_voxel三维体
geopro::data::Api3dRepository::VolumeInfo info;
if (scene3dRepo->volumeInfo(dsId.toStdString(), info)) {
geopro::app::VolumePropertiesDialog dlg(name, info, &window);
dlg.exec();
}
}
});
// 三维分析栏切片右键「删除」→ 删除 mock 切片 + 刷新列表(若在渲染,删后行消失→取消勾选→自动移除图元)。
QObject::connect(ca, &geopro::app::Column3DAnalysis::sliceDeleteRequested, &window,
[scene3dRepo, refreshAnalysis](const QString& dsId) {
scene3dRepo->deleteSlice(
dsId.toStdString(), [refreshAnalysis]() { refreshAnalysis(); },
[](const std::string&) {});
});
// 列表切片「保存」=把当前(可能被拖动过的)位姿覆盖更新到该 dd_slice须该切片正在渲染才有位姿可取。
QObject::connect(ca, &geopro::app::Column3DAnalysis::sliceSaveRequested, &window,
[&window, interactionMgr, scene3dRepo, sceneView](const QString& dsId) {
if (!interactionMgr->selectSavedSlice(dsId.toStdString())) {
QMessageBox::information(&window, QStringLiteral("保存"),
QStringLiteral("请先勾选该切片渲染后再保存其位姿。"));
return;
}
int axis = 3;
geopro::render::interact::Vec3 o{}, p1{}, p2{};
interactionMgr->selectedSlicePlane(axis, o, p1, p2);
geopro::data::I3dSceneRepository::SliceSpec spec;
spec.volumeDsId = sceneView->currentVolumeDsId();
spec.axis = axis;
spec.origin = o;
spec.point1 = p1;
spec.point2 = p2;
scene3dRepo->saveSlice(dsId.toStdString(), spec, []() {},
[](const std::string&) {});
});
// 列表切片「保存为」=以该切片当前(存储)位姿另存为新 dd_slice不依赖渲染
QObject::connect(ca, &geopro::app::Column3DAnalysis::sliceSaveAsRequested, &window,
[&window, scene3dRepo, refreshAnalysis](const QString& dsId) {
geopro::data::I3dSceneRepository::SliceSpec spec;
if (!scene3dRepo->sliceSpec(dsId.toStdString(), spec)) return;
bool ok = false;
const QString name = QInputDialog::getText(
&window, QStringLiteral("保存为"), QStringLiteral("新切片名称"),
QLineEdit::Normal, QStringLiteral("切片副本"), &ok);
if (!ok) return;
scene3dRepo->createSlice(
spec, name.isEmpty() ? std::string("切片副本") : name.toStdString(),
[refreshAnalysis](std::string) { refreshAnalysis(); },
[](const std::string&) {});
});
// 列表切片「导出▸图片」:定位到渲染中的该切片 → 导出其上色 2D 图。
QObject::connect(ca, &geopro::app::Column3DAnalysis::sliceExportImageRequested, &window,
[&window, interactionMgr](const QString& dsId) {
if (!interactionMgr->selectSavedSlice(dsId.toStdString())) {
QMessageBox::information(&window, QStringLiteral("导出"),
QStringLiteral("请先勾选该切片渲染后再导出。"));
return;
}
vtkSmartPointer<vtkImageData> img = interactionMgr->selectedSliceColorImage();
if (img == nullptr) return;
const QString path = QFileDialog::getSaveFileName(
&window, QStringLiteral("导出为图片"), QStringLiteral("slice.png"),
QStringLiteral("PNG 图片 (*.png)"));
if (!path.isEmpty() &&
!geopro::app::exportSliceImagePng(img, path.toStdString()))
QMessageBox::warning(&window, QStringLiteral("导出"),
QStringLiteral("导出失败。"));
});
// 列表切片「导出▸dat」定位到渲染中的该切片 → 导出其重采样标量网格。
QObject::connect(ca, &geopro::app::Column3DAnalysis::sliceExportDatRequested, &window,
[&window, interactionMgr](const QString& dsId) {
if (!interactionMgr->selectSavedSlice(dsId.toStdString())) {
QMessageBox::information(&window, QStringLiteral("导出"),
QStringLiteral("请先勾选该切片渲染后再导出。"));
return;
}
vtkImageData* img = interactionMgr->selectedSliceImage();
if (img == nullptr) return;
const QString path = QFileDialog::getSaveFileName(
&window, QStringLiteral("导出到 dat"), QStringLiteral("slice.dat"),
QStringLiteral("数据文件 (*.dat)"));
if (!path.isEmpty() &&
!geopro::app::exportSliceDat(img, path.toStdString()))
QMessageBox::warning(&window, QStringLiteral("导出"),
QStringLiteral("导出失败。"));
});
// 色阶(三维体/切片):本期占位。
QObject::connect(ca, &geopro::app::Column3DAnalysis::colorScaleRequested, &window,
[&window](const QString&) {
QMessageBox::information(&window, QStringLiteral("色阶"),
QStringLiteral("色阶设置开发中。"));
});
// ── 3D 异常控制(#4c显示过滤 / 单条显隐 / 删除 → 驱动 VTK 异常渲染 ──────────
// 过滤档位变化 → 重算异常集合并重渲染 + 刷新列表(独立于体勾选)。
QObject::connect(ca, &geopro::app::Column3DAnalysis::anomalyDisplayFilterChanged, vtkWidget,
[refreshAnomalies](int) { refreshAnomalies(); });
// 单条显隐 → 切该异常 actor 可见性。
QObject::connect(ca, &geopro::app::Column3DAnalysis::anomalyVisibilityChanged, vtkWidget,
[sceneView, renderWindowPtr](const QString& id, bool vis) {
sceneView->setAnomalyVisible(id.toStdString(), vis);
renderWindowPtr->Render();
});
// 列表选中异常 → VTK 高亮联动R84list→VTK
QObject::connect(ca, &geopro::app::Column3DAnalysis::anomalySelected, vtkWidget,
[sceneView](const QString& id) {
sceneView->setSelectedAnomaly(id.toStdString());
});
// 双击异常 → 只读属性对话框R83名称/类型/标记/归属/坐标/备注)。
QObject::connect(ca, &geopro::app::Column3DAnalysis::anomalyPropertiesRequested, &window,
[&window](const geopro::core::Anomaly& a) {
geopro::app::AnomalyPropertiesDialog dlg(a, &window);
dlg.exec();
});
// 删除异常 → 删 mock + 刷新渲染/列表。
QObject::connect(ca, &geopro::app::Column3DAnalysis::anomalyDeleteRequested, &window,
[scene3dRepo, refreshAnomalies](const QString& id) {
scene3dRepo->deleteAnomaly(
id.toStdString(), [refreshAnomalies]() { refreshAnomalies(); },
[](const std::string&) {});
});
// ── 二维数据集栏:天地图底图开关(③,复用轨迹图 token经同一共享 GeoLocalFrame 配准)──

View File

@ -1,7 +1,13 @@
#include "panels/columns/Column3DAnalysis.hpp"
#include <QComboBox>
#include <QGroupBox>
#include <QHBoxLayout>
#include <QLabel>
#include <QMenu>
#include <QSet>
#include <QSignalBlocker>
#include <QSplitter>
#include <QTreeWidget>
#include <QTreeWidgetItem>
#include <QTreeWidgetItemIterator>
@ -34,24 +40,132 @@ Column3DAnalysis::Column3DAnalysis(QWidget* parent) : QWidget(parent) {
emit checkedItemsChanged(ids);
});
root->addWidget(tree_, 1);
// ── 数据集树(上) + 「异常」分组(下) 放进竖向 Splitter可拖拽、清晰分隔数据集树占多数 ──
// ── 3D 异常控制(#4c分组框内含 显示过滤下拉 + 异常列表(每条显隐勾选;选中联动 VTK──
anomalyTree_ = new QTreeWidget();
anomalyTree_->setHeaderHidden(true);
anomalyTree_->setRootIsDecorated(false);
anomalyTree_->setContextMenuPolicy(Qt::CustomContextMenu);
connect(anomalyTree_, &QTreeWidget::customContextMenuRequested, this,
&Column3DAnalysis::onAnomalyContextMenu);
connect(anomalyTree_, &QTreeWidget::itemChanged, this, [this](QTreeWidgetItem* it, int) {
if (it == nullptr) return;
emit anomalyVisibilityChanged(it->data(0, kDsIdRole).toString(),
it->checkState(0) == Qt::Checked);
});
connect(anomalyTree_, &QTreeWidget::currentItemChanged, this,
[this](QTreeWidgetItem* cur, QTreeWidgetItem*) {
if (cur != nullptr) emit anomalySelected(cur->data(0, kDsIdRole).toString());
});
// 双击异常项 → 属性对话框R83按 id 回查当前集合发出整条异常。
connect(anomalyTree_, &QTreeWidget::itemDoubleClicked, this,
[this](QTreeWidgetItem* it, int) {
if (it == nullptr) return;
const QString id = it->data(0, kDsIdRole).toString();
for (const auto& a : anomalies_)
if (QString::fromStdString(a.id) == id) {
emit anomalyPropertiesRequested(a);
return;
}
});
auto* anomGroup = new QGroupBox(QStringLiteral("异常"));
auto* gv = new QVBoxLayout(anomGroup);
gv->setContentsMargins(space::kSm, space::kSm, space::kSm, space::kSm);
gv->setSpacing(space::kSm);
{
auto* fr = new QHBoxLayout();
fr->addWidget(new QLabel(QStringLiteral("显示")));
anomalyFilter_ = new QComboBox();
anomalyFilter_->addItem(QStringLiteral("全部显示")); // 0
anomalyFilter_->addItem(QStringLiteral("随GS")); // 1
anomalyFilter_->addItem(QStringLiteral("随数据集")); // 2
anomalyFilter_->addItem(QStringLiteral("全部隐藏")); // 3
anomalyFilter_->setCurrentIndex(2); // 默认随数据集(= 跟当前三维体显隐)
connect(anomalyFilter_, qOverload<int>(&QComboBox::currentIndexChanged), this,
[this](int idx) { emit anomalyDisplayFilterChanged(idx); });
fr->addWidget(anomalyFilter_, 1);
gv->addLayout(fr);
}
gv->addWidget(anomalyTree_, 1);
auto* splitter = new QSplitter(Qt::Vertical);
splitter->setChildrenCollapsible(false);
splitter->addWidget(tree_);
splitter->addWidget(anomGroup);
splitter->setStretchFactor(0, 3); // 数据集树占多
splitter->setStretchFactor(1, 2);
root->addWidget(splitter, 1);
}
int Column3DAnalysis::anomalyFilterMode() const {
return anomalyFilter_ ? anomalyFilter_->currentIndex() : 2;
}
void Column3DAnalysis::setAnomalies(const std::vector<geopro::core::Anomaly>& anoms) {
anomalies_ = anoms; // 留存供双击查属性R83
QSignalBlocker block(anomalyTree_); // 填充不触发 visibilityChanged
anomalyTree_->clear();
for (const auto& a : anoms) {
auto* item = new QTreeWidgetItem(anomalyTree_);
const QString name = a.name.empty() ? QStringLiteral("异常") : QString::fromStdString(a.name);
const QString type = a.typeName.empty() ? QString() : QString::fromStdString(a.typeName);
item->setText(0, type.isEmpty() ? name : QStringLiteral("%1%2").arg(name, type));
item->setData(0, kDsIdRole, QString::fromStdString(a.id));
item->setFlags(item->flags() | Qt::ItemIsUserCheckable);
item->setCheckState(0, Qt::Checked); // 默认显示
}
}
void Column3DAnalysis::onAnomalyContextMenu(const QPoint& pos) {
QTreeWidgetItem* it = anomalyTree_->itemAt(pos);
if (it == nullptr) return;
const QString id = it->data(0, kDsIdRole).toString();
QMenu menu(this);
menu.addAction(QStringLiteral("删除异常"), this, [this, id] { emit anomalyDeleteRequested(id); });
menu.exec(anomalyTree_->viewport()->mapToGlobal(pos));
}
void Column3DAnalysis::setDatasets(const std::vector<geopro::data::DsRow>& rows) {
// 按 dsId 保留刷新前的勾选态:列表重建(保存切片/生成体追加一行也会整树重建)不应丢已勾选项
// 的渲染态——否则保存切片会连带取消三维体勾选、把它从场景移除(实测 bug
// 切换测线(新数据)时旧 id 不匹配 → 自然全空,行为与原先一致。
QSet<QString> wasChecked;
for (QTreeWidgetItemIterator it(tree_); *it; ++it)
if ((*it)->checkState(0) == Qt::Checked)
wasChecked.insert((*it)->data(0, kDsIdRole).toString());
{
QSignalBlocker blocker(tree_);
populateDatasetList(tree_, rows, /*append=*/false);
for (QTreeWidgetItemIterator it(tree_); *it; ++it) {
(*it)->setFlags((*it)->flags() | Qt::ItemIsUserCheckable);
(*it)->setCheckState(0, Qt::Unchecked);
const QString id = (*it)->data(0, kDsIdRole).toString();
(*it)->setCheckState(0, wasChecked.contains(id) ? Qt::Checked : Qt::Unchecked);
}
} // blocker released here
// 填充后统一发一次(新载入必为空选):清掉上一次的渲染勾选
// 仅当勾选集真正变化才发信号:重建但勾选集不变(如保存切片仅追加一行)→ 不发,
// 避免下游 syncSlices 用"尚未勾选新切片"的中间态误隐藏刚链接的切片(闪烁/重复)。
QStringList ids;
QSet<QString> nowChecked;
for (QTreeWidgetItemIterator it(tree_); *it; ++it)
if ((*it)->checkState(0) == Qt::Checked)
ids << (*it)->data(0, kDsIdRole).toString();
emit checkedItemsChanged(ids);
if ((*it)->checkState(0) == Qt::Checked) {
const QString id = (*it)->data(0, kDsIdRole).toString();
ids << id;
nowChecked.insert(id);
}
if (nowChecked != wasChecked) emit checkedItemsChanged(ids);
}
void Column3DAnalysis::setItemChecked(const QString& dsId, bool checked) {
for (QTreeWidgetItemIterator it(tree_); *it; ++it) {
if ((*it)->data(0, kDsIdRole).toString() != dsId) continue;
for (QTreeWidgetItem* p = (*it)->parent(); p != nullptr; p = p->parent())
p->setExpanded(true); // 展开父链 → 新勾选行可见
// setCheckState 仅在状态变化时发 itemChanged → checkedItemsChanged驱动渲染同步
(*it)->setCheckState(0, checked ? Qt::Checked : Qt::Unchecked);
return;
}
}
void Column3DAnalysis::onContextMenu(const QPoint& pos) {
@ -65,7 +179,8 @@ void Column3DAnalysis::onContextMenu(const QPoint& pos) {
QMenu menu(this);
if (!isSlice) {
// 三维体数据集:切片▸(上下/前后/左右/任意) / 色阶 / 显示·隐藏 / 数据详情
// 三维体数据集:切片▸(上下/前后/左右/任意) / 色阶 / 数据详情。
// 显示/隐藏 = 勾选框,故菜单不再重复提供(去冗余)。
QMenu* sub = menu.addMenu(QStringLiteral("切片"));
using SA = geopro::render::interact::SliceAxis;
sub->addAction(QStringLiteral("上下"), this, [this]{ emit sliceRequested(SA::UpDown); });
@ -73,17 +188,18 @@ void Column3DAnalysis::onContextMenu(const QPoint& pos) {
sub->addAction(QStringLiteral("左右"), this, [this]{ emit sliceRequested(SA::LeftRight); });
sub->addAction(QStringLiteral("任意"), this, [this]{ emit sliceRequested(SA::Oblique); });
menu.addAction(QStringLiteral("色阶"), this, [this, dsId]{ emit colorScaleRequested(dsId); });
menu.addAction(QStringLiteral("显示 / 隐藏"), this, [this, dsId]{ emit visibilityToggled(dsId); });
menu.addAction(QStringLiteral("数据详情"), this, [this, dsId, ddCode, name]{ emit detailRequested(dsId, ddCode, name); });
} else {
// 切片数据集:保存/保存为/导出/删除 — 色阶/显示·隐藏/数据详情
// 切片数据集:保存(覆盖位姿) / 保存为(另存新切片) / 导出▸(图片·dat) / 删除 / 色阶 / 数据详情。
// 显示/隐藏 = 勾选框,去冗余。导出与 VTK 视图切片右键统一为二级菜单。
menu.addAction(QStringLiteral("保存"), this, [this, dsId]{ emit sliceSaveRequested(dsId); });
menu.addAction(QStringLiteral("保存为"), this, [this, dsId]{ emit sliceSaveAsRequested(dsId); });
menu.addAction(QStringLiteral("导出"), this, [this, dsId]{ emit sliceExportRequested(dsId); });
QMenu* exp = menu.addMenu(QStringLiteral("导出"));
exp->addAction(QStringLiteral("图片"), this, [this, dsId]{ emit sliceExportImageRequested(dsId); });
exp->addAction(QStringLiteral("dat"), this, [this, dsId]{ emit sliceExportDatRequested(dsId); });
menu.addAction(QStringLiteral("删除"), this, [this, dsId]{ emit sliceDeleteRequested(dsId); });
menu.addSeparator();
menu.addAction(QStringLiteral("色阶"), this, [this, dsId]{ emit colorScaleRequested(dsId); });
menu.addAction(QStringLiteral("显示 / 隐藏"), this, [this, dsId]{ emit visibilityToggled(dsId); });
menu.addAction(QStringLiteral("数据详情"), this, [this, dsId, ddCode, name]{ emit detailRequested(dsId, ddCode, name); });
}

View File

@ -3,35 +3,53 @@
#include <QStringList>
#include <vector>
#include "repo/RepoTypes.hpp"
#include "model/Anomaly.hpp"
#include "interact/SlicePlaneMath.hpp" // SliceAxis
class QTreeWidget;
class QComboBox;
class QPoint;
namespace geopro::app {
// 三维分析栏:对象→三维体模型→切片 树 + 三维体/切片两套右键菜单
// 三维分析栏:对象→三维体模型→切片 树 + 三维体/切片两套右键菜单 + 3D 异常控制(列表/过滤/显隐)
class Column3DAnalysis : public QWidget {
Q_OBJECT
public:
explicit Column3DAnalysis(QWidget* parent = nullptr);
// 本期:按 ds parentId 建树(切片挂源数据下);完整 对象→三维体→切片 三级树待后端数据(P4)。
void setDatasets(const std::vector<geopro::data::DsRow>& rows); // Analysis 维度(三维体/切片)
// 程序化勾选某 dsId 的行(保存切片后自动勾选新行)+ 展开其父节点使可见。
void setItemChecked(const QString& dsId, bool checked);
// 3D 异常列表(#4c每条带显隐勾选选中联动 VTK。anoms 为当前应展示的异常集合。
void setAnomalies(const std::vector<geopro::core::Anomaly>& anoms);
// 当前显示过滤档位0全部显示/1随GS/2随数据集/3全部隐藏
int anomalyFilterMode() const;
signals:
void sliceRequested(geopro::render::interact::SliceAxis axis); // 三维体右键 切片▸(上下/前后/左右/任意)
void colorScaleRequested(const QString& dsId);
void visibilityToggled(const QString& dsId);
void detailRequested(const QString& dsId, const QString& ddCode, const QString& name);
void sliceSaveRequested(const QString& dsId);
void sliceSaveAsRequested(const QString& dsId);
void sliceExportRequested(const QString& dsId);
void sliceExportImageRequested(const QString& dsId); // 导出▸图片
void sliceExportDatRequested(const QString& dsId); // 导出▸dat
void sliceDeleteRequested(const QString& dsId);
void checkedItemsChanged(const QStringList& dsIds);
// ── 异常(#4c──
void anomalyVisibilityChanged(const QString& anomalyId, bool visible); // 单条显隐勾选
void anomalyDisplayFilterChanged(int mode); // 过滤档位 0..3
void anomalySelected(const QString& anomalyId); // 列表选中→VTK 高亮
void anomalyDeleteRequested(const QString& anomalyId); // 右键删除
void anomalyPropertiesRequested(const geopro::core::Anomaly& a); // 双击→属性对话框(R83)
private:
void onContextMenu(const QPoint& pos);
void onAnomalyContextMenu(const QPoint& pos);
QTreeWidget* tree_ = nullptr;
QTreeWidget* anomalyTree_ = nullptr;
QComboBox* anomalyFilter_ = nullptr;
std::vector<geopro::core::Anomaly> anomalies_; // 当前展示集合(双击查属性按 id 回查)
};
} // namespace geopro::app

View File

@ -5,6 +5,7 @@
#include <QAbstractItemView>
#include <QAction>
#include <QComboBox>
#include <QDebug>
#include <QFormLayout>
#include <QHBoxLayout>
#include <QLabel>
@ -116,11 +117,10 @@ Column3DDataset::Column3DDataset(QWidget* parent) : QWidget(parent) {
root->addLayout(row);
}
// 数据集列表(可勾选 = 渲染选择;多选高亮 + 右键 = 生成三维体的源选择,两者独立)
// 数据集列表(可勾选)。勾选 = 渲染为帘面,同时是「生成三维体」的源集合(右键菜单据勾选集生成)。
list_ = new QTreeWidget();
list_->setHeaderHidden(true);
list_->setRootIsDecorated(true);
list_->setSelectionMode(QAbstractItemView::ExtendedSelection); // Ctrl/Shift 多选源剖面
list_->setContextMenuPolicy(Qt::CustomContextMenu);
applyDatasetCardDelegate(list_);
connect(list_, &QTreeWidget::itemChanged, this, [this](QTreeWidgetItem*, int) {
@ -137,15 +137,18 @@ Column3DDataset::Column3DDataset(QWidget* parent) : QWidget(parent) {
}
void Column3DDataset::showListContextMenu(const QPoint& pos) {
// 收集选中项中"可作三维体源"的数据集(反演剖面类)。
// 按**勾选集合**收集"可作三维体源"的数据集(反演剖面类)——与右键点在哪一项无关
static const QSet<QString> kSourceDdCodes = {QStringLiteral("dd_section"),
QStringLiteral("dd_inversion_data")};
QStringList sourceIds;
for (QTreeWidgetItem* item : list_->selectedItems()) {
const QString ddCode = item->data(0, kDsDdCodeRole).toString();
for (QTreeWidgetItemIterator it(list_); *it; ++it) {
if ((*it)->checkState(0) != Qt::Checked) continue; // 仅勾选项
const QString ddCode = (*it)->data(0, kDsDdCodeRole).toString();
if (kSourceDdCodes.contains(ddCode))
sourceIds << item->data(0, kDsIdRole).toString();
sourceIds << (*it)->data(0, kDsIdRole).toString();
}
qInfo().noquote() << "[volsrc] 按勾选收集源 ds 数 =" << sourceIds.size() << ":"
<< sourceIds.join(',');
QMenu menu(this);
QAction* gen = menu.addAction(QStringLiteral("生成三维体"));

View File

@ -38,6 +38,16 @@ public:
// 增量移除某数据集的全部图元(取消勾选时调,不影响其余 ds 与底图)。
virtual void removeDataset(const std::string& dsId) = 0;
// ── 异常(#4按 anomaly id 跟踪 3D actor独立于数据集图元 ──────────────
// addAnomaly用 worldPts 建 3D 多边形/折线/点 actor 加入场景id 已在 Anomaly 内)。
// 坐标已是世界系(圈定时从切片平面取,含 VE故不再额外施加 VE 缩放。
virtual void addAnomaly(const geopro::core::Anomaly& a) = 0;
virtual void removeAnomaly(const std::string& anomalyId) = 0;
virtual void clearAnomalies() = 0;
virtual void setAnomalyVisible(const std::string& anomalyId, bool visible) = 0;
// 高亮选中的异常列表↔VTK 联动 R84选中者加粗高亮、其余恢复空 id = 全不选。
virtual void setSelectedAnomaly(const std::string& anomalyId) = 0;
// 坐标轴设置P2显示方式 + 刻度单位 + 字号。视图据当前场景包围盒重建坐标轴 prop。
// None 模式 = 移除坐标轴rebuild 时由控制器在 clear 后重新下发当前坐标轴设置。
virtual void setAxes(AxesMode mode, AxesUnit unit, int fontSize) = 0;

View File

@ -11,12 +11,18 @@
namespace geopro::core {
namespace {
// ext包络长度/ cell间距→ 网格点数,限幅 [1, kMaxVolumeDim]。
int clampDim(double ext, double cell) {
// 某轴:优先用 cell 间距;若包络 ext 过大致格数超 kMaxVolumeDim则**放大间距**使 maxDim 格跨满 ext
// (分辨率降低,但**不截断**——否则跨 TM 多剖面相距 > maxDim×cell 时,远端剖面落网格外、丢失)。
void fitAxis(double ext, double cell, double& outCell, int& outN) {
if (!(ext > 0.0) || !(cell > 0.0)) { outCell = (cell > 0.0 ? cell : 1.0); outN = 1; return; }
int n = static_cast<int>(ext / cell) + 1;
if (n < 1) n = 1;
if (n > kMaxVolumeDim) n = kMaxVolumeDim;
return n;
if (n <= kMaxVolumeDim) {
outCell = cell;
outN = (n < 1) ? 1 : n;
return;
}
outN = kMaxVolumeDim;
outCell = ext / static_cast<double>(kMaxVolumeDim - 1); // maxDim 格覆盖全 ext
}
} // namespace
@ -36,13 +42,13 @@ BuiltVolume buildVolume(const PointSet& pts, double cellXY, double cellZ,
minz = std::min(minz, pts.z[i]); maxz = std::max(maxz, pts.z[i]);
}
// 2) GridSpec角点对齐 = 原点取包络最小角)。
// 2) GridSpec角点对齐 = 原点取包络最小角)。间距优先用 cell包络过大时放大间距以覆盖全程
// fitAxis避免跨 TM 多剖面相距过远时远端被截断。
GridSpec spec{};
spec.ox = minx; spec.oy = miny; spec.oz = minz;
spec.dx = cellXY; spec.dy = cellXY; spec.dz = cellZ;
spec.nx = clampDim(maxx - minx, cellXY);
spec.ny = clampDim(maxy - miny, cellXY);
spec.nz = clampDim(maxz - minz, cellZ);
fitAxis(maxx - minx, cellXY, spec.dx, spec.nx);
fitAxis(maxy - miny, cellXY, spec.dy, spec.ny);
fitAxis(maxz - minz, cellZ, spec.dz, spec.nz);
spec.power = power;
spec.maxDist = maxDist;

View File

@ -6,12 +6,23 @@ namespace geopro::core {
enum class AnomalyMarkType { Point = 1, Polyline = 2, Polygon = 3 };
struct Vec2 { double x, y; };
struct Vec3 { double x, y, z; };
struct Anomaly {
std::string id; // 持久化 idVTK 三维按 id 跟踪 actor 显隐/选中2D 详情可空)
std::string volumeDsId; // 归属三维体 ds id= remarkSourceId异常挂三维体非切片
std::string consortiumId; // 异常体分组 id空 = 未分组/loose
std::string name;
std::string typeName; // exceptionTypeName
std::string exceptionTypeId; // 异常类型 id保存请求 exceptionTypeId
std::string remark; // 备注
AnomalyMarkType markType = AnomalyMarkType::Polyline;
std::vector<Vec2> localPts; // location.coordinate局部坐标
std::vector<Vec2> localPts; // 2D 局部坐标剖面详情x=距离, y=深度)
// VTK 三维:异常多边形/折线/点的世界 3D 坐标(落在所在切片平面上)+ 平面(法向/一点)
// 用于 3D 渲染与重定位/正视;与切片生命周期解耦(切片可删,异常按 worldPts/plane 仍可显示)。
std::vector<Vec3> worldPts;
Vec3 planeNormal{0.0, 0.0, 1.0};
Vec3 planeOrigin{0.0, 0.0, 0.0};
std::string lineColor = "#000000"; // legend.polylineColor
double lineWidth = 1.0; // legend.polylineWidth
bool dashed = true; // legend.polylineShape == "dash"

View File

@ -1,5 +1,6 @@
#include "api/Api3dRepository.hpp"
#include <QDebug>
#include <QObject>
#include <QString>
#include <QVariant>
@ -87,6 +88,29 @@ std::vector<DsRow> Api3dRepository::volumeRows() const {
return rows;
}
bool Api3dRepository::volumeInfo(const std::string& dsId, VolumeInfo& out) const {
auto it = volumes_.find(dsId);
if (it == volumes_.end()) return false;
const StoredVolume& sv = it->second;
out = VolumeInfo{};
out.params = sv.params;
out.name = sv.name;
out.loaded = sv.cachedGrid.has_value();
if (out.loaded) {
const VolumeGrid& g = *sv.cachedGrid;
out.vmin = g.vmin;
out.vmax = g.vmax;
out.nx = g.vol.nx();
out.ny = g.vol.ny();
out.nz = g.vol.nz();
out.dx = g.spacing[0];
out.dy = g.spacing[1];
out.dz = g.spacing[2];
out.pointCount = sv.pointCount.value_or(0);
}
return true;
}
void Api3dRepository::appendGridPoints(const core::Grid& g, core::PointSet& pts) const {
const int nx = g.nx(), ny = g.ny();
if (nx < 1 || ny < 1 || g.y.size() < static_cast<std::size_t>(ny)) return;
@ -133,6 +157,10 @@ void Api3dRepository::finalizeVolume(const std::string& dsId, const core::PointS
vmin = stops.front();
vmax = stops.back();
}
qInfo().noquote() << "[volbuild] finalize pts=" << pts.v.size() << "grid"
<< bv.spec.nx << "x" << bv.spec.ny << "x" << bv.spec.nz
<< "origin" << bv.spec.ox << bv.spec.oy << bv.spec.oz << "spacing"
<< bv.spec.dx << bv.spec.dy << bv.spec.dz;
VolumeGrid out{std::move(bv.vol),
{{bv.spec.ox, bv.spec.oy, bv.spec.oz}},
{{bv.spec.dx, bv.spec.dy, bv.spec.dz}},
@ -141,6 +169,7 @@ void Api3dRepository::finalizeVolume(const std::string& dsId, const core::PointS
if (it != volumes_.end()) { // 缓存明细 + 色阶(下次命中即跳重算)
it->second.cachedGrid = out;
it->second.cachedScale = scale;
it->second.pointCount = pts.v.size(); // 持久化聚合散点数(详情统计用)
}
onOk(std::move(out), scale);
} catch (const std::exception& e) {
@ -182,9 +211,14 @@ void Api3dRepository::loadVolume(const std::string& dsId,
for (const std::string& srcId : params.sourceDatasetIds) {
loadSection(
srcId,
[this, dsId, params, agg, onOk, onErr](SectionData s) {
[this, dsId, srcId, params, agg, onOk, onErr](SectionData s) {
if (agg->failed) return;
const std::size_t before = agg->pts.v.size();
appendGridPoints(s.grid, agg->pts);
qInfo().noquote() << "[volbuild] source" << QString::fromStdString(srcId)
<< "grid" << s.grid.nx() << "x" << s.grid.ny() << "-> +"
<< (agg->pts.v.size() - before) << "pts (total"
<< agg->pts.v.size() << ")";
if (!agg->haveScale) {
agg->scale = s.scale;
agg->haveScale = true;
@ -204,44 +238,108 @@ void Api3dRepository::loadTerrainPaths(std::function<void(TerrainPaths)> /*onOk*
onErr(kNotReady); // 后端地形 DEM/影像端点未就绪
}
// ── 切片 CRUD后端未就绪 → 变更走 onErr给用户明确"未实现"──────────────
// ── 切片 CRUD后端无切片端点 → 内存 mock端点就绪后换实现────────────────
void Api3dRepository::createSlice(const SliceSpec& /*spec*/, const std::string& /*name*/,
std::function<void(std::string)> /*onOk*/, OnError onErr) {
onErr(kNotReady);
std::vector<DsRow> Api3dRepository::sliceRows() const {
std::vector<DsRow> rows;
rows.reserve(slices_.size());
for (const auto& [id, ss] : slices_) {
DsRow r;
r.id = id;
r.dsName = ss.name;
r.ddCode = "dd_slice";
r.typeName = "切片";
r.parentId = ss.spec.volumeDsId; // 树中挂在所属三维体下
rows.push_back(std::move(r));
}
return rows;
}
void Api3dRepository::saveSlice(const std::string& /*dsId*/, const SliceSpec& /*spec*/,
std::function<void()> /*onOk*/, OnError onErr) {
onErr(kNotReady);
bool Api3dRepository::isSliceDataset(const std::string& dsId) const {
return slices_.find(dsId) != slices_.end();
}
void Api3dRepository::deleteSlice(const std::string& /*dsId*/, std::function<void()> /*onOk*/,
OnError onErr) {
onErr(kNotReady);
bool Api3dRepository::sliceSpec(const std::string& dsId, SliceSpec& out) const {
auto it = slices_.find(dsId);
if (it == slices_.end()) return false;
out = it->second.spec;
return true;
}
// ── 异常 / 异常体load 回空树避免 UI 崩;变更走 onErr─────────────────────
void Api3dRepository::createSlice(const SliceSpec& spec, const std::string& name,
std::function<void(std::string)> onOk, OnError /*onErr*/) {
const std::string id = "slice-" + std::to_string(++sliceCounter_);
slices_[id] = StoredSlice{spec, name};
onOk(id);
}
void Api3dRepository::loadAnomalyTree(const std::string& /*objectId*/,
void Api3dRepository::saveSlice(const std::string& dsId, const SliceSpec& spec,
std::function<void()> onOk, OnError /*onErr*/) {
auto it = slices_.find(dsId);
if (it != slices_.end()) it->second.spec = spec; // 覆盖位姿
onOk();
}
void Api3dRepository::deleteSlice(const std::string& dsId, std::function<void()> onOk,
OnError /*onErr*/) {
slices_.erase(dsId);
onOk();
}
// ── 异常 / 异常体(后端真实端点存在,但异常挂三维体、三维体仍 mock → 异常暂内存 mock
// 挂载结构按"异常→三维体",整链端点就绪后切真实,见记忆 vtk-3d-persistence-structure──
void Api3dRepository::loadAnomalyTree(const std::string& volumeDsId,
std::function<void(AnomalyTree)> onOk, OnError /*onErr*/) {
onOk(AnomalyTree{}); // 后端未就绪 → 空树
// 按归属三维体过滤;按 consortiumId 分组(异常体),空 consortiumId → loose(未分组)。
AnomalyTree tree;
std::map<std::string, std::size_t> bodyIndex; // consortiumId → tree.bodies 下标
for (const auto& [id, sa] : anomalies_) {
if (!volumeDsId.empty() && sa.a.volumeDsId != volumeDsId) continue;
if (sa.a.consortiumId.empty()) {
tree.loose.push_back(sa.a);
continue;
}
auto it = bodyIndex.find(sa.a.consortiumId);
if (it == bodyIndex.end()) {
it = bodyIndex.emplace(sa.a.consortiumId, tree.bodies.size()).first;
AnomalyBody body;
body.id = sa.a.consortiumId;
body.name = sa.a.consortiumId; // mock名同 id真实异常体有独立 name/typeName
tree.bodies.push_back(std::move(body));
}
tree.bodies[it->second].members.push_back(sa.a);
}
onOk(std::move(tree));
}
void Api3dRepository::saveAnomaly(const geopro::core::Anomaly& /*a*/,
const std::string& /*screenshotPngPath*/,
std::function<void(std::string)> /*onOk*/, OnError onErr) {
onErr(kNotReady);
void Api3dRepository::saveAnomaly(const geopro::core::Anomaly& a,
const std::string& screenshotPngPath,
std::function<void(std::string)> onOk, OnError /*onErr*/) {
std::string id = a.id;
if (id.empty()) id = "anomaly-" + std::to_string(++anomalyCounter_); // 新建 → 生成 id
geopro::core::Anomaly stored = a;
stored.id = id;
anomalies_[id] = StoredAnomaly{std::move(stored), screenshotPngPath};
onOk(id);
}
void Api3dRepository::deleteAnomaly(const std::string& /*anomalyId*/,
std::function<void()> /*onOk*/, OnError onErr) {
onErr(kNotReady);
void Api3dRepository::deleteAnomaly(const std::string& anomalyId, std::function<void()> onOk,
OnError /*onErr*/) {
anomalies_.erase(anomalyId);
onOk();
}
void Api3dRepository::deleteAnomalyGroup(const std::string& /*bodyId*/,
std::function<void()> /*onOk*/, OnError onErr) {
onErr(kNotReady);
void Api3dRepository::deleteAnomalyGroup(const std::string& bodyId, std::function<void()> onOk,
OnError /*onErr*/) {
// 删除该异常体分组下所有异常mockconsortiumId == bodyId 的全删)。
for (auto it = anomalies_.begin(); it != anomalies_.end();) {
if (it->second.a.consortiumId == bodyId)
it = anomalies_.erase(it);
else
++it;
}
onOk();
}
// ── 任务管理load 回空列表避免 UI 崩)──────────────────────────────────────

View File

@ -1,4 +1,5 @@
#pragma once
#include <cstddef>
#include <functional>
#include <map>
#include <memory>
@ -38,9 +39,29 @@ public:
// ── 客户端创建三维体mock 持久化:内存;端点就绪后换实现)──────────────────
// 登记新三维体(仅存参数,不立即插值)→ 返回新 dsId"vol-N")。插值在首次 loadVolume 惰性做并缓存。
std::string createVolume(VolumeBuildParams params, const std::string& name);
// 已创建三维体的列表行ddCode="dd_voxel"),供三维数据集栏合并注入(每次 setDatasets 追加)。
// 已创建三维体的列表行ddCode="dd_voxel"),供三维分析栏合并注入(每次 setDatasets 追加)。
std::vector<DsRow> volumeRows() const;
// 三维体只读详情(属性对话框用):参数随时可取;统计(值域/网格/测点数/范围)仅
// loadedloadVolume 缓存过明细)时有效,未加载 loaded=false、统计字段全 0。
struct VolumeInfo {
VolumeBuildParams params;
std::string name;
bool loaded = false; // cachedGrid 是否就绪(= loadVolume 跑过)
double vmin = 0.0, vmax = 0.0; // 以下仅 loaded 时有效:
int nx = 0, ny = 0, nz = 0; // 网格维度
double dx = 0.0, dy = 0.0, dz = 0.0; // 单元间距
std::size_t pointCount = 0; // 聚合后参与插值的散点数
};
// 取回三维体详情dsId 非三维体返回 false不弹空对话框
bool volumeInfo(const std::string& dsId, VolumeInfo& out) const;
// 已保存切片的列表行ddCode="dd_slice"parentId=所属体 dsId → 树中挂父体下),供三维分析栏合并。
std::vector<DsRow> sliceRows() const;
// 该 dsId 是否为已保存切片3b分析栏勾选 dd_slice 走切片重渲染路径,不进控制器帘面/体素路径)。
bool isSliceDataset(const std::string& dsId) const;
// 取回已保存切片位姿(还原渲染用);不存在返回 false。
bool sliceSpec(const std::string& dsId, SliceSpec& out) const;
void loadVolume(const std::string& dsId,
std::function<void(VolumeGrid, geopro::core::ColorScale)> onOk,
OnError onErr) override;
@ -92,9 +113,26 @@ private:
std::string name;
std::optional<VolumeGrid> cachedGrid;
core::ColorScale cachedScale; // 与 cachedGrid 同时填(源剖面色阶)
std::optional<std::size_t> pointCount; // 聚合散点数finalizeVolume 时持久化,详情统计用)
};
std::map<std::string, StoredVolume> volumes_; // dsId → 体
int volumeCounter_ = 0;
// 内存态切片存储mock重启清空。切片保存后成 dd_slice 数据集,进三维分析栏。
struct StoredSlice {
SliceSpec spec;
std::string name;
};
std::map<std::string, StoredSlice> slices_; // dsId → 切片
int sliceCounter_ = 0;
// 内存态异常存储mock挂三维体 = a.volumeDsId。异常体(consortium)分组用 a.consortiumId。
struct StoredAnomaly {
geopro::core::Anomaly a;
std::string screenshotPath;
};
std::map<std::string, StoredAnomaly> anomalies_; // anomalyId → 异常
int anomalyCounter_ = 0;
};
} // namespace geopro::data

View File

@ -70,11 +70,15 @@ public:
virtual void loadTerrainPaths(std::function<void(TerrainPaths)> onOk, OnError onErr) = 0;
// ── 切片数据集 CRUDspec §6.3)──────────────────────────────────────────
// 切面位姿(原点 + 法向,用 std::array 去裸 double[])。
// 切面精确几何vtkImagePlaneWidget 的三点(Origin/Point1/Point2) + 轴向 →
// 重渲染逐点精确还原(尺寸/朝向/位置一致);法向 = normalize((p1-o)×(p2-o)),可派生。
// axis: 0 上下 / 1 前后 / 2 左右 / 3 任意(=render::interact::SliceAxis 顺序);决定还原时是否锁旋转。
struct SliceSpec {
std::string volumeDsId; // 所属三维体 dsId
std::array<double, 3> origin{{0, 0, 0}}; // 切面上一点(世界米)
std::array<double, 3> normal{{0, 0, 1}}; // 切面法向(单位向量)
int axis = 3; // 轴向(锁旋转用)
std::array<double, 3> origin{{0, 0, 0}}; // 平面 Origin
std::array<double, 3> point1{{0, 0, 0}}; // 平面 Point1
std::array<double, 3> point2{{0, 0, 0}}; // 平面 Point2
std::string colorScaleId;
};
// 切片数据集持久化态dsId/名字 + 位姿 + 采样网格。

View File

@ -2,7 +2,7 @@ find_package(VTK REQUIRED COMPONENTS CommonCore CommonDataModel FiltersGeometry
find_package(GDAL CONFIG REQUIRED)
add_library(geopro_render STATIC
Scene.cpp ColorLutBuilder.cpp CameraPreset.cpp VoxelFromScatters.cpp ContourBands.cpp actors/GridContourActor.cpp actors/VoxelActor.cpp actors/CurtainActor.cpp actors/MapLineActor.cpp actors/ScatterActor.cpp actors/AnomalyActor.cpp actors/ElectrodeActor.cpp actors/TerrainActor.cpp actors/AxesActor.cpp
interact/SlicePlaneMath.cpp interact/SliceTool.cpp interact/PickInteractorStyle.cpp interact/InteractionManager.cpp
interact/SlicePlaneMath.cpp interact/SliceTool.cpp interact/PickInteractorStyle.cpp interact/InteractionManager.cpp interact/AnomalyDrawTool.cpp
ground/TileMath.cpp)
target_include_directories(geopro_render PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
target_link_libraries(geopro_render PUBLIC geopro_core ${VTK_LIBRARIES} GDAL::GDAL)

View File

@ -23,7 +23,7 @@ constexpr int kDashRepeat = 1;
constexpr float kPointSize = 8.0F;
// 把一个异常的 localPts 灌入 pointsx, -y, 0深度取负与 #18 同坐标系)。
void fillPoints(vtkPoints* points, const geopro::core::Anomaly& a)
void fillPoints2D(vtkPoints* points, const geopro::core::Anomaly& a)
{
points->SetNumberOfPoints(static_cast<vtkIdType>(a.localPts.size()));
for (std::size_t i = 0; i < a.localPts.size(); ++i) {
@ -31,27 +31,25 @@ void fillPoints(vtkPoints* points, const geopro::core::Anomaly& a)
}
}
} // namespace
std::vector<vtkSmartPointer<vtkActor>> buildAnomalies(
const std::vector<geopro::core::Anomaly>& anomalies)
// 把一个异常的 worldPts 灌入 points世界 3D 坐标,直接用,不翻 y/不压 z
void fillPoints3D(vtkPoints* points, const geopro::core::Anomaly& a)
{
std::vector<vtkSmartPointer<vtkActor>> out;
out.reserve(anomalies.size());
for (const auto& a : anomalies) {
const std::size_t n = a.localPts.size();
if (n == 0) continue; // 无几何,跳过
vtkNew<vtkPoints> points;
fillPoints(points, a);
points->SetNumberOfPoints(static_cast<vtkIdType>(a.worldPts.size()));
for (std::size_t i = 0; i < a.worldPts.size(); ++i) {
points->SetPoint(static_cast<vtkIdType>(i), a.worldPts[i].x, a.worldPts[i].y,
a.worldPts[i].z);
}
}
// 由已灌点的 points + 异常样式/类型,构建单个 actor点/折线/闭合多边形 + 颜色/线宽/虚线)。
vtkSmartPointer<vtkActor> buildActor(vtkPoints* points, std::size_t n,
const geopro::core::Anomaly& a)
{
vtkNew<vtkPolyData> poly;
poly->SetPoints(points);
const bool asPoints = (a.markType == geopro::core::AnomalyMarkType::Point);
if (asPoints) {
// 点型:每点一个 vtkVertex。
vtkNew<vtkCellArray> verts;
for (std::size_t i = 0; i < n; ++i) {
const auto id = static_cast<vtkIdType>(i);
@ -91,10 +89,33 @@ std::vector<vtkSmartPointer<vtkActor>> buildAnomalies(
actor->GetProperty()->SetLineStippleRepeatFactor(kDashRepeat);
}
}
out.push_back(actor);
}
return actor;
}
} // namespace
std::vector<vtkSmartPointer<vtkActor>> buildAnomalies(
const std::vector<geopro::core::Anomaly>& anomalies)
{
std::vector<vtkSmartPointer<vtkActor>> out;
out.reserve(anomalies.size());
for (const auto& a : anomalies) {
const std::size_t n = a.localPts.size();
if (n == 0) continue; // 无几何,跳过
vtkNew<vtkPoints> points;
fillPoints2D(points, a);
out.push_back(buildActor(points, n, a));
}
return out;
}
vtkSmartPointer<vtkActor> buildAnomaly3D(const geopro::core::Anomaly& a)
{
const std::size_t n = a.worldPts.size();
if (n == 0) return nullptr; // 无 3D 几何
vtkNew<vtkPoints> points;
fillPoints3D(points, a);
return buildActor(points, n, a);
}
} // namespace geopro::render

View File

@ -18,4 +18,8 @@ namespace geopro::render {
std::vector<vtkSmartPointer<vtkActor>> buildAnomalies(
const std::vector<geopro::core::Anomaly>& anomalies);
// 单个异常 → 世界坐标 3D actorVTK 三维视图):用 worldPts 直接建点/折线/闭合多边形(不翻 y、不压 z=0
// 空几何(worldPts 为空)返回 nullptr。样式同 buildAnomalies(lineColor/width/dashed)。
vtkSmartPointer<vtkActor> buildAnomaly3D(const geopro::core::Anomaly& anomaly);
} // namespace geopro::render

View File

@ -0,0 +1,276 @@
#include "interact/AnomalyDrawTool.hpp"
#include <chrono>
#include <cmath>
#include <cstddef>
#include <string>
#include <vtkActor.h>
#include <vtkCallbackCommand.h>
#include <vtkCellArray.h>
#include <vtkCommand.h>
#include <vtkNew.h>
#include <vtkPoints.h>
#include <vtkPolyData.h>
#include <vtkPolyDataMapper.h>
#include <vtkPolyLine.h>
#include <vtkProperty.h>
#include <vtkRenderWindowInteractor.h>
#include <vtkRenderer.h>
#include <vtkTextActor.h>
#include <vtkTextProperty.h>
namespace geopro::render::interact {
namespace {
constexpr double kEps = 1e-9;
constexpr double kObserverPriority = 5.0; // 高于切片 widget 与右键菜单(1.0),绘制期独占
constexpr double kDoubleClickMs = 350.0; // 左键双击闭合阈值
constexpr int kClickSlopPx = 6; // 双击位置相近阈值(px)
double nowMs() {
return std::chrono::duration<double, std::milli>(
std::chrono::steady_clock::now().time_since_epoch())
.count();
}
} // namespace
AnomalyDrawTool::AnomalyDrawTool(vtkRenderWindowInteractor* interactor, vtkRenderer* renderer)
: interactor_(interactor), renderer_(renderer) {}
AnomalyDrawTool::~AnomalyDrawTool() { removeObservers(); }
void AnomalyDrawTool::start(const Vec3& planeOrigin, const Vec3& planeNormal,
std::function<void(const std::vector<Vec3>&)> onFinish,
std::function<void()> onCancel) {
if (active_) cancel();
origin_ = planeOrigin;
normal_ = normalize(planeNormal);
onFinish_ = std::move(onFinish);
onCancel_ = std::move(onCancel);
pts_.clear();
lastClickMs_ = -1.0;
hasCursor_ = false;
active_ = true;
installObservers();
// 屏幕操作提示(左上角),解决"不知如何闭合"。
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();
}
}
void AnomalyDrawTool::cancel() {
if (!active_) return;
auto cb = onCancel_;
teardownActive(); // 先清理状态,回调里可能再 start
if (cb) cb();
}
// 提取:清理活动态(移观察者/预览/置 inactive不触发回调。
void AnomalyDrawTool::teardownActive() {
removeObservers();
if (renderer_) {
if (preview_) renderer_->RemoveViewProp(preview_);
if (rubber_) renderer_->RemoveViewProp(rubber_);
if (hint_) renderer_->RemoveViewProp(hint_);
}
preview_ = nullptr;
rubber_ = nullptr;
hint_ = nullptr;
active_ = false;
hasCursor_ = false;
pts_.clear();
if (interactor_) interactor_->Render();
}
Vec3 AnomalyDrawTool::pickOnPlane() const {
const int* pos = interactor_->GetEventPosition();
// 屏幕点 → 世界近/远点(齐次,需除 w
auto toWorld = [this](int x, int y, double z) -> Vec3 {
renderer_->SetDisplayPoint(static_cast<double>(x), static_cast<double>(y), z);
renderer_->DisplayToWorld();
double w[4];
renderer_->GetWorldPoint(w);
if (std::abs(w[3]) > kEps) {
w[0] /= w[3]; w[1] /= w[3]; w[2] /= w[3];
}
return Vec3{{w[0], w[1], w[2]}};
};
const Vec3 nearP = toWorld(pos[0], pos[1], 0.0);
const Vec3 farP = toWorld(pos[0], pos[1], 1.0);
const Vec3 dir{{farP[0] - nearP[0], farP[1] - nearP[1], farP[2] - nearP[2]}};
const double denom = dot(dir, normal_);
if (std::abs(denom) < kEps) return nearP; // 射线平行平面 → 退化用近点
// t = ((origin - near)·normal) / (dir·normal)
const Vec3 on{{origin_[0] - nearP[0], origin_[1] - nearP[1], origin_[2] - nearP[2]}};
const double t = dot(on, normal_) / denom;
return Vec3{{nearP[0] + t * dir[0], nearP[1] + t * dir[1], nearP[2] + t * dir[2]}};
}
void AnomalyDrawTool::addVertex() {
pts_.push_back(pickOnPlane());
updatePreview();
}
void AnomalyDrawTool::updatePreview() {
if (!renderer_) return;
if (preview_) renderer_->RemoveViewProp(preview_);
preview_ = nullptr;
if (pts_.empty()) {
interactor_->Render();
return;
}
vtkNew<vtkPoints> points;
points->SetNumberOfPoints(static_cast<vtkIdType>(pts_.size()));
for (std::size_t i = 0; i < pts_.size(); ++i)
points->SetPoint(static_cast<vtkIdType>(i), pts_[i][0], pts_[i][1], pts_[i][2]);
vtkNew<vtkPolyData> poly;
poly->SetPoints(points);
// 顶点圆点:每点一个 vtkVertex → 单点也可见(解决"第一下看不到点在哪")。
vtkNew<vtkCellArray> verts;
for (std::size_t i = 0; i < pts_.size(); ++i) {
const auto id = static_cast<vtkIdType>(i);
verts->InsertNextCell(1, &id);
}
poly->SetVerts(verts);
// 实线折线≥2 点)。
if (pts_.size() >= 2) {
vtkNew<vtkPolyLine> line;
line->GetPointIds()->SetNumberOfIds(static_cast<vtkIdType>(pts_.size()));
for (std::size_t i = 0; i < pts_.size(); ++i)
line->GetPointIds()->SetId(static_cast<vtkIdType>(i), static_cast<vtkIdType>(i));
vtkNew<vtkCellArray> cells;
cells->InsertNextCell(line);
poly->SetLines(cells);
}
vtkNew<vtkPolyDataMapper> mapper;
mapper->SetInputData(poly);
mapper->ScalarVisibilityOff();
preview_ = vtkSmartPointer<vtkActor>::New();
preview_->SetMapper(mapper);
preview_->GetProperty()->SetColor(1.0, 0.9, 0.0); // 亮黄
preview_->GetProperty()->SetLineWidth(2.0);
preview_->GetProperty()->SetPointSize(9.0); // 醒目圆点
renderer_->AddActor(preview_);
interactor_->Render();
}
void AnomalyDrawTool::updateRubber() {
if (!renderer_) return;
if (rubber_) renderer_->RemoveViewProp(rubber_);
rubber_ = nullptr;
if (pts_.empty() || !hasCursor_) {
if (interactor_) interactor_->Render();
return;
}
// 末点 → 当前光标投影点 的虚线橡皮筋(跟手反馈)。
const Vec3& a = pts_.back();
vtkNew<vtkPoints> points;
points->SetNumberOfPoints(2);
points->SetPoint(0, a[0], a[1], a[2]);
points->SetPoint(1, cursorPt_[0], cursorPt_[1], cursorPt_[2]);
vtkNew<vtkPolyData> poly;
poly->SetPoints(points);
vtkNew<vtkPolyLine> line;
line->GetPointIds()->SetNumberOfIds(2);
line->GetPointIds()->SetId(0, 0);
line->GetPointIds()->SetId(1, 1);
vtkNew<vtkCellArray> cells;
cells->InsertNextCell(line);
poly->SetLines(cells);
vtkNew<vtkPolyDataMapper> mapper;
mapper->SetInputData(poly);
mapper->ScalarVisibilityOff();
rubber_ = vtkSmartPointer<vtkActor>::New();
rubber_->SetMapper(mapper);
rubber_->GetProperty()->SetColor(1.0, 0.9, 0.0);
rubber_->GetProperty()->SetLineWidth(1.5);
rubber_->GetProperty()->SetLineStipplePattern(0xF0F0); // 虚线
rubber_->GetProperty()->SetLineStippleRepeatFactor(1);
renderer_->AddActor(rubber_);
interactor_->Render();
}
void AnomalyDrawTool::finish() {
if (pts_.size() < 3) { // 不足以成面 → 取消
cancel();
return;
}
std::vector<Vec3> result = pts_;
auto cb = onFinish_;
teardownActive();
if (cb) cb(result);
}
void AnomalyDrawTool::installObservers() {
if (!interactor_) return;
cmd_ = vtkSmartPointer<vtkCallbackCommand>::New();
cmd_->SetClientData(this);
cmd_->SetCallback([](vtkObject*, unsigned long eid, void* client, void*) {
auto* self = static_cast<AnomalyDrawTool*>(client);
if (!self->active_) return;
if (eid == vtkCommand::MouseMoveEvent) {
// 鼠标移动:更新末点→光标的虚线橡皮筋(跟手反馈)。不 abort不干扰其它悬停。
self->cursorPt_ = self->pickOnPlane();
self->hasCursor_ = true;
self->updateRubber();
return;
}
// 先消费事件abort再处理finish()/cancel() 内 teardown 会置空 cmd_若 abort 留到末尾会被跳过,
// 导致触发闭合的那次按键漏给切片 widget → widget 当左键按下开始 slice-motion鼠标一动切片就动
if (self->cmd_) self->cmd_->SetAbortFlag(1);
switch (eid) {
case vtkCommand::LeftButtonPressEvent: {
// 左键双连击 = 闭合(标准多边形交互);否则加顶点。
const double now = nowMs();
const int* p = self->interactor_->GetEventPosition();
const bool dbl = self->lastClickMs_ >= 0.0 &&
(now - self->lastClickMs_) < kDoubleClickMs &&
std::abs(p[0] - self->lastClickX_) <= kClickSlopPx &&
std::abs(p[1] - self->lastClickY_) <= kClickSlopPx;
self->lastClickMs_ = now;
self->lastClickX_ = p[0];
self->lastClickY_ = p[1];
if (dbl)
self->finish();
else
self->addVertex();
break;
}
case vtkCommand::RightButtonPressEvent: self->finish(); break;
case vtkCommand::KeyPressEvent: {
const char* key = self->interactor_->GetKeySym();
if (key && (std::string(key) == "Escape")) self->cancel();
else if (key && (std::string(key) == "Return")) self->finish();
break;
}
default: break;
}
});
tagLeft_ = interactor_->AddObserver(vtkCommand::LeftButtonPressEvent, cmd_, kObserverPriority);
tagRight_ = interactor_->AddObserver(vtkCommand::RightButtonPressEvent, cmd_, kObserverPriority);
tagKey_ = interactor_->AddObserver(vtkCommand::KeyPressEvent, cmd_, kObserverPriority);
tagMove_ = interactor_->AddObserver(vtkCommand::MouseMoveEvent, cmd_, kObserverPriority);
}
void AnomalyDrawTool::removeObservers() {
if (interactor_) {
if (tagLeft_) interactor_->RemoveObserver(tagLeft_);
if (tagRight_) interactor_->RemoveObserver(tagRight_);
if (tagKey_) interactor_->RemoveObserver(tagKey_);
if (tagMove_) interactor_->RemoveObserver(tagMove_);
if (tagDbl_) interactor_->RemoveObserver(tagDbl_);
}
tagLeft_ = tagRight_ = tagKey_ = tagMove_ = tagDbl_ = 0;
cmd_ = nullptr;
}
} // namespace geopro::render::interact

View File

@ -0,0 +1,69 @@
#pragma once
#include <functional>
#include <vector>
#include <vtkSmartPointer.h>
#include "interact/SlicePlaneMath.hpp"
class vtkRenderWindowInteractor;
class vtkRenderer;
class vtkActor;
class vtkTextActor;
class vtkCallbackCommand;
namespace geopro::render::interact {
// 异常圈定工具(#4b在给定切片平面上交互式画多边形。
// 左键逐点加顶点(屏幕射线与平面求交,落在平面上);右键 / 双击 / 回车 闭合 → onFinish(worldPts)
// Esc / 不足 3 点闭合 → onCancel。绘制中实时预览折线。
// 高优先级(2.0)交互器观察者抢输入:先于切片 widget 与 InteractionManager 右键菜单,绘制期独占左右键。
// render 层:只碰 VTK不认业务产物(平面上的世界点)经回调交上层组装 core::Anomaly。
class AnomalyDrawTool {
public:
AnomalyDrawTool(vtkRenderWindowInteractor* interactor, vtkRenderer* renderer);
~AnomalyDrawTool();
AnomalyDrawTool(const AnomalyDrawTool&) = delete;
AnomalyDrawTool& operator=(const AnomalyDrawTool&) = delete;
// 开始在平面(origin/normal)上圈定。onFinish 收闭合多边形顶点(世界系)onCancel 取消。
void start(const Vec3& planeOrigin, const Vec3& planeNormal,
std::function<void(const std::vector<Vec3>&)> onFinish,
std::function<void()> onCancel);
bool active() const { return active_; }
void cancel(); // 外部强制取消(如切走视图)
private:
void addVertex(); // 左键:加顶点
void updatePreview(); // 重建已点几何(顶点圆点 + 实线折线;单点也可见)
void updateRubber(); // 鼠标移动:末点→光标的虚线橡皮筋
void finish(); // 右键/双击/回车:闭合
Vec3 pickOnPlane() const; // 当前鼠标屏幕点 → 射线与平面交点
void installObservers();
void removeObservers();
void teardownActive(); // 清理活动态(移观察者/预览/置inactive),不触发回调
vtkRenderWindowInteractor* interactor_;
vtkRenderer* renderer_;
bool active_ = false;
Vec3 origin_{{0, 0, 0}}, normal_{{0, 0, 1}};
std::vector<Vec3> pts_;
std::function<void(const std::vector<Vec3>&)> onFinish_;
std::function<void()> onCancel_;
vtkSmartPointer<vtkActor> preview_; // 已点几何(顶点圆点 + 实线折线)
vtkSmartPointer<vtkActor> rubber_; // 末点→光标 虚线橡皮筋
vtkSmartPointer<vtkTextActor> hint_; // 屏幕操作提示
Vec3 cursorPt_{{0, 0, 0}}; // 当前鼠标在切面上的投影点
bool hasCursor_ = false;
vtkSmartPointer<vtkCallbackCommand> cmd_;
unsigned long tagLeft_ = 0, tagMove_ = 0, tagRight_ = 0, tagKey_ = 0, tagDbl_ = 0;
// 双击闭合检测(左键两连击):记上次左键时刻 + 屏幕位置。
double lastClickMs_ = -1.0;
int lastClickX_ = 0, lastClickY_ = 0;
};
} // namespace geopro::render::interact

View File

@ -5,12 +5,20 @@
#include <cmath>
#include <cstddef>
#include <vtkCallbackCommand.h>
#include <vtkCamera.h>
#include <vtkCellPicker.h>
#include <vtkCommand.h>
#include <vtkImageData.h>
#include <vtkImageMapToColors.h>
#include <vtkImageResize.h>
#include <vtkLookupTable.h>
#include <vtkNew.h>
#include <vtkRenderWindow.h>
#include <vtkRenderWindowInteractor.h>
#include <vtkRenderer.h>
#include "ColorLutBuilder.hpp"
#include "interact/PickInteractorStyle.hpp"
namespace geopro::render::interact {
@ -48,6 +56,15 @@ void InteractionManager::installStyle() {
return true;
};
interactor_->SetInteractorStyle(style_);
// 右键菜单观察者:高优先级(1.0)直接挂交互器,先于 vtkImagePlaneWidget(默认 0.0)消费右键。
// 命中切片 → handleRightButton 内 abort + 弹菜单;未命中 → 不 abort事件继续走默认。
rightBtnCmd_ = vtkSmartPointer<vtkCallbackCommand>::New();
rightBtnCmd_->SetClientData(this);
rightBtnCmd_->SetCallback([](vtkObject*, unsigned long, void* client, void*) {
static_cast<InteractionManager*>(client)->handleRightButton();
});
rightBtnTag_ = interactor_->AddObserver(vtkCommand::RightButtonPressEvent, rightBtnCmd_, 1.0);
}
void InteractionManager::uninstallStyle() {
@ -58,6 +75,12 @@ void InteractionManager::uninstallStyle() {
style_->onWheelStep = nullptr;
style_->getRotateCenter = nullptr;
}
// 摘除右键观察者this 即将析构)。
if (interactor_ && rightBtnTag_ != 0) {
interactor_->RemoveObserver(rightBtnTag_);
rightBtnTag_ = 0;
}
rightBtnCmd_ = nullptr;
// 从 interactor 上彻底摘除自定义 style避免 interactor 仍持空回调 style评审 H2
if (interactor_) interactor_->SetInteractorStyle(nullptr);
style_ = nullptr;
@ -94,6 +117,54 @@ void InteractionManager::addSlice(SliceAxis axis) {
safeRender();
}
void InteractionManager::showSavedSlice(const std::string& dsId, int axis, const Vec3& origin,
const Vec3& point1, const Vec3& point2) {
if (!image_ || !interactor_ || dsId.empty()) return;
for (const auto& s : slices_)
if (s->dsId() == dsId) return; // 已显示 → 去重跳过
const SliceAxis ax = static_cast<SliceAxis>(axis);
auto tool = std::make_unique<SliceTool>(image_, interactor_, ax, colorScale_, vmin_, vmax_,
origin, point1, point2); // 三点精确还原
tool->setDsId(dsId);
SliceTool* tp = tool.get();
tool->onInteract = [this, tp]() { selectByTool(tp); };
slices_.push_back(std::move(tool));
selected_ = static_cast<int>(slices_.size()) - 1;
updateSelectionVisual();
safeRender();
}
void InteractionManager::hideSavedSlice(const std::string& dsId) {
for (std::size_t i = 0; i < slices_.size(); ++i) {
if (slices_[i]->dsId() != dsId) continue;
slices_[i]->close();
slices_.erase(slices_.begin() + static_cast<long>(i));
selected_ = slices_.empty() ? -1
: std::min(selected_, static_cast<int>(slices_.size()) - 1);
updateSelectionVisual();
safeRender();
return;
}
}
std::vector<std::string> InteractionManager::shownSavedSliceIds() const {
std::vector<std::string> out;
for (const auto& s : slices_)
if (!s->dsId().empty()) out.push_back(s->dsId());
return out;
}
bool InteractionManager::selectSavedSlice(const std::string& dsId) {
for (std::size_t i = 0; i < slices_.size(); ++i) {
if (slices_[i]->dsId() != dsId) continue;
selected_ = static_cast<int>(i);
updateSelectionVisual();
safeRender();
return true;
}
return false;
}
void InteractionManager::selectByTool(const SliceTool* tool) {
int idx = -1;
for (std::size_t i = 0; i < slices_.size(); ++i)
@ -120,6 +191,7 @@ void InteractionManager::selectByTool(const SliceTool* tool) {
void InteractionManager::closeSelected() {
if (selected_ < 0 || selected_ >= static_cast<int>(slices_.size())) return;
const std::string closedDsId = slices_[static_cast<std::size_t>(selected_)]->dsId();
slices_[static_cast<std::size_t>(selected_)]->close();
slices_.erase(slices_.begin() + selected_);
// 选中停在原位就近(删后该位变成下一张;删的是末张则退一张),不跳回 0评审 M2
@ -127,6 +199,8 @@ void InteractionManager::closeSelected() {
: std::min(selected_, static_cast<int>(slices_.size()) - 1);
updateSelectionVisual();
safeRender();
// 已保存切片被主动关闭 → 通知上层取消列表勾选(场景↔列表同步)。
if (!closedDsId.empty() && onSliceClosed) onSliceClosed(closedDsId);
}
void InteractionManager::closeAll() {
@ -145,6 +219,96 @@ void InteractionManager::flipView() {
safeRender();
}
void InteractionManager::faceSelected() { faceSlice(selected_); }
bool InteractionManager::selectedSlicePlane(int& axis, Vec3& origin, Vec3& point1,
Vec3& point2) const {
if (selected_ < 0 || selected_ >= static_cast<int>(slices_.size())) return false;
const auto& s = slices_[static_cast<std::size_t>(selected_)];
axis = static_cast<int>(s->axis());
double o[3], p1[3], p2[3];
s->planePoints(o, p1, p2);
origin = {{o[0], o[1], o[2]}};
point1 = {{p1[0], p1[1], p1[2]}};
point2 = {{p2[0], p2[1], p2[2]}};
return true;
}
std::string InteractionManager::selectedSliceDsId() const {
if (selected_ < 0 || selected_ >= static_cast<int>(slices_.size())) return {};
return slices_[static_cast<std::size_t>(selected_)]->dsId();
}
void InteractionManager::tagSelectedSlice(const std::string& dsId) {
if (selected_ < 0 || selected_ >= static_cast<int>(slices_.size())) return;
slices_[static_cast<std::size_t>(selected_)]->setDsId(dsId);
}
vtkImageData* InteractionManager::selectedSliceImage() const {
if (selected_ < 0 || selected_ >= static_cast<int>(slices_.size())) return nullptr;
return slices_[static_cast<std::size_t>(selected_)]->reslicedOutput();
}
vtkSmartPointer<vtkImageData> InteractionManager::selectedSliceColorImage() const {
vtkImageData* scalar = selectedSliceImage();
if (scalar == nullptr) return nullptr;
// 高清导出切片重采样像素维度受体素网格分辨率限制常仅几十px→ 先上采样到目标分辨率
// (最长边 kExportLongSide保持长宽比、插值再上色得到清晰大图。
constexpr int kExportLongSide = 2048;
int dims[3];
scalar->GetDimensions(dims);
const int nx = dims[0], ny = dims[1];
const int longest = std::max(nx, ny);
double f = (longest > 0) ? static_cast<double>(kExportLongSide) / longest : 1.0;
if (f < 1.0) f = 1.0; // 不缩小(已够大则原样)
vtkNew<vtkImageResize> resize;
resize->SetInputData(scalar);
resize->SetResizeMethodToOutputDimensions();
resize->SetOutputDimensions(std::max(1, static_cast<int>(nx * f)),
std::max(1, static_cast<int>(ny * f)), 1);
resize->Update();
// 用与切片显示同一色阶 LUT 上色colorScale_/vmin_/vmax_ 即当前体/切片着色区间)。
auto lut = buildLut(colorScale_, vmin_, vmax_);
vtkNew<vtkImageMapToColors> map;
map->SetInputConnection(resize->GetOutputPort());
map->SetLookupTable(lut);
map->SetOutputFormatToRGB();
map->Update();
auto out = vtkSmartPointer<vtkImageData>::New();
out->DeepCopy(map->GetOutput()); // 深拷贝脱离 filter 生命周期
return out;
}
int InteractionManager::pickSliceAtCursor() const {
if (!interactor_ || slices_.empty()) return -1;
const int* pos = interactor_->GetEventPosition();
auto* ren = interactor_->FindPokedRenderer(pos[0], pos[1]);
if (!ren) return -1;
vtkNew<vtkCellPicker> picker;
picker->SetTolerance(0.005);
if (!picker->Pick(pos[0], pos[1], 0.0, ren)) return -1;
double w[3];
picker->GetPickPosition(w);
return nearestSlice({w[0], w[1], w[2]});
}
void InteractionManager::handleRightButton() {
// 高优先级右键观察者(先于 vtkImagePlaneWidget 消费右键)。
// 选中目标 = 拾取命中的切片;拾取没命中(常因拾到体/其它面)则回退到"当前选中切片"。
// 有可操作切片 → abort 右键 + 弹菜单;否则放行默认右键。
if (!interactor_) return;
int idx = pickSliceAtCursor();
if (idx < 0) idx = selected_; // 回退到当前选中切片
if (idx < 0 || idx >= static_cast<int>(slices_.size())) return; // 无切片可操作 → 放行默认右键
selected_ = idx;
updateSelectionVisual();
safeRender();
if (rightBtnCmd_) rightBtnCmd_->SetAbortFlag(1); // 消费右键,阻止 widget/style 默认行为
if (onSliceContextMenuRequested) onSliceContextMenuRequested();
}
int InteractionManager::nearestSlice(const Vec3& worldPoint) const {
if (slices_.empty()) return -1;
std::vector<Vec3> centers, normals;
@ -166,14 +330,10 @@ int InteractionManager::nearestSlice(const Vec3& worldPoint) const {
}
void InteractionManager::onPicked(const Vec3& worldPoint) {
// 单击 = 仅选中命中切片 + 高亮,**不动相机** → 切换切片永不跳。
// 拖动旋转交给默认 TrackballCamera绕场景/体中心,稳定)。曾试"按切片中心移焦点"以实现
// spec C38'以切片为中心',但切片中心≈体中心→与默认视觉等价、却引入切换跳动,得不偿失,故去除。
const int idx = nearestSlice(worldPoint);
if (idx >= 0) {
selected_ = idx;
// 单击 = 选中命中切片;点在切片外(如点到体/帘面)→ 取消选中idx=-1。**不动相机**。
// 解决"选了切片无法取消":点击切片之外即清选中,滚轮恢复缩放(见 onWheel
selected_ = nearestSlice(worldPoint);
updateSelectionVisual();
}
safeRender();
}
@ -204,11 +364,14 @@ void InteractionManager::faceSlice(int idx) {
bool InteractionManager::onWheel(int dir) {
// 滚轮推进**当前选中**的切片(需先显式选中);无选中 → 不消费 → 相机缩放。
// 配合 onPicked 的"点击切片外取消选中":取消后滚轮即恢复缩放,解决"选了切片无法缩放"。
// (不采用"悬停即推进":推进时鼠标难持续压在移动的切片上,且过敏感。)
if (selected_ < 0 || selected_ >= static_cast<int>(slices_.size())) return false;
const double step = wheelStep(imageBounds(image_), dir);
slices_[static_cast<std::size_t>(selected_)]->advance(step);
safeRender();
return true; // 消费滚轮(不缩放)
return true; // 消费滚轮(推进选中切片,不缩放)
}
} // namespace geopro::render::interact

View File

@ -1,5 +1,7 @@
#pragma once
#include <functional>
#include <memory>
#include <string>
#include <vector>
#include <vtkSmartPointer.h>
@ -12,6 +14,7 @@ class vtkImageData;
class vtkRenderWindow;
class vtkRenderWindowInteractor;
class vtkRenderer;
class vtkCallbackCommand;
namespace geopro::render::interact {
@ -43,6 +46,16 @@ public:
// 创建一张切片(轴向/任意)。无体素 image 则忽略。新切片自动设为选中。
void addSlice(SliceAxis axis);
// ── 已保存切片(dd_slice)按 dsId 显隐3b三维分析栏勾选/取消已保存切片)──────────
// showSavedSlice在当前体上按精确三点几何还原一张带 dsId 标签的切片;已显示则跳过(去重)。
// 须在父体 image 已 setVolumeImage 后调用(无 image 则忽略。axis 仅决定是否锁旋转。
void showSavedSlice(const std::string& dsId, int axis, const Vec3& origin, const Vec3& point1,
const Vec3& point2);
void hideSavedSlice(const std::string& dsId);
std::vector<std::string> shownSavedSliceIds() const; // 当前已显示的已保存切片 dsId 列表
// 选中已显示的某 dsId 切片(列表操作定位到对应渲染切片);找到返回 true。
bool selectSavedSlice(const std::string& dsId);
// 关闭选中切片E56。无选中则忽略。
void closeSelected();
// 关闭并释放所有切片(切到二维 / 清场 / 体素重建前调)。
@ -54,6 +67,26 @@ public:
// 视图翻转:水平旋转 180°E55
void flipView();
// 正视当前选中切片(菜单「正视图」入口;无选中则忽略)。
void faceSelected();
// 选中切片精确平面(保存用):有选中→填 axis + 三点 返回 true否则 false。
bool selectedSlicePlane(int& axis, Vec3& origin, Vec3& point1, Vec3& point2) const;
// 选中切片的归属 dsId已保存切片非空未保存为空。无选中返回空字符串。
std::string selectedSliceDsId() const;
// 给当前选中(未保存)切片打 dsId 标签:保存=把当前切片链接到新数据集(不重绘、不重复)。
void tagSelectedSlice(const std::string& dsId);
// 选中切片的重采样 2D 标量影像(导出 dat 用);无选中返回 nullptr。
vtkImageData* selectedSliceImage() const;
// 选中切片"上色后"的 2D 影像(导出图片用):重采样标量经切片色阶 LUT → RGB 图;无选中返回 nullptr。
vtkSmartPointer<vtkImageData> selectedSliceColorImage() const;
// 右键命中切片时回调manager 已选中所在切片)→ 上层据此弹切片右键菜单(用 QCursor::pos 定位)。
std::function<void()> onSliceContextMenuRequested;
// 通过「关闭」显式关掉一张已保存切片时回调其 dsId → 上层据此取消列表勾选(场景↔列表同步)。
// 仅 closeSelected(用户主动关闭) 触发closeAll(体变更/清场) 不触发(切片应随体回来再现)。
std::function<void(const std::string& dsId)> onSliceClosed;
// 安装/卸载自定义交互样式(构造时安装;析构卸载恢复原样式)。
void installStyle();
@ -65,8 +98,14 @@ private:
void onDoubleClicked(const Vec3& worldPoint); // 正视所在切片
bool onWheel(int dir); // 推进选中切片;无选中返回 false
// 右键命中切片 → 选中 + 请求弹菜单 + abort高优先级交互器观察者先于 vtkImagePlaneWidget
// 消费右键,否则 widget 抢走事件、InteractorStyle 永不触发)。未命中切片则不 abort、放行默认。
void handleRightButton();
// 找离世界点最近的切片索引;无切片返回 -1。
int nearestSlice(const Vec3& worldPoint) const;
// 在当前鼠标屏幕位置拾取 → 命中的切片索引;未命中切片返回 -1。
int pickSliceAtCursor() const;
// 按 SliceTool 指针设为选中widget 交互回调用:触碰即选中)。
void selectByTool(const SliceTool* tool);
// 相机正视给定切面focal=center, 沿 normal 退 dist
@ -90,6 +129,9 @@ private:
int selected_ = -1; // 选中切片索引(-1=无)
vtkSmartPointer<PickInteractorStyle> style_;
// 右键菜单:高优先级交互器观察者(先于 widget 抢右键。tag 供 uninstall 时摘除。
vtkSmartPointer<vtkCallbackCommand> rightBtnCmd_;
unsigned long rightBtnTag_ = 0;
// 析构进行中closeAll() 跳过 renderWindow_->Render()Qt 拆台时窗口可能已半析构,
// 析构期再 Render 易崩,评审 M3

View File

@ -21,16 +21,13 @@ namespace {
constexpr double kSqrt2Inv = 0.70710678118654752440;
} // namespace
SliceTool::SliceTool(vtkImageData* image, vtkRenderWindowInteractor* interactor, SliceAxis axis,
const geopro::core::ColorScale& cs, double vmin, double vmax)
: axis_(axis), image_(image), widget_(vtkSmartPointer<vtkImagePlaneWidget>::New()) {
void SliceTool::initWidget(const geopro::core::ColorScale& cs, double vmin, double vmax) {
// 经 trivial producer 把已存在的 vtkImageData 接入 widgetwidget 只暴露 SetInputConnection
// producer_ 为成员,随 SliceTool 保活(局部变量会构造后即析构→管线断裂,评审 H1
producer_ = vtkSmartPointer<vtkTrivialProducer>::New();
producer_->SetOutput(image_);
widget_->SetInputConnection(producer_->GetOutputPort());
widget_->SetInteractor(interactor);
widget_->RestrictPlaneToVolumeOn(); // 切面限制在体内,滚轮推进不跑飞
widget_->SetResliceInterpolateToLinear(); // reslice 线性插值出连续剖面(非 cutter 交线)
widget_->TextureInterpolateOn();
@ -39,45 +36,9 @@ SliceTool::SliceTool(vtkImageData* image, vtkRenderWindowInteractor* interactor,
// 色阶 LUT 套用:用户自管 LUT不让 widget 用默认灰度窗位)。
auto lut = buildLut(cs, vmin, vmax);
widget_->SetLookupTable(lut);
}
// 轴向:固定到 X/Y/Z角度不可调符合 G22G24
// 上下=水平面=Z 法向;前后=Y 法向;左右=X 法向。
switch (axis_) {
case SliceAxis::UpDown:
widget_->SetPlaneOrientationToZAxes();
break;
case SliceAxis::FrontBack:
widget_->SetPlaneOrientationToYAxes();
break;
case SliceAxis::LeftRight:
widget_->SetPlaneOrientationToXAxes();
break;
case SliceAxis::Oblique: {
// 任意 45°F25vtkImagePlaneWidget 用 Origin/Point1/Point2 三角点定义平面
// (无 SetNormal。法向 = (Point1-Origin)×(Point2-Origin)。
// 取法向 (sin45,0,cos45)in-plane 轴1 = Y(0,1,0)轴2 = XZ 内与法向正交方向 (cos45,0,-sin45)。
// 以体中心为面心,沿两轴各展半个体范围,得一张斜插体的对角面(可继续交互旋转)。
const auto b = imageBounds();
const double cx = 0.5 * (b[0] + b[1]);
const double cy = 0.5 * (b[2] + b[3]);
const double cz = 0.5 * (b[4] + b[5]);
const double hy = 0.5 * (b[3] - b[2]);
// 轴2 半长取 X/Z 范围的较大者,保证面铺满体对角。
const double hxz = 0.5 * std::max(b[1] - b[0], b[5] - b[4]);
// 轴1 = +Y轴2 = (cos45,0,-sin45)。
const double a2x = kSqrt2Inv, a2z = -kSqrt2Inv;
// Origin = center - 0.5*axis1 - 0.5*axis2使 center 为面心)。
const double ox = cx - 0.0 - a2x * hxz;
const double oy = cy - hy - 0.0;
const double oz = cz - 0.0 - a2z * hxz;
widget_->SetOrigin(ox, oy, oz);
widget_->SetPoint1(ox + 0.0, oy + 2.0 * hy, oz + 0.0); // 沿 +Y
widget_->SetPoint2(ox + a2x * 2.0 * hxz, oy, oz + a2z * 2.0 * hxz); // 沿 (cos45,0,-sin45)
widget_->UpdatePlacement();
break;
}
}
void SliceTool::applyMarginsAndActivate() {
// 左键拖动=移动切面(默认左键是窗位调整,无用);中键=取值光标。
widget_->SetLeftButtonAction(vtkImagePlaneWidget::VTK_SLICE_MOTION_ACTION);
widget_->SetMiddleButtonAction(vtkImagePlaneWidget::VTK_CURSOR_ACTION);
@ -89,7 +50,6 @@ SliceTool::SliceTool(vtkImageData* image, vtkRenderWindowInteractor* interactor,
}
widget_->On();
// 保持 widget 交互开启:任意切片可拖动调整角度/位置(F25 '可任意调整')。
// 监听其交互开始事件 → 触碰本切片即回调 onInteract(上层据此设为选中)。
interactObserver_ = vtkSmartPointer<vtkCallbackCommand>::New();
interactObserver_->SetClientData(this);
@ -100,6 +60,61 @@ SliceTool::SliceTool(vtkImageData* image, vtkRenderWindowInteractor* interactor,
widget_->AddObserver(vtkCommand::StartInteractionEvent, interactObserver_);
}
SliceTool::SliceTool(vtkImageData* image, vtkRenderWindowInteractor* interactor, SliceAxis axis,
const geopro::core::ColorScale& cs, double vmin, double vmax)
: axis_(axis), image_(image), widget_(vtkSmartPointer<vtkImagePlaneWidget>::New()) {
initWidget(cs, vmin, vmax);
widget_->SetInteractor(interactor);
// 轴向:固定到 X/Y/Z角度不可调符合 G22G24。上下=Z 法向;前后=Y 法向;左右=X 法向。
switch (axis_) {
case SliceAxis::UpDown:
widget_->SetPlaneOrientationToZAxes();
break;
case SliceAxis::FrontBack:
widget_->SetPlaneOrientationToYAxes();
break;
case SliceAxis::LeftRight:
widget_->SetPlaneOrientationToXAxes();
break;
case SliceAxis::Oblique: {
// 任意 45°F25用 Origin/Point1/Point2 三点定义平面。法向 (sin45,0,cos45)
// in-plane 轴1=Y(0,1,0)轴2=(cos45,0,-sin45);以体中心为面心、铺满体对角。
const auto b = imageBounds();
const double cx = 0.5 * (b[0] + b[1]);
const double cy = 0.5 * (b[2] + b[3]);
const double cz = 0.5 * (b[4] + b[5]);
const double hy = 0.5 * (b[3] - b[2]);
const double hxz = 0.5 * std::max(b[1] - b[0], b[5] - b[4]);
const double a2x = kSqrt2Inv, a2z = -kSqrt2Inv;
const double ox = cx - a2x * hxz;
const double oy = cy - hy;
const double oz = cz - a2z * hxz;
widget_->SetOrigin(ox, oy, oz);
widget_->SetPoint1(ox, oy + 2.0 * hy, oz); // 沿 +Y
widget_->SetPoint2(ox + a2x * 2.0 * hxz, oy, oz + a2z * 2.0 * hxz); // 沿 (cos45,0,-sin45)
widget_->UpdatePlacement();
break;
}
}
applyMarginsAndActivate();
}
SliceTool::SliceTool(vtkImageData* image, vtkRenderWindowInteractor* interactor, SliceAxis axis,
const geopro::core::ColorScale& cs, double vmin, double vmax,
const std::array<double, 3>& origin, const std::array<double, 3>& point1,
const std::array<double, 3>& point2)
: axis_(axis), image_(image), widget_(vtkSmartPointer<vtkImagePlaneWidget>::New()) {
initWidget(cs, vmin, vmax);
widget_->SetInteractor(interactor);
// 还原:直接用保存的精确三点(不做轴向 snap保证尺寸/朝向/位置与保存时一致。
widget_->SetOrigin(origin[0], origin[1], origin[2]);
widget_->SetPoint1(point1[0], point1[1], point1[2]);
widget_->SetPoint2(point2[0], point2[1], point2[2]);
widget_->UpdatePlacement();
applyMarginsAndActivate(); // 按 axis 锁旋转(轴向切片仍不可旋转)
}
SliceTool::~SliceTool() { close(); }
std::array<double, 6> SliceTool::imageBounds() const {
@ -141,6 +156,20 @@ void SliceTool::advance(double step) {
widget_->UpdatePlacement();
}
void SliceTool::planePoints(double origin[3], double point1[3], double point2[3]) const {
if (!widget_) {
for (int i = 0; i < 3; ++i) origin[i] = point1[i] = point2[i] = 0.0;
return;
}
widget_->GetOrigin(origin);
widget_->GetPoint1(point1);
widget_->GetPoint2(point2);
}
vtkImageData* SliceTool::reslicedOutput() const {
return widget_ ? widget_->GetResliceOutput() : nullptr;
}
double SliceTool::distanceToPlane(const Vec3& p) const {
const Vec3 c = center();
const Vec3 n = normal();

View File

@ -1,6 +1,7 @@
#pragma once
#include <array>
#include <functional>
#include <string>
#include <vtkSmartPointer.h>
@ -30,6 +31,12 @@ public:
// axis切面方向。vmin/vmax色阶区间。
SliceTool(vtkImageData* image, vtkRenderWindowInteractor* interactor, SliceAxis axis,
const geopro::core::ColorScale& cs, double vmin, double vmax);
// 还原构造(已保存切片按 spec 重渲染用精确三点几何axis 仅决定是否锁旋转(不做轴向 snap
// 尺寸/朝向/位置与保存时完全一致。
SliceTool(vtkImageData* image, vtkRenderWindowInteractor* interactor, SliceAxis axis,
const geopro::core::ColorScale& cs, double vmin, double vmax,
const std::array<double, 3>& origin, const std::array<double, 3>& point1,
const std::array<double, 3>& point2);
~SliceTool();
SliceTool(const SliceTool&) = delete;
@ -39,6 +46,13 @@ public:
SliceAxis axis() const { return axis_; }
// 已保存切片(dd_slice)还原时打的归属标签;临时(交互新建)切片为空。供按 dsId 显隐/去重。
const std::string& dsId() const { return dsId_; }
void setDsId(std::string id) { dsId_ = std::move(id); }
// 取当前切面精确三点(保存用)。
void planePoints(double origin[3], double point1[3], double point2[3]) const;
// 当前切面法向(世界系单位向量)。
Vec3 normal() const;
// 当前切面中心origin
@ -57,17 +71,23 @@ public:
// 世界点到本切面(无限平面)的垂直距离绝对值。供 picker 命中判定"点在哪张切片上"。
double distanceToPlane(const Vec3& worldPoint) const;
// 当前切面重采样得到的 2D 标量影像(导出 dat 用widget 已释放则 nullptr。
vtkImageData* reslicedOutput() const;
// 关闭Off() 并解除 interactor 绑定(幂等)。
void close();
private:
SliceAxis axis_;
std::string dsId_; // 已保存切片归属标签(空=临时交互切片)
vtkImageData* image_; // 非拥有;生命周期由调用方(VtkSceneView 的 currentVolumeImage_)保证
// 把已存在的 image 接入 widget 的 producer须随 SliceTool 保活(否则构造后析构→管线断裂崩溃,评审 H1
vtkSmartPointer<vtkTrivialProducer> producer_;
vtkSmartPointer<vtkImagePlaneWidget> widget_;
vtkSmartPointer<vtkCallbackCommand> interactObserver_; // 监听 widget StartInteractionEvent → onInteract
void initWidget(const geopro::core::ColorScale& cs, double vmin, double vmax); // 共享 widget 配置
void applyMarginsAndActivate(); // 按 axis 设旋转锁 + On() + 装交互观察者
std::array<double, 6> imageBounds() const;
};

View File

@ -2,6 +2,7 @@
#include <functional>
#include <map>
#include <set>
#include <string>
#include <utility>
#include <vector>
@ -78,6 +79,13 @@ struct FakeView : I3dSceneView {
void render(bool is2D) override { ++renders; lastIs2D = is2D; }
void renderIncremental() override { ++renders; }
// 异常(#4测试不断言异常渲染空实现满足接口。
void addAnomaly(const core::Anomaly&) override {}
void removeAnomaly(const std::string&) override {}
void clearAnomalies() override {}
void setAnomalyVisible(const std::string&, bool) override {}
void setSelectedAnomaly(const std::string&) override {}
int props() const { return surveyLines + curtains + volumes + terrains; }
};
@ -106,13 +114,23 @@ struct FakeSceneRepo : data::I3dSceneRepository {
data::DsDimension dimensionOf(const data::DsRow&) const override {
return data::DsDimension::Dim3D;
}
void loadVolume(const std::string&, std::function<void(data::VolumeGrid)> onOk,
// 按数据集类型分流(取代旧全局 showVoxel/showCurtainvolumeIds 内 → 体素,否则帘面。
// 默认空 → 全走帘面(同旧默认行为);体素测试显式标记某 ds 为体素类型。
std::set<std::string> volumeIds;
bool isVolumeDataset(const std::string& dsId) const override {
return volumeIds.count(dsId) > 0;
}
void loadVolume(const std::string&,
std::function<void(data::VolumeGrid, core::ColorScale)> onOk,
OnError) override {
data::VolumeGrid g;
g.vol = core::ScalarVolume(2, 2, 2);
g.spacing = {{1.0, 1.0, 1.0}};
g.vmin = 0.0; g.vmax = 1.0;
onOk(std::move(g)); // 同步回调(异步壳)
core::ColorScale cs;
cs.addStop(0.0, core::Rgba{0, 0, 255, 255});
cs.addStop(1.0, core::Rgba{255, 0, 0, 255});
onOk(std::move(g), cs); // 同步回调(异步壳)
}
void loadSection(const std::string&, std::function<void(data::SectionData)> onOk,
OnError) override {
@ -177,16 +195,16 @@ TEST(VtkSceneController, View3DCurtainAddsCurtain) {
EXPECT_FALSE(view.lastIs2D);
}
// 3D + 帘面 + 体素 → 帘面 1 + 体素 1体素经异步回调进场)。
TEST(VtkSceneController, View3DWithVoxelAddsVolume) {
// 3D + 体素类型数据集 → 体素 1、帘面 0按类型分流体素 XOR 帘面,一个 ds 只一种表示)。
TEST(VtkSceneController, View3DVolumeDatasetAddsVolume) {
FakeDsRepo ds; FakeSceneRepo sc; FakeView view;
sc.volumeIds = {"ds1"}; // ds1 = 三维体类型 → 体素渲染路径
VtkSceneController c(ds, sc, view);
c.setViewMode(ViewMode::View3D);
c.setLayer(SceneLayer::Voxel, true);
c.setCheckedDatasets({"ds1"});
EXPECT_EQ(view.curtains, 1);
EXPECT_EQ(view.volumes, 1);
EXPECT_EQ(view.curtains, 0); // 体素数据集不再同时出帘面
}
// 3D + 地形 → 地形 1与勾选数据集无关地形是场景图层

View File

@ -1,10 +1,15 @@
#include <gtest/gtest.h>
#include <memory>
#include <string>
#include "api/Api3dRepository.hpp"
#include "geo/GeoLocalFrame.hpp"
#include "repo/I3dSceneRepository.hpp"
#include "repo/IAsyncDatasetRepository.hpp"
#include "repo/LocalSample3dRepository.hpp"
#include "repo/LocalSampleRepository.hpp"
#include "repo/VolumeBuildParams.hpp"
using namespace geopro::data;
@ -43,7 +48,8 @@ TEST(LocalSample3dRepo, LoadVolumeCallsBackWithValidGrid) {
bool ok = false;
std::string err;
VolumeGrid got;
repo.loadVolume("voxel1", [&](VolumeGrid g) { ok = true; got = std::move(g); },
repo.loadVolume("voxel1",
[&](VolumeGrid g, geopro::core::ColorScale) { ok = true; got = std::move(g); },
[&](const std::string& m) { err = m; });
ASSERT_TRUE(ok) << "loadVolume onErr: " << err;
@ -68,3 +74,51 @@ TEST(LocalSample3dRepo, LoadTerrainPathsCallsBack) {
EXPECT_FALSE(got.demPath.empty());
EXPECT_FALSE(got.imagePath.empty());
}
namespace {
// 极简桩volumeInfo/createVolume 不触碰 dsRepo_loadAsync 直接回空。
struct StubAsyncRepo : IAsyncDatasetRepository {
DetailLoad* loadAsync(const std::string&, const std::string&, int, int) override {
return nullptr;
}
};
} // namespace
// volumeInfocreateVolume 后、loadVolume 前 → 返回 true参数/名称正确loaded=false、无测点数。
TEST(Api3dRepo, VolumeInfoBeforeLoad) {
StubAsyncRepo dsRepo;
auto frame = std::make_shared<geopro::core::GeoLocalFrame>(22.0, 114.0);
Api3dRepository repo(dsRepo, frame);
VolumeBuildParams p;
p.sourceDatasetIds = {"src-a", "src-b"};
p.interpModel = VolumeBuildParams::Model::Idw;
p.cellXY = 2.0;
p.cellZ = 0.5;
p.power = 3.0;
p.maxDist = 5.0;
p.colorScaleId = "src-a";
const std::string id = repo.createVolume(p, "体A");
Api3dRepository::VolumeInfo info;
ASSERT_TRUE(repo.volumeInfo(id, info));
EXPECT_EQ(info.name, "体A");
EXPECT_FALSE(info.loaded);
EXPECT_EQ(info.pointCount, 0u);
ASSERT_EQ(info.params.sourceDatasetIds.size(), 2u);
EXPECT_EQ(info.params.sourceDatasetIds[0], "src-a");
EXPECT_DOUBLE_EQ(info.params.cellXY, 2.0);
EXPECT_DOUBLE_EQ(info.params.power, 3.0);
EXPECT_DOUBLE_EQ(info.params.maxDist, 5.0);
EXPECT_EQ(info.params.colorScaleId, "src-a");
}
// volumeInfo未知 dsId非三维体→ 返回 false不弹空对话框。
TEST(Api3dRepo, VolumeInfoUnknownIdReturnsFalse) {
StubAsyncRepo dsRepo;
auto frame = std::make_shared<geopro::core::GeoLocalFrame>(22.0, 114.0);
Api3dRepository repo(dsRepo, frame);
Api3dRepository::VolumeInfo info;
EXPECT_FALSE(repo.volumeInfo("not-a-volume", info));
}