Compare commits
14 Commits
b261374cc9
...
579bd46616
| Author | SHA1 | Date |
|---|---|---|
|
|
579bd46616 | |
|
|
05041299fa | |
|
|
b97ea68109 | |
|
|
c27bb6ab8f | |
|
|
9d8f36ff90 | |
|
|
c83f63a8f5 | |
|
|
f1309240a4 | |
|
|
44d31a824d | |
|
|
324d4ac605 | |
|
|
6210d615f3 | |
|
|
4e1b8e7635 | |
|
|
0e7a5c1bf7 | |
|
|
d56e35f93d | |
|
|
afdd98f416 |
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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 dock),Windows/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上色 PNG;dat=重采样标量网格)。切片持久化=`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点位置」「字体」弹框仍是 stub(main.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。
|
||||
|
|
|
|||
|
|
@ -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. 持久化 mock(Api3dRepository,挂三维体)
|
||||
|
||||
- `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,降耦合;可选接真实只读。
|
||||
|
|
@ -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. 核心数据模型
|
||||
|
|
|
|||
|
|
@ -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 三维视图交互(详情只读查阅,职责清晰)。
|
||||
|
|
@ -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/` — 最终安装包
|
||||
|
|
@ -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\)"
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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您要继续吗?
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
// 切片重采样为 2D(dims[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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
406
src/app/main.cpp
406
src/app/main.cpp
|
|
@ -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 高亮联动(R84,list→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 配准)──
|
||||
|
|
|
|||
|
|
@ -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); });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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("生成三维体"));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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; // 持久化 id(VTK 三维按 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"
|
||||
|
|
|
|||
|
|
@ -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*/) {
|
||||
// 删除该异常体分组下所有异常(mock:consortiumId == bodyId 的全删)。
|
||||
for (auto it = anomalies_.begin(); it != anomalies_.end();) {
|
||||
if (it->second.a.consortiumId == bodyId)
|
||||
it = anomalies_.erase(it);
|
||||
else
|
||||
++it;
|
||||
}
|
||||
onOk();
|
||||
}
|
||||
|
||||
// ── 任务管理(load 回空列表避免 UI 崩)──────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
// 三维体只读详情(属性对话框用):参数随时可取;统计(值域/网格/测点数/范围)仅
|
||||
// loaded(loadVolume 缓存过明细)时有效,未加载 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
|
||||
|
|
|
|||
|
|
@ -70,11 +70,15 @@ public:
|
|||
virtual void loadTerrainPaths(std::function<void(TerrainPaths)> onOk, OnError onErr) = 0;
|
||||
|
||||
// ── 切片数据集 CRUD(spec §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/名字 + 位姿 + 采样网格。
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ constexpr int kDashRepeat = 1;
|
|||
constexpr float kPointSize = 8.0F;
|
||||
|
||||
// 把一个异常的 localPts 灌入 points(x, -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
|
||||
|
|
|
|||
|
|
@ -18,4 +18,8 @@ namespace geopro::render {
|
|||
std::vector<vtkSmartPointer<vtkActor>> buildAnomalies(
|
||||
const std::vector<geopro::core::Anomaly>& anomalies);
|
||||
|
||||
// 单个异常 → 世界坐标 3D actor(VTK 三维视图):用 worldPts 直接建点/折线/闭合多边形(不翻 y、不压 z=0)。
|
||||
// 空几何(worldPts 为空)返回 nullptr。样式同 buildAnomalies(lineColor/width/dashed)。
|
||||
vtkSmartPointer<vtkActor> buildAnomaly3D(const geopro::core::Anomaly& anomaly);
|
||||
|
||||
} // namespace geopro::render
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)。
|
||||
|
|
|
|||
|
|
@ -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 接入 widget(widget 只暴露 SetInputConnection)。
|
||||
// producer_ 为成员,随 SliceTool 保活(局部变量会构造后即析构→管线断裂,评审 H1)。
|
||||
producer_ = vtkSmartPointer<vtkTrivialProducer>::New();
|
||||
producer_->SetOutput(image_);
|
||||
widget_->SetInputConnection(producer_->GetOutputPort());
|
||||
|
||||
widget_->SetInteractor(interactor);
|
||||
widget_->RestrictPlaneToVolumeOn(); // 切面限制在体内,滚轮推进不跑飞
|
||||
widget_->SetResliceInterpolateToLinear(); // reslice 线性插值出连续剖面(非 cutter 交线)
|
||||
widget_->TextureInterpolateOn();
|
||||
|
|
@ -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(角度不可调,符合 G22–G24)。
|
||||
// 上下=水平面=Z 法向;前后=Y 法向;左右=X 法向。
|
||||
switch (axis_) {
|
||||
case SliceAxis::UpDown:
|
||||
widget_->SetPlaneOrientationToZAxes();
|
||||
break;
|
||||
case SliceAxis::FrontBack:
|
||||
widget_->SetPlaneOrientationToYAxes();
|
||||
break;
|
||||
case SliceAxis::LeftRight:
|
||||
widget_->SetPlaneOrientationToXAxes();
|
||||
break;
|
||||
case SliceAxis::Oblique: {
|
||||
// 任意 45°(F25):vtkImagePlaneWidget 用 Origin/Point1/Point2 三角点定义平面
|
||||
// (无 SetNormal)。法向 = (Point1-Origin)×(Point2-Origin)。
|
||||
// 取法向 (sin45,0,cos45):in-plane 轴1 = Y(0,1,0),轴2 = XZ 内与法向正交方向 (cos45,0,-sin45)。
|
||||
// 以体中心为面心,沿两轴各展半个体范围,得一张斜插体的对角面(可继续交互旋转)。
|
||||
const auto b = imageBounds();
|
||||
const double cx = 0.5 * (b[0] + b[1]);
|
||||
const double cy = 0.5 * (b[2] + b[3]);
|
||||
const double cz = 0.5 * (b[4] + b[5]);
|
||||
const double hy = 0.5 * (b[3] - b[2]);
|
||||
// 轴2 半长取 X/Z 范围的较大者,保证面铺满体对角。
|
||||
const double hxz = 0.5 * std::max(b[1] - b[0], b[5] - b[4]);
|
||||
// 轴1 = +Y;轴2 = (cos45,0,-sin45)。
|
||||
const double a2x = kSqrt2Inv, a2z = -kSqrt2Inv;
|
||||
// Origin = center - 0.5*axis1 - 0.5*axis2(使 center 为面心)。
|
||||
const double ox = cx - 0.0 - a2x * hxz;
|
||||
const double oy = cy - hy - 0.0;
|
||||
const double oz = cz - 0.0 - a2z * hxz;
|
||||
widget_->SetOrigin(ox, oy, oz);
|
||||
widget_->SetPoint1(ox + 0.0, oy + 2.0 * hy, oz + 0.0); // 沿 +Y
|
||||
widget_->SetPoint2(ox + a2x * 2.0 * hxz, oy, oz + a2z * 2.0 * hxz); // 沿 (cos45,0,-sin45)
|
||||
widget_->UpdatePlacement();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
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(角度不可调,符合 G22–G24)。上下=Z 法向;前后=Y 法向;左右=X 法向。
|
||||
switch (axis_) {
|
||||
case SliceAxis::UpDown:
|
||||
widget_->SetPlaneOrientationToZAxes();
|
||||
break;
|
||||
case SliceAxis::FrontBack:
|
||||
widget_->SetPlaneOrientationToYAxes();
|
||||
break;
|
||||
case SliceAxis::LeftRight:
|
||||
widget_->SetPlaneOrientationToXAxes();
|
||||
break;
|
||||
case SliceAxis::Oblique: {
|
||||
// 任意 45°(F25):用 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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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/showCurtain):volumeIds 内 → 体素,否则帘面。
|
||||
// 默认空 → 全走帘面(同旧默认行为);体素测试显式标记某 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(与勾选数据集无关,地形是场景图层)。
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
// volumeInfo:createVolume 后、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));
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue